Archived
Merge branch 'main' into cleanup/plugin-api-scope
This commit is contained in:
+6
-4
@@ -386,20 +386,21 @@ function restartOrder(refs: ServiceRef[]): ServiceRef[] {
|
||||
}
|
||||
|
||||
function productionServiceDefinitions(options: InstallOptions, configPath: string, executables: ServiceExecutables): ServiceDefinition[] {
|
||||
const environment = configEnvironment(options, configPath);
|
||||
return [
|
||||
{
|
||||
...serviceRefs.sessiond,
|
||||
description: "PI WEB session daemon",
|
||||
shellCommand: `exec ${executables.sessiond.command}`,
|
||||
restart: "on-failure",
|
||||
environment: {},
|
||||
environment,
|
||||
},
|
||||
{
|
||||
...serviceRefs.web,
|
||||
description: "PI WEB server",
|
||||
shellCommand: `exec ${executables.web.command}`,
|
||||
restart: "on-failure",
|
||||
environment: configEnvironment(options, configPath),
|
||||
environment,
|
||||
after: ["sessiond"],
|
||||
wants: ["sessiond"],
|
||||
},
|
||||
@@ -429,13 +430,14 @@ function validateDevCheckout(root: string): void {
|
||||
}
|
||||
|
||||
function devServiceDefinitions(options: InstallOptions, configPath: string, root: string): ServiceDefinition[] {
|
||||
const environment = configEnvironment(options, configPath);
|
||||
return [
|
||||
{
|
||||
...serviceRefs.sessiond,
|
||||
description: "PI WEB session daemon (dev)",
|
||||
shellCommand: "exec npm run start:sessiond",
|
||||
restart: "never",
|
||||
environment: {},
|
||||
environment,
|
||||
workingDirectory: root,
|
||||
},
|
||||
{
|
||||
@@ -443,7 +445,7 @@ function devServiceDefinitions(options: InstallOptions, configPath: string, root
|
||||
description: "PI WEB UI dev server",
|
||||
shellCommand: `exec /usr/bin/env bash -c ${serviceShellQuote('trap "kill 0" EXIT; npm run dev:web & npm run dev:client & wait')}`,
|
||||
restart: "never",
|
||||
environment: configEnvironment(options, configPath),
|
||||
environment,
|
||||
after: ["sessiond"],
|
||||
wants: ["sessiond"],
|
||||
workingDirectory: root,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||
import { machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import { filesApi, machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "w/1",
|
||||
@@ -84,6 +84,26 @@ describe("session API compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("machine-scoped file suggestion API", () => {
|
||||
it("uses the workspace-scoped route when the caller has enabled workspace-scoped suggestions", async () => {
|
||||
const fetchMock = stubJsonFetch([]);
|
||||
|
||||
await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a", workspaceScoped: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked");
|
||||
});
|
||||
|
||||
it("falls back to the legacy cwd route when workspace-scoped suggestions are not enabled", async () => {
|
||||
const fetchMock = stubJsonFetch([]);
|
||||
|
||||
await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("machine-scoped terminal command-run API", () => {
|
||||
it("deletes workspaces through the selected machine scope", async () => {
|
||||
const fetchMock = stubJsonFetch(commandRun);
|
||||
|
||||
@@ -238,14 +238,21 @@ export interface FileSuggestionQueryOptions {
|
||||
mode?: "file" | "path" | undefined;
|
||||
scope?: "tracked" | "all" | undefined;
|
||||
machineId?: string | undefined;
|
||||
projectId?: string | undefined;
|
||||
workspaceId?: string | undefined;
|
||||
workspaceScoped?: boolean | undefined;
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
files: (cwd: string, query: string, options: FileSuggestionQueryOptions = {}) => {
|
||||
const params = new URLSearchParams({ cwd, q: query });
|
||||
const params = new URLSearchParams({ q: query });
|
||||
if (options.kind !== undefined) params.set("kind", options.kind);
|
||||
if (options.mode !== undefined) params.set("mode", options.mode);
|
||||
if (options.scope !== undefined) params.set("scope", options.scope);
|
||||
if (options.workspaceScoped === true && options.projectId !== undefined && options.workspaceId !== undefined) {
|
||||
return request(`${machinePrefix(options.machineId)}/projects/${encodeURIComponent(options.projectId)}/workspaces/${encodeURIComponent(options.workspaceId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion));
|
||||
}
|
||||
params.set("cwd", cwd);
|
||||
return request(`${machinePrefix(options.machineId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("federated route contract", () => {
|
||||
ignoreParseFailure(workspacesApi.deleteWorkspaceFile("p 1", "w 1", "README.md", machineId)),
|
||||
ignoreParseFailure(workspacesApi.moveWorkspaceFile("p 1", "w 1", "README.md", "docs/README.md", { overwrite: false }, machineId)),
|
||||
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })),
|
||||
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", projectId: "p 1", workspaceId: "w 1", machineId, workspaceScoped: true })),
|
||||
ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)),
|
||||
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
|
||||
@@ -64,6 +65,7 @@ describe("federated route contract", () => {
|
||||
ignoreParseFailure(sessionsApi.archiveWithDescendants(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.restore(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.deleteArchived(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.reloadSession(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.detachParent(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
|
||||
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
|
||||
|
||||
@@ -7,15 +7,15 @@ describe("API parsers", () => {
|
||||
expect(parsePiWebConfigResponse({
|
||||
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 } } } },
|
||||
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true },
|
||||
envOverrides: { host: true, port: false, allowedHosts: false },
|
||||
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"] }, maxUploadBytes: 1234 },
|
||||
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } },
|
||||
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: 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 } } } },
|
||||
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true },
|
||||
envOverrides: { host: true, port: false, allowedHosts: false },
|
||||
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"] }, maxUploadBytes: 1234 },
|
||||
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } },
|
||||
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -473,16 +473,43 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues {
|
||||
...optionalField("allowedHosts", optionalAllowedHosts(record["allowedHosts"])),
|
||||
...optionalField("shortcuts", optionalShortcuts(record["shortcuts"])),
|
||||
...optionalField("plugins", optionalPlugins(record["plugins"])),
|
||||
...optionalField("pathAccess", optionalPathAccess(record["pathAccess"])),
|
||||
...optionalField("maxUploadBytes", optionalNumber(record, "maxUploadBytes")),
|
||||
...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")),
|
||||
...optionalField("subsessions", optionalBoolean(record, "subsessions")),
|
||||
};
|
||||
}
|
||||
|
||||
function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === true) return true;
|
||||
if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value;
|
||||
if (isStringArray(value)) return value;
|
||||
throw new Error("Invalid PI WEB allowedHosts field");
|
||||
}
|
||||
|
||||
function optionalPathAccess(value: unknown): PiWebConfigValues["pathAccess"] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isRecord(value)) throw new Error("Invalid PI WEB pathAccess field");
|
||||
const allowedPaths = value["allowedPaths"];
|
||||
return {
|
||||
...optionalField("allowedPaths", optionalStringArray(allowedPaths, "pathAccess.allowedPaths")),
|
||||
};
|
||||
}
|
||||
|
||||
function optionalStringArray(value: unknown, field: string): string[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (isNonEmptyStringArray(value)) return value;
|
||||
throw new Error(`Invalid PI WEB ${field} field`);
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isNonEmptyStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
|
||||
}
|
||||
|
||||
function optionalShortcuts(value: unknown): PiWebShortcutConfig | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB shortcuts field");
|
||||
@@ -507,7 +534,7 @@ function optionalPlugins(value: unknown): PiWebPluginConfigMap | undefined {
|
||||
|
||||
function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
|
||||
const record = requireRecord(value);
|
||||
return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts") };
|
||||
return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions"), subsessions: requireBoolean(record, "subsessions") };
|
||||
}
|
||||
|
||||
export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse {
|
||||
@@ -751,6 +778,13 @@ export function parseReloaded(value: unknown): { reloaded: true } {
|
||||
return { reloaded: true };
|
||||
}
|
||||
|
||||
function optionalBoolean(record: Record<string, unknown>, key: string): boolean | undefined {
|
||||
const value = record[key];
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "boolean") throw new Error(`Invalid PI WEB ${key} field`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = record[key];
|
||||
if (value === undefined) return undefined;
|
||||
|
||||
@@ -162,9 +162,9 @@ function normalizeContent(content: unknown, message: unknown): ChatPart[] {
|
||||
if (type === "toolCall") {
|
||||
const toolName = getString(part, "name") ?? "tool";
|
||||
const args = getProperty(part, "arguments");
|
||||
const skillRead = toolName === "read" ? parseSkillReadPath(getString(args, "path")) : undefined;
|
||||
if (skillRead !== undefined) return [{ type: "skillRead", ...skillRead }];
|
||||
const toolCallId = getString(part, "id");
|
||||
const skillRead = toolName === "read" ? parseSkillReadPath(getString(args, "path")) : undefined;
|
||||
if (skillRead !== undefined) return [{ type: "skillRead", ...skillRead, ...(toolCallId === undefined ? {} : { toolCallId }) }];
|
||||
return [{ type: "toolCall", ...(toolCallId === undefined ? {} : { toolCallId }), toolName, summary: summarizeArgs(args), ...(args === undefined ? {} : { args }) }];
|
||||
}
|
||||
if (type === "image") {
|
||||
|
||||
@@ -101,8 +101,9 @@ describe("applyTranscriptEvent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces streamed skill reads when the finalized assistant message includes thinking", () => {
|
||||
it("replaces streamed thinking and skill reads when the finalized assistant message includes thinking", () => {
|
||||
const streamed: ChatLine[] = [
|
||||
{ role: "assistant", parts: [{ type: "thinking", text: "load skill" }] },
|
||||
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] },
|
||||
{ role: "tool", parts: [{ type: "toolResult", toolName: "read", text: "skill content", isError: false }] },
|
||||
];
|
||||
@@ -174,8 +175,8 @@ describe("applyTranscriptEvent", () => {
|
||||
messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "2", summary: "", args: { path: "/skills/sentry-cli/SKILL.md" } }) ?? messages;
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] },
|
||||
{ role: "skill", parts: [{ type: "skillRead", name: "sentry-cli", path: "/skills/sentry-cli/SKILL.md" }] },
|
||||
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md", toolCallId: "1" }] },
|
||||
{ role: "skill", parts: [{ type: "skillRead", name: "sentry-cli", path: "/skills/sentry-cli/SKILL.md", toolCallId: "2" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -185,6 +186,74 @@ describe("applyTranscriptEvent", () => {
|
||||
messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "1", summary: "", args: { path: "/skills/playwright/SKILL.md" } }) ?? messages;
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md", toolCallId: "1" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces multiple streamed skill reads with the finalized grouped skill message", () => {
|
||||
const firstTool: ChatLine = { role: "tool", parts: [{ type: "toolExecution", toolCallId: "read-1", toolName: "read", summary: "/skills/code-quality-architecture/SKILL.md", status: "success", resultText: "content" }] };
|
||||
const secondTool: ChatLine = { role: "tool", parts: [{ type: "toolExecution", toolCallId: "read-2", toolName: "read", summary: "/skills/relay/SKILL.md", status: "success", resultText: "content" }] };
|
||||
const thirdTool: ChatLine = { role: "tool", parts: [{ type: "toolExecution", toolCallId: "read-3", toolName: "read", summary: "/skills/skill-creator/SKILL.md", status: "success", resultText: "content" }] };
|
||||
const streamed: ChatLine[] = [
|
||||
{ role: "skill", parts: [{ type: "skillRead", name: "code-quality-architecture", path: "/skills/code-quality-architecture/SKILL.md", toolCallId: "read-1" }] },
|
||||
firstTool,
|
||||
{ role: "skill", parts: [{ type: "skillRead", name: "relay", path: "/skills/relay/SKILL.md", toolCallId: "read-2" }] },
|
||||
secondTool,
|
||||
{ role: "skill", parts: [{ type: "skillRead", name: "skill-creator", path: "/skills/skill-creator/SKILL.md", toolCallId: "read-3" }] },
|
||||
thirdTool,
|
||||
];
|
||||
|
||||
expect(applyTranscriptEvent(streamed, {
|
||||
type: "message.end",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "/skills/code-quality-architecture/SKILL.md" } },
|
||||
{ type: "toolCall", id: "read-2", name: "read", arguments: { path: "/skills/relay/SKILL.md" } },
|
||||
{ type: "toolCall", id: "read-3", name: "read", arguments: { path: "/skills/skill-creator/SKILL.md" } },
|
||||
],
|
||||
timestamp: "2026-05-09T12:00:00.000Z",
|
||||
},
|
||||
})).toEqual([
|
||||
{
|
||||
role: "skill",
|
||||
parts: [
|
||||
{ type: "skillRead", name: "code-quality-architecture", path: "/skills/code-quality-architecture/SKILL.md", toolCallId: "read-1" },
|
||||
{ type: "skillRead", name: "relay", path: "/skills/relay/SKILL.md", toolCallId: "read-2" },
|
||||
{ type: "skillRead", name: "skill-creator", path: "/skills/skill-creator/SKILL.md", toolCallId: "read-3" },
|
||||
],
|
||||
meta: { timestamp: "2026-05-09T12:00:00.000Z" },
|
||||
},
|
||||
firstTool,
|
||||
secondTool,
|
||||
thirdTool,
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores streamed skill read starts that are already in a finalized grouped skill message", () => {
|
||||
const messages: ChatLine[] = [
|
||||
{
|
||||
role: "skill",
|
||||
parts: [
|
||||
{ type: "skillRead", name: "code-quality-architecture", path: "/skills/code-quality-architecture/SKILL.md", toolCallId: "read-1" },
|
||||
{ type: "skillRead", name: "relay", path: "/skills/relay/SKILL.md", toolCallId: "read-2" },
|
||||
],
|
||||
meta: { timestamp: "2026-05-09T12:00:00.000Z" },
|
||||
},
|
||||
{ role: "tool", parts: [{ type: "toolExecution", toolCallId: "read-1", toolName: "read", summary: "/skills/code-quality-architecture/SKILL.md", status: "success", resultText: "content" }] },
|
||||
];
|
||||
|
||||
expect(applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "read-2", summary: "", args: { path: "/skills/relay/SKILL.md" } })).toEqual(messages);
|
||||
});
|
||||
|
||||
it("allows the same skill read after a user boundary", () => {
|
||||
const messages: ChatLine[] = [
|
||||
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] },
|
||||
textMessage("user", "load it again"),
|
||||
];
|
||||
|
||||
expect(applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "", summary: "", args: { path: "/skills/playwright/SKILL.md" } })).toEqual([
|
||||
...messages,
|
||||
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -35,8 +35,8 @@ function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[
|
||||
}
|
||||
|
||||
function applyFinalLine(messages: ChatLine[], displayEnded: ChatLine): ChatLine[] {
|
||||
const skillReadIndex = findMatchingSkillRead(messages, displayEnded);
|
||||
if (skillReadIndex >= 0) return [...messages.slice(0, skillReadIndex), displayEnded, ...messages.slice(skillReadIndex + 1)];
|
||||
const skillReadIndexes = findMatchingSkillReadIndexes(messages, displayEnded);
|
||||
if (skillReadIndexes.length > 0) return replaceSkillReadLines(messages, skillReadIndexes, displayEnded);
|
||||
const last = messages.at(-1);
|
||||
if (last?.role !== displayEnded.role) return [...messages, displayEnded];
|
||||
if (displayEnded.role === "assistant" || sameMessageText(last, displayEnded)) return [...messages.slice(0, -1), displayEnded];
|
||||
@@ -58,7 +58,9 @@ function parseSkillReadPath(path: string | undefined): { name: string; path: str
|
||||
|
||||
function appendToolExecutionStart(messages: ChatLine[], event: Extract<SessionUiEvent, { type: "tool.start" }>): ChatLine[] {
|
||||
const skillRead = event.toolName === "read" ? parseSkillReadPath(getString(event.args, "path")) : undefined;
|
||||
if (skillRead !== undefined) return appendLine(messages, { role: "skill", parts: [{ type: "skillRead", ...skillRead }] });
|
||||
if (skillRead !== undefined) {
|
||||
return appendLine(messages, { role: "skill", parts: [{ type: "skillRead", ...skillRead, ...(event.toolCallId === "" ? {} : { toolCallId: event.toolCallId }) }] });
|
||||
}
|
||||
|
||||
const part: ToolExecutionPart = {
|
||||
type: "toolExecution",
|
||||
@@ -151,16 +153,51 @@ function stringifyToolContent(content: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function findMatchingSkillRead(messages: ChatLine[], ended: ChatLine): number {
|
||||
function findMatchingSkillReadIndexes(messages: ChatLine[], ended: ChatLine): number[] {
|
||||
const endedReads = skillReads(ended);
|
||||
if (endedReads.length === 0) return -1;
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (message?.role !== "skill") continue;
|
||||
const reads = skillReads(message);
|
||||
if (sameSkillReads(reads, endedReads)) return index;
|
||||
if (endedReads.length === 0) return [];
|
||||
|
||||
const matchedIndexes: number[] = [];
|
||||
let readEnd = endedReads.length;
|
||||
const lowerBound = lastUserBoundaryIndex(messages) + 1;
|
||||
|
||||
for (let index = messages.length - 1; index >= lowerBound; index--) {
|
||||
const reads = skillReads(messages[index]);
|
||||
if (reads.length === 0) continue;
|
||||
const readStart = readEnd - reads.length;
|
||||
if (readStart < 0) continue;
|
||||
if (!sameSkillReads(reads, endedReads.slice(readStart, readEnd))) continue;
|
||||
matchedIndexes.unshift(index);
|
||||
readEnd = readStart;
|
||||
if (readEnd === 0) return matchedIndexes;
|
||||
}
|
||||
return -1;
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function replaceSkillReadLines(messages: ChatLine[], indexes: number[], replacement: ChatLine): ChatLine[] {
|
||||
const replacementIndexes = indexesWithAdjacentAssistantFragment(messages, indexes, replacement);
|
||||
const insertIndex = replacementIndexes[0];
|
||||
if (insertIndex === undefined) return messages;
|
||||
const replaced = new Set(replacementIndexes);
|
||||
const next: ChatLine[] = [];
|
||||
for (let index = 0; index < messages.length; index++) {
|
||||
if (index === insertIndex) next.push(replacement);
|
||||
const message = messages[index];
|
||||
if (message !== undefined && !replaced.has(index)) next.push(message);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function indexesWithAdjacentAssistantFragment(messages: ChatLine[], indexes: number[], replacement: ChatLine): number[] {
|
||||
const firstIndex = indexes[0];
|
||||
if (replacement.role !== "assistant" || firstIndex === undefined) return indexes;
|
||||
const previousIndex = firstIndex - 1;
|
||||
return isStreamedAssistantFragment(messages[previousIndex]) ? [previousIndex, ...indexes] : indexes;
|
||||
}
|
||||
|
||||
function isStreamedAssistantFragment(message: ChatLine | undefined): boolean {
|
||||
return message?.role === "assistant" && message.parts.length > 0 && message.parts.every((part) => part.type === "text" || part.type === "thinking");
|
||||
}
|
||||
|
||||
function skillReads(message: ChatLine | undefined): SkillRead[] {
|
||||
@@ -176,6 +213,7 @@ function sameSkillReads(left: SkillRead[], right: SkillRead[]): boolean {
|
||||
|
||||
function sameSkillRead(left: SkillRead, right: SkillRead | undefined): boolean {
|
||||
if (right === undefined) return false;
|
||||
if (left.toolCallId !== undefined && right.toolCallId !== undefined) return left.toolCallId === right.toolCallId;
|
||||
return normalizeSkillPath(left.path) === normalizeSkillPath(right.path) || left.name === right.name;
|
||||
}
|
||||
|
||||
@@ -201,11 +239,32 @@ function appendNewMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[]
|
||||
|
||||
function appendLine(messages: ChatLine[], line: ChatLine): ChatLine[] {
|
||||
const last = messages.at(-1);
|
||||
if (line.role === "skill" && sameSkillReads(skillReads(last), skillReads(line))) return messages;
|
||||
if (isDuplicateSkillLine(messages, line)) return messages;
|
||||
if (last?.role === line.role && line.role !== "skill") return [...messages.slice(0, -1), { ...last, parts: [...last.parts, ...line.parts] }];
|
||||
return [...messages, line];
|
||||
}
|
||||
|
||||
function isDuplicateSkillLine(messages: ChatLine[], line: ChatLine): boolean {
|
||||
const reads = skillReads(line);
|
||||
if (line.role !== "skill" || reads.length === 0) return false;
|
||||
const lowerBound = lastUserBoundaryIndex(messages) + 1;
|
||||
return reads.every((read) => hasMatchingSkillRead(messages, read, lowerBound));
|
||||
}
|
||||
|
||||
function hasMatchingSkillRead(messages: ChatLine[], read: SkillRead, lowerBound: number): boolean {
|
||||
for (let index = messages.length - 1; index >= lowerBound; index--) {
|
||||
if (skillReads(messages[index]).some((candidate) => sameSkillRead(candidate, read))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function lastUserBoundaryIndex(messages: ChatLine[]): number {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
if (messages[index]?.role === "user") return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ export class PiWebApp extends LitElement {
|
||||
private readonly handledWorkspaceDeletionRunIds = new Set<string>();
|
||||
private readonly terminalCommandRunRuntimes = new Map<string, TerminalCommandRunsInternalRuntime>();
|
||||
private machineNavigationRestoreSeq = 0;
|
||||
private navigationSelectionSeq = 0;
|
||||
private routeRestoreSeq = 0;
|
||||
private routeRestoreDepth = 0;
|
||||
private restoringRouteTerminalId: string | undefined;
|
||||
@@ -575,12 +576,16 @@ export class PiWebApp extends LitElement {
|
||||
if (tool === "core:workspace.git") await this.git.refreshGit();
|
||||
}
|
||||
|
||||
private async withChatScrollTransition(action: () => Promise<void>) {
|
||||
private async withChatScrollTransition(action: () => Promise<void>, shouldComplete: () => boolean = () => true) {
|
||||
this.chatView?.saveScrollPosition();
|
||||
await action();
|
||||
if (!shouldComplete()) return;
|
||||
await this.updateComplete;
|
||||
if (!shouldComplete()) return;
|
||||
await this.chatView?.updateComplete;
|
||||
if (!shouldComplete()) return;
|
||||
await nextFrame();
|
||||
if (!shouldComplete()) return;
|
||||
this.chatView?.restoreScrollPosition();
|
||||
if (this.shouldAutoFocusPrompt()) this.promptEditor?.focusInput();
|
||||
}
|
||||
@@ -1004,6 +1009,14 @@ export class PiWebApp extends LitElement {
|
||||
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload);
|
||||
}
|
||||
|
||||
private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean {
|
||||
if (machineId === "local") return true;
|
||||
// COMPAT-CAP workspace.fileSuggestions: remote machines without this
|
||||
// capability stay on the legacy cwd-based /files route.
|
||||
const runtime = this.state.machineRuntimes[machineId];
|
||||
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.workspaceFileSuggestions);
|
||||
}
|
||||
|
||||
private archivedDeleteUnavailableMessage(): string {
|
||||
const machineName = this.state.selectedMachine?.name ?? "this machine";
|
||||
return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`;
|
||||
@@ -1078,10 +1091,15 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private async selectNavigationItem(section: NavigationSection, nextTarget: NavigationFocusTarget, action: () => Promise<void>): Promise<void> {
|
||||
const seq = ++this.navigationSelectionSeq;
|
||||
const isCurrentSelection = () => seq === this.navigationSelectionSeq;
|
||||
|
||||
await this.withChatScrollTransition(async () => {
|
||||
this.navigationSections.advanceAfterSelection(section);
|
||||
await action();
|
||||
});
|
||||
}, isCurrentSelection);
|
||||
|
||||
if (!isCurrentSelection()) return;
|
||||
await this.focusNavigationTarget(nextTarget);
|
||||
}
|
||||
|
||||
@@ -1779,7 +1797,7 @@ export class PiWebApp extends LitElement {
|
||||
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
|
||||
${state.selectedSession ? html`
|
||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
|
||||
<status-bar .status=${state.status}></status-bar>
|
||||
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
|
||||
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
|
||||
|
||||
@@ -33,6 +33,9 @@ export class PromptEditor extends LitElement {
|
||||
@property() sessionId?: string;
|
||||
@property() cwd?: string;
|
||||
@property() machineId = "local";
|
||||
@property() projectId?: string;
|
||||
@property() workspaceId?: string;
|
||||
@property({ type: Boolean }) workspaceScopedFileSuggestions = false;
|
||||
@property({ type: Boolean }) canSteer = false;
|
||||
@property({ type: Boolean }) isCompacting = false;
|
||||
@property({ type: Boolean }) canStop = false;
|
||||
@@ -146,7 +149,7 @@ export class PromptEditor extends LitElement {
|
||||
<label class="attachment-delivery" title="How attachments are delivered to the agent">
|
||||
<select .value=${this.attachmentDelivery} @change=${(event: Event) => { this.changeDelivery(event); }}>
|
||||
<option value="inline">Attach to message</option>
|
||||
<option value="folder">Save to .pi-web/paste</option>
|
||||
<option value="folder">Save to .pi-web/attachments</option>
|
||||
</select>
|
||||
</label>
|
||||
` : null}
|
||||
@@ -298,7 +301,7 @@ export class PromptEditor extends LitElement {
|
||||
...(command.description === undefined ? {} : { description: command.description }),
|
||||
}));
|
||||
} else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") {
|
||||
const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId }).catch(emptyFileSuggestions);
|
||||
const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId, projectId: this.projectId, workspaceId: this.workspaceId, workspaceScoped: this.workspaceScopedFileSuggestions }).catch(emptyFileSuggestions);
|
||||
if (version !== this.requestVersion) return;
|
||||
this.completions = files
|
||||
.slice(0, 12)
|
||||
|
||||
@@ -228,10 +228,10 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete archived session" : this.archivedDeleteUnavailableMessage} ?disabled=${!this.canDeleteArchived} @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button>
|
||||
`
|
||||
: html`
|
||||
${this.canReload ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading" : "Reload session from disk"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload</button>` : null}
|
||||
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
|
||||
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
|
||||
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
|
||||
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
|
||||
${this.canReload ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading" : "Reload session from disk"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload</button>` : null}
|
||||
`}
|
||||
</div>
|
||||
` : null}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AppAction } from "../actions";
|
||||
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
|
||||
import type { SettingsSection } from "../settingsRoute";
|
||||
import "./settings/SettingsGeneralPanel";
|
||||
import "./settings/SettingsSessiondPanel";
|
||||
import "./settings/SettingsPluginsPanel";
|
||||
import "./settings/SettingsShortcutsPanel";
|
||||
|
||||
@@ -47,6 +48,7 @@ export class SettingsDialog extends LitElement {
|
||||
<div class="settings-body">
|
||||
<nav class="settings-nav" aria-label="Settings sections">
|
||||
${this.renderNavButton("general", "General", "Server config")}
|
||||
${this.renderNavButton("sessiond", "Session daemon", "Runtime settings")}
|
||||
${this.renderNavButton("plugins", "Plugins", "Enable and disable")}
|
||||
${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")}
|
||||
</nav>
|
||||
@@ -60,6 +62,19 @@ export class SettingsDialog extends LitElement {
|
||||
}
|
||||
|
||||
private renderActiveSection(): TemplateResult {
|
||||
if (this.section === "sessiond") {
|
||||
return html`
|
||||
<settings-sessiond-panel
|
||||
.configResponse=${this.configResponse}
|
||||
.loading=${this.loading}
|
||||
.saving=${this.saving}
|
||||
.error=${this.error}
|
||||
.savedMessage=${this.savedMessage}
|
||||
.onReload=${() => this.loadConfig()}
|
||||
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
|
||||
></settings-sessiond-panel>
|
||||
`;
|
||||
}
|
||||
if (this.section === "shortcuts") {
|
||||
return html`
|
||||
<settings-shortcuts-panel
|
||||
|
||||
@@ -562,7 +562,11 @@ export class TerminalPanel extends LitElement {
|
||||
.terminal-host .xterm { height: 100%; cursor: text; position: relative; user-select: none; }
|
||||
.terminal-host .xterm.focus, .terminal-host .xterm:focus { outline: none; }
|
||||
.terminal-host .xterm-helpers { position: absolute; top: 0; z-index: 5; }
|
||||
.terminal-host .xterm-helper-textarea { position: absolute !important; left: -9999em !important; top: 0 !important; width: 0 !important; height: 0 !important; min-width: 0 !important; min-height: 0 !important; padding: 0 !important; border: 0 !important; margin: 0 !important; opacity: 0 !important; z-index: -5 !important; white-space: nowrap !important; overflow: hidden !important; resize: none !important; outline: 0 !important; appearance: none !important; }
|
||||
/* Hide the helper textarea without using !important on the positional properties (left/top/width/height/z-index). xterm sets those inline during IME/dead-key composition (e.g. "~" on a Swedish layout) so the composition is positioned at the cursor and committed correctly; forcing them here would pin the textarea off-screen with zero size and break composition. */
|
||||
.terminal-host .xterm-helper-textarea { position: absolute; left: -9999em; top: 0; width: 0; height: 0; padding: 0 !important; border: 0 !important; margin: 0 !important; opacity: 0 !important; z-index: -5; white-space: nowrap !important; overflow: hidden !important; resize: none !important; outline: 0 !important; appearance: none !important; }
|
||||
/* The composition view shows pending dead-key/IME input. Without these rules it renders as a static block in the top-left corner instead of overlaying the cursor. */
|
||||
.terminal-host .composition-view { position: absolute; display: none; white-space: nowrap; z-index: 1; background: var(--pi-terminal-bg, #000); color: var(--pi-terminal-text, #fff); }
|
||||
.terminal-host .composition-view.active { display: block; }
|
||||
.terminal-host .xterm-viewport { position: absolute; inset: 0; overflow-y: scroll; cursor: default; background-color: var(--pi-terminal-bg); }
|
||||
.terminal-host .xterm-screen { position: relative; }
|
||||
.terminal-host .xterm-screen canvas { position: absolute; left: 0; top: 0; }
|
||||
|
||||
@@ -71,6 +71,14 @@ export class SettingsGeneralPanel extends LitElement {
|
||||
<small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>External filesystem roots</span>
|
||||
</span>
|
||||
<textarea .value=${this.draft.allowedPathsText} rows="4" placeholder="~/SDKs /opt/reference" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedPathsText: textAreaValue(event) }); }}></textarea>
|
||||
<small>Global allowlist for absolute <code>@</code> completions and file explorer reads outside a workspace. Enter one absolute path, Windows absolute path, or <code>~</code>-prefixed path per line. Leave empty to deny external paths by default.</small>
|
||||
</label>
|
||||
|
||||
${this.renderEffectiveConfig()}
|
||||
|
||||
<footer class="form-actions">
|
||||
@@ -102,6 +110,7 @@ export class SettingsGeneralPanel extends LitElement {
|
||||
<div><dt>Host</dt><dd>${effective.host ?? html`<span class="muted">127.0.0.1 default</span>`}</dd></div>
|
||||
<div><dt>Port</dt><dd>${effective.port ?? html`<span class="muted">8504 default</span>`}</dd></div>
|
||||
<div><dt>Allowed hosts</dt><dd>${formatAllowedHosts(effective.allowedHosts)}</dd></div>
|
||||
<div><dt>External roots</dt><dd>${formatAllowedPaths(effective.pathAccess?.allowedPaths)}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
`;
|
||||
@@ -173,6 +182,11 @@ function formatAllowedHosts(value: PiWebConfigValues["allowedHosts"]): string |
|
||||
return html`<span class="muted">Unset</span>`;
|
||||
}
|
||||
|
||||
function formatAllowedPaths(value: string[] | undefined): string | TemplateResult {
|
||||
if (value === undefined || value.length === 0) return html`<span class="muted">External paths denied</span>`;
|
||||
return value.join(", ");
|
||||
}
|
||||
|
||||
function inputValue(event: Event): string {
|
||||
return event.target instanceof HTMLInputElement ? event.target.value : "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { css, html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
|
||||
@customElement("settings-sessiond-panel")
|
||||
export class SettingsSessiondPanel extends LitElement {
|
||||
@property({ attribute: false }) configResponse: PiWebConfigResponse | undefined;
|
||||
@property({ type: Boolean }) loading = false;
|
||||
@property({ type: Boolean }) saving = false;
|
||||
@property() error = "";
|
||||
@property() savedMessage = "";
|
||||
@property({ attribute: false }) onReload?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
|
||||
|
||||
override render(): TemplateResult {
|
||||
const config = this.configResponse;
|
||||
const spawnOverridden = config?.envOverrides.spawnSessions === true;
|
||||
// On by default: the effective config is the source of truth for the toggle
|
||||
// state, so an unset config file still shows the feature as enabled.
|
||||
const effectiveSpawn = config?.effectiveConfig.spawnSessions !== false;
|
||||
const subsessionsOverridden = config?.envOverrides.subsessions === true;
|
||||
// Beta, off by default; also requires spawn to be enabled.
|
||||
const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn;
|
||||
return html`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Session daemon</h2>
|
||||
<p>These settings affect the long-lived session runtime. Changes are saved to the config file immediately but only take effect after the session daemon restarts.</p>
|
||||
</div>
|
||||
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
|
||||
</div>
|
||||
${this.renderMessages()}
|
||||
<div class="restart-note" role="note">Restart required: run <code>pi-web restart</code> (or restart the session daemon service) after changing these settings.</div>
|
||||
${config === undefined && this.loading ? html`<div class="loading-card">Loading configuration…</div>` : html`
|
||||
<div class="config-path-card">
|
||||
<span>Config file</span>
|
||||
<code>${config?.path ?? "Unknown"}</code>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Allow agents to start sessions</span>
|
||||
${spawnOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||
</span>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${effectiveSpawn}
|
||||
?disabled=${this.loading || this.saving || spawnOverridden}
|
||||
@change=${(event: Event) => { void this.toggleSpawnSessions(event); }}
|
||||
>
|
||||
<span>Enable the <code>spawn_session</code> tool</span>
|
||||
</label>
|
||||
<small>When enabled, LLMs can start new sessions, constrained to a workspace (any worktree) of the same registered project so every spawned session stays visible here. On by default.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Allow agents to start tracked subsessions</span>
|
||||
<span class="beta-badge">beta</span>
|
||||
${subsessionsOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||
</span>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${effectiveSubsessions}
|
||||
?disabled=${this.loading || this.saving || subsessionsOverridden || !effectiveSpawn}
|
||||
@change=${(event: Event) => { void this.toggleSubsessions(event); }}
|
||||
>
|
||||
<span>Enable the <code>spawn_subsession</code> tools</span>
|
||||
</label>
|
||||
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
|
||||
</div>
|
||||
<section class="effective-card" aria-label="Effective configuration summary">
|
||||
<h3>Effective after environment overrides</h3>
|
||||
<dl>
|
||||
<div><dt>Spawn sessions</dt><dd>${effectiveSpawn ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
|
||||
<div><dt>Subsessions</dt><dd>${effectiveSubsessions ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMessages(): TemplateResult | null {
|
||||
if (this.error !== "") return html`<div class="message error-message">${this.error}</div>`;
|
||||
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`;
|
||||
return null;
|
||||
}
|
||||
|
||||
private async toggleSpawnSessions(event: Event): Promise<void> {
|
||||
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
|
||||
const baseConfig = this.configResponse?.config ?? {};
|
||||
await this.onSave?.({ ...baseConfig, spawnSessions: enabled });
|
||||
}
|
||||
|
||||
private async toggleSubsessions(event: Event): Promise<void> {
|
||||
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
|
||||
const baseConfig = this.configResponse?.config ?? {};
|
||||
await this.onSave?.({ ...baseConfig, subsessions: enabled });
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { display: block; }
|
||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
|
||||
.section-heading > div { display: grid; gap: 6px; min-width: 0; }
|
||||
h2, h3, p { margin: 0; }
|
||||
h2 { font-size: 17px; line-height: 1.25; }
|
||||
h3 { font-size: 13px; line-height: 1.3; }
|
||||
p { color: var(--pi-muted); line-height: 1.45; }
|
||||
button, input { font: inherit; }
|
||||
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.secondary { flex: 0 0 auto; }
|
||||
.message, .loading-card, .config-path-card, .effective-card, .restart-note { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||
.message { margin-bottom: 12px; }
|
||||
.error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
|
||||
.success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
|
||||
.loading-card { color: var(--pi-muted); }
|
||||
.restart-note { margin-bottom: 14px; border-color: var(--pi-warning-border); color: var(--pi-warning); background: var(--pi-warning-surface); line-height: 1.45; }
|
||||
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; }
|
||||
.config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
|
||||
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
|
||||
.field { display: grid; gap: 7px; margin-bottom: 14px; }
|
||||
.field small { color: var(--pi-muted); line-height: 1.45; }
|
||||
.field-heading { display: flex; align-items: center; gap: 8px; }
|
||||
.toggle { display: flex; align-items: center; gap: 9px; cursor: pointer; }
|
||||
.toggle input { width: 16px; height: 16px; }
|
||||
.toggle input:disabled { cursor: not-allowed; }
|
||||
.override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; }
|
||||
.beta-badge { border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); background: var(--pi-bg); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.effective-card { display: grid; gap: 10px; }
|
||||
.effective-card dl { display: grid; gap: 8px; margin: 0; }
|
||||
.effective-card dl > div { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 12px; align-items: baseline; }
|
||||
dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.muted { color: var(--pi-muted); }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.section-heading { display: grid; gap: 12px; }
|
||||
.section-heading .secondary { justify-self: start; }
|
||||
.effective-card dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -3,27 +3,62 @@ import { configFromDraft, draftFromConfig } from "./settingsConfigDraft";
|
||||
|
||||
describe("settings config drafts", () => {
|
||||
it("converts PI WEB config values to editable general settings drafts", () => {
|
||||
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"] })).toEqual({
|
||||
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"], pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } })).toEqual({
|
||||
host: "0.0.0.0",
|
||||
port: "8504",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "example.local\n192.168.1.20",
|
||||
allowedPathsText: "/tmp\n~/SDKs",
|
||||
});
|
||||
expect(draftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all");
|
||||
});
|
||||
|
||||
it("converts drafts back to config while preserving shortcut and plugin preferences", () => {
|
||||
it("converts drafts back to config while preserving non-general preferences", () => {
|
||||
expect(configFromDraft({
|
||||
host: " 127.0.0.1 ",
|
||||
port: "9000",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "example.local, 192.168.1.20\n",
|
||||
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } } })).toEqual({
|
||||
allowedPathsText: "/tmp\n~/SDKs\n",
|
||||
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, maxUploadBytes: 1234 })).toEqual({
|
||||
host: "127.0.0.1",
|
||||
port: 9000,
|
||||
allowedHosts: ["example.local", "192.168.1.20"],
|
||||
shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null },
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
|
||||
maxUploadBytes: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it("removes global path access when the allowed paths field is cleared", () => {
|
||||
expect(configFromDraft({
|
||||
host: "",
|
||||
port: "",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "",
|
||||
allowedPathsText: "",
|
||||
}, { pathAccess: { allowedPaths: ["/old"] } })).not.toHaveProperty("pathAccess");
|
||||
});
|
||||
|
||||
it("rejects relative external paths before saving", () => {
|
||||
expect(() => configFromDraft({
|
||||
host: "",
|
||||
port: "",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "",
|
||||
allowedPathsText: "relative/path",
|
||||
})).toThrow("Allowed external paths must be absolute paths or start with ~");
|
||||
});
|
||||
|
||||
it("preserves the spawnSessions flag when saving general settings", () => {
|
||||
const result = configFromDraft({
|
||||
host: "",
|
||||
port: "",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "",
|
||||
allowedPathsText: "",
|
||||
}, { spawnSessions: true });
|
||||
expect(result.spawnSessions).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,11 @@ export interface ConfigDraft {
|
||||
port: string;
|
||||
allowedHostsMode: "list" | "all";
|
||||
allowedHostsText: string;
|
||||
allowedPathsText: string;
|
||||
}
|
||||
|
||||
export function emptyConfigDraft(): ConfigDraft {
|
||||
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
|
||||
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "", allowedPathsText: "" };
|
||||
}
|
||||
|
||||
export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
|
||||
@@ -17,6 +18,7 @@ export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
|
||||
port: config.port === undefined ? "" : String(config.port),
|
||||
allowedHostsMode: config.allowedHosts === true ? "all" : "list",
|
||||
allowedHostsText: Array.isArray(config.allowedHosts) ? config.allowedHosts.join("\n") : "",
|
||||
allowedPathsText: config.pathAccess?.allowedPaths?.join("\n") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +26,9 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
|
||||
const config: PiWebConfigValues = {
|
||||
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }),
|
||||
...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }),
|
||||
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
|
||||
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
|
||||
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
|
||||
};
|
||||
const host = draft.host.trim();
|
||||
const port = draft.port.trim();
|
||||
@@ -34,9 +39,22 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
|
||||
config.port = parsed;
|
||||
}
|
||||
config.allowedHosts = draft.allowedHostsMode === "all" ? true : parseAllowedHostsText(draft.allowedHostsText);
|
||||
const allowedPaths = parseAllowedPathsText(draft.allowedPathsText);
|
||||
if (allowedPaths.length > 0) config.pathAccess = { allowedPaths };
|
||||
return config;
|
||||
}
|
||||
|
||||
function parseAllowedHostsText(value: string): string[] {
|
||||
return value.split(/[\n,]/u).map((host) => host.trim()).filter((host) => host !== "");
|
||||
}
|
||||
|
||||
function parseAllowedPathsText(value: string): string[] {
|
||||
const paths = value.split("\n").map((path) => path.trim()).filter((path) => path !== "");
|
||||
const invalid = paths.find((path) => !isAbsoluteishAllowedPath(path));
|
||||
if (invalid !== undefined) throw new Error(`Allowed external paths must be absolute paths or start with ~: ${invalid}`);
|
||||
return paths;
|
||||
}
|
||||
|
||||
function isAbsoluteishAllowedPath(path: string): boolean {
|
||||
return path === "~" || path.startsWith("~/") || path.startsWith("~\\") || path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(path);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export type ChatPart =
|
||||
| { type: "image"; mimeType: string; data: string }
|
||||
| { type: "thinking"; text: string }
|
||||
| { type: "skillInvocation"; name: string; location: string; content: string }
|
||||
| { type: "skillRead"; name: string; path: string }
|
||||
| { type: "skillRead"; name: string; path: string; toolCallId?: string }
|
||||
| { type: "toolCall"; toolCallId?: string; toolName: string; summary: string; args?: unknown }
|
||||
| ToolExecutionPart
|
||||
| { type: "toolResult"; toolCallId?: string; toolName: string; text: string; isError: boolean; content?: unknown; details?: unknown }
|
||||
@@ -280,9 +280,9 @@ export const chatStyles = css`
|
||||
.activity-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; }
|
||||
.activity-dock.active .dot { animation: pulse 1s ease-in-out infinite; opacity: 1; }
|
||||
.msg { max-width: 100%; min-width: 0; box-sizing: border-box; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-border); border-left: 3px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); overflow: visible; }
|
||||
.msg.assistant { border-left-color: var(--pi-text-secondary); background: var(--pi-surface); }
|
||||
.msg.user { border-color: var(--pi-accent-border); border-left: 3px solid var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
.msg { max-width: 100%; min-width: 0; box-sizing: border-box; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); overflow: visible; }
|
||||
.msg.assistant { background: var(--pi-surface); }
|
||||
.msg.user { border-color: var(--pi-accent-border); background: var(--pi-selection-bg); }
|
||||
.msg.tool { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-warning); }
|
||||
.msg.tool-execution-shell { padding: 0; border: 0; background: transparent; color: var(--pi-text); }
|
||||
.msg.system { color: var(--pi-danger); }
|
||||
|
||||
@@ -142,6 +142,68 @@ describe("SessionController", () => {
|
||||
expect(state.selectedSession?.messageCount).toBe(3);
|
||||
});
|
||||
|
||||
it("adds a newly created session to the list when it belongs to the selected workspace", () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
const spawned: SessionInfo = { ...oldSession, id: "spawned-session", path: "/tmp/spawned-session.jsonl" };
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.created", session: spawned });
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual(["spawned-session", "old-session"]);
|
||||
});
|
||||
|
||||
it("ignores a created session for a different workspace or a duplicate id", () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession, id: "other", cwd: "/other-repo" } });
|
||||
controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession } });
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual(["old-session"]);
|
||||
});
|
||||
|
||||
it("does not duplicate a started session when its session.created broadcast races the HTTP response", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const socket = new FakeSocket();
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => {
|
||||
// Simulate the broadcast arriving before the HTTP response resolves.
|
||||
controller.applyGlobalEvent({ type: "session.created", session: started });
|
||||
return Promise.resolve(started);
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket },
|
||||
);
|
||||
|
||||
await controller.startSession();
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]);
|
||||
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("toggles the per-session sending state around an inline attachment send and forwards attachments", async () => {
|
||||
let resolvePrompt: (() => void) | undefined;
|
||||
let promptArgs: { attachments?: PromptAttachment[] } | undefined;
|
||||
@@ -207,7 +269,7 @@ describe("SessionController", () => {
|
||||
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
saveAttachments: (_session, sent) => { savedCalledWith = sent; return Promise.resolve([{ path: ".pi-web/paste/shot.png", mimeType: "image/png", size: 3 }]); },
|
||||
saveAttachments: (_session, sent) => { savedCalledWith = sent; return Promise.resolve([{ path: ".pi-web/attachments/shot.png", mimeType: "image/png", size: 3 }]); },
|
||||
prompt: (_session, text, _behavior, _machineId, sentAttachments) => { promptText = text; promptAttachments = sentAttachments; return Promise.resolve({ accepted: true }); },
|
||||
};
|
||||
const controller = new SessionController(
|
||||
@@ -221,7 +283,7 @@ describe("SessionController", () => {
|
||||
await controller.send("check this", undefined, attachments, "folder");
|
||||
|
||||
expect(savedCalledWith).toEqual(attachments);
|
||||
expect(promptText).toBe("check this\n\[email protected]/paste/shot.png");
|
||||
expect(promptText).toBe("check this\n\[email protected]/attachments/shot.png");
|
||||
expect(promptAttachments).toBeUndefined();
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
});
|
||||
@@ -246,6 +308,36 @@ describe("SessionController", () => {
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
});
|
||||
|
||||
it("sends slash commands without inserting an optimistic transcript line and toggles the sending state", async () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
let resolveCommand: (() => void) | undefined;
|
||||
const seenDuringCommand: Record<string, true>[] = [];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
runCommand: (_session, text) => new Promise((resolve) => {
|
||||
seenDuringCommand.push({ ...state.sendingPrompts });
|
||||
resolveCommand = () => { resolve(text.startsWith("/skill") ? { type: "done" } : { type: "done", message: "stats" }); };
|
||||
}),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const run = controller.send("/skill:skill-creator");
|
||||
expect(seenDuringCommand).toEqual([{ [oldSession.id]: true }]);
|
||||
// No raw command text is added to the transcript; the agent streams the
|
||||
// canonical expanded message back instead.
|
||||
expect(state.messages).toEqual([]);
|
||||
resolveCommand?.();
|
||||
await run;
|
||||
expect(state.messages).toEqual([]);
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps live message count updates when a cached new session becomes persisted", async () => {
|
||||
const cachedSession = markCachedNewSessionInfo(oldSession);
|
||||
let resolvePrompt: (() => void) | undefined;
|
||||
|
||||
@@ -51,6 +51,7 @@ export class SessionController {
|
||||
applyGlobalEvent(event: GlobalSessionEvent): void {
|
||||
if (event.type === "status.update") this.applyStatus(event.status);
|
||||
else if (event.type === "activity.update") this.applyActivity(event.activity);
|
||||
else if (event.type === "session.created") this.applyCreatedSession(event.session);
|
||||
else this.applySessionName(event.sessionId, event.name);
|
||||
}
|
||||
|
||||
@@ -93,7 +94,10 @@ export class SessionController {
|
||||
const session = await this.api.startSession(workspace.path, machineId);
|
||||
rememberCachedNewSession(session, machineId);
|
||||
const cachedSession = markCachedNewSessionInfo(session, machineId);
|
||||
this.setState({ sessions: [cachedSession, ...this.getState().sessions] });
|
||||
// Drop any entry the session.created broadcast may have inserted for this
|
||||
// same session before the HTTP response resolved, so the cached marker
|
||||
// (and its delete action) wins instead of leaving a duplicate badge.
|
||||
this.setState({ sessions: [cachedSession, ...this.getState().sessions.filter((candidate) => candidate.id !== cachedSession.id)] });
|
||||
await this.selectSession(cachedSession);
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
@@ -139,9 +143,7 @@ export class SessionController {
|
||||
const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]);
|
||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
||||
const history = this.transcripts.mergeHistory(transcriptKey, page);
|
||||
const isReceivingPartialStream = status.isStreaming;
|
||||
this.catchupStreamSessionId = isReceivingPartialStream ? session.id : undefined;
|
||||
this.setState({ ...history, isLoadingEarlierMessages: false, isReceivingPartialStream, status, activity: this.getState().sessionActivities[session.id], availableThinkingLevels: [] });
|
||||
this.setState({ ...history, isLoadingEarlierMessages: false, ...this.setStreamCatchup(status.isStreaming ? session.id : undefined), status, activity: this.getState().sessionActivities[session.id], availableThinkingLevels: [] });
|
||||
this.applyStatus(status);
|
||||
void this.refreshAvailableThinkingLevels();
|
||||
for (const event of buffered) this.applyEvent(event);
|
||||
@@ -231,12 +233,21 @@ export class SessionController {
|
||||
async runCommand(text: string) {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session || session.archived === true) return;
|
||||
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
||||
// Commands are not inserted into the transcript optimistically: a builtin
|
||||
// command produces its own result line, and a runtime/skill command is
|
||||
// forwarded to the agent, which streams back the canonical (expanded)
|
||||
// message. Inserting the raw text here would leave a line that doesn't
|
||||
// converge with server history and disappears on reload. Surface the same
|
||||
// per-session sending indicator that send() uses for the pre-receipt window.
|
||||
const sessionId = session.id;
|
||||
this.markSendingPrompt(sessionId, true);
|
||||
try {
|
||||
this.applyCommandResult(await this.api.runCommand(session, text, selectedMachineId(this.getState())));
|
||||
this.markCachedNewSessionPersisted(session);
|
||||
} catch (error) {
|
||||
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
|
||||
} finally {
|
||||
this.markSendingPrompt(sessionId, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,7 +519,7 @@ export class SessionController {
|
||||
...history,
|
||||
status,
|
||||
activity: this.getState().sessionActivities[sessionId],
|
||||
isReceivingPartialStream: status.isStreaming,
|
||||
...this.setStreamCatchup(status.isStreaming ? sessionId : undefined),
|
||||
});
|
||||
this.applyStatus(status);
|
||||
} catch (error) {
|
||||
@@ -576,6 +587,16 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
private applyCreatedSession(session: SessionInfo) {
|
||||
const state = this.getState();
|
||||
// Only surface sessions for the workspace currently in view; others are
|
||||
// picked up when their workspace is opened. Skip if already present (e.g.
|
||||
// the optimistic insert from startSession in this same tab).
|
||||
if (state.selectedWorkspace?.path !== session.cwd) return;
|
||||
if (state.sessions.some((candidate) => candidate.id === session.id)) return;
|
||||
this.setState({ sessions: [session, ...state.sessions] });
|
||||
}
|
||||
|
||||
private applyActivity(activity: SessionActivity) {
|
||||
this.setState({
|
||||
sessionActivities: { ...this.getState().sessionActivities, [activity.sessionId]: activity },
|
||||
@@ -593,7 +614,7 @@ export class SessionController {
|
||||
status: state.selectedSession?.id === status.sessionId ? status : state.status,
|
||||
activity: state.selectedSession?.id === status.sessionId && clearsStaleActivity ? undefined : state.activity,
|
||||
});
|
||||
if (this.catchupStreamSessionId === status.sessionId && !status.isStreaming) this.finishStreamCatchup(status.sessionId);
|
||||
if (!status.isStreaming) this.finishStreamCatchup(status.sessionId);
|
||||
}
|
||||
|
||||
private applySessionName(sessionId: string, name: string | undefined) {
|
||||
@@ -664,10 +685,24 @@ export class SessionController {
|
||||
this.pendingTranscriptFrame = undefined;
|
||||
}
|
||||
|
||||
// Stream catch-up is a single mode with two coupled facets that must never
|
||||
// drift: the private `catchupStreamSessionId` guard (which suppresses live
|
||||
// transcript events while we lack the in-flight message prefix) and the
|
||||
// public `isReceivingPartialStream` flag (which drives the "Catching up…"
|
||||
// badge). Route every mutation of the mode through this helper so the guard
|
||||
// and the badge can never disagree. Catch-up only ever applies to the
|
||||
// selected session, so an active session id always implies the badge is on.
|
||||
private setStreamCatchup(sessionId: string | undefined): Pick<AppState, "isReceivingPartialStream"> {
|
||||
this.catchupStreamSessionId = sessionId;
|
||||
return { isReceivingPartialStream: sessionId !== undefined };
|
||||
}
|
||||
|
||||
private finishStreamCatchup(sessionId: string) {
|
||||
if (this.catchupStreamSessionId !== sessionId) return;
|
||||
const isSelected = this.getState().selectedSession?.id === sessionId;
|
||||
const wasCatchingUp = this.catchupStreamSessionId === sessionId || (isSelected && this.getState().isReceivingPartialStream);
|
||||
if (!wasCatchingUp) return;
|
||||
this.catchupStreamSessionId = undefined;
|
||||
if (this.getState().selectedSession?.id === sessionId) this.setState({ isReceivingPartialStream: false });
|
||||
if (isSelected) this.setState({ isReceivingPartialStream: false });
|
||||
void this.refreshMessages(sessionId);
|
||||
}
|
||||
|
||||
|
||||
@@ -131,12 +131,12 @@ export class RealtimeSocket {
|
||||
|
||||
function isSessionUiEvent(event: unknown): event is SessionUiEvent {
|
||||
const type = eventType(event);
|
||||
return ["message.append", "assistant.delta", "assistant.thinking.delta", "tool.start", "tool.update", "tool.end", "shell.start", "shell.chunk", "shell.end", "agent.start", "agent.end", "message.end", "status.update", "activity.update", "command.output", "session.error", "session.name", "pi.event"].includes(type);
|
||||
return ["message.append", "assistant.delta", "assistant.thinking.delta", "tool.start", "tool.update", "tool.end", "shell.start", "shell.chunk", "shell.end", "agent.start", "agent.end", "message.end", "status.update", "activity.update", "command.output", "session.error", "session.name", "session.created", "pi.event"].includes(type);
|
||||
}
|
||||
|
||||
function isGlobalSessionEvent(event: unknown): event is GlobalSessionEvent {
|
||||
const type = eventType(event);
|
||||
return type === "status.update" || type === "activity.update" || type === "session.name";
|
||||
return type === "status.update" || type === "activity.update" || type === "session.name" || type === "session.created";
|
||||
}
|
||||
|
||||
function isRealtimeEvent(event: unknown): event is RealtimeEvent {
|
||||
|
||||
@@ -35,6 +35,8 @@ function installWindow(href: string): { pushed: string[]; replaced: string[] } {
|
||||
describe("settings route helpers", () => {
|
||||
it("parses supported settings deep links and aliases", () => {
|
||||
expect(parseSettingsSection("general")).toBe("general");
|
||||
expect(parseSettingsSection("sessiond")).toBe("sessiond");
|
||||
expect(parseSettingsSection("sessions")).toBe("sessiond");
|
||||
expect(parseSettingsSection("plugins")).toBe("plugins");
|
||||
expect(parseSettingsSection("shortcuts")).toBe("shortcuts");
|
||||
expect(parseSettingsSection("keyboard")).toBe("shortcuts");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SettingsSection = "general" | "plugins" | "shortcuts";
|
||||
export type SettingsSection = "general" | "sessiond" | "plugins" | "shortcuts";
|
||||
|
||||
export function readSettingsSection(): SettingsSection | undefined {
|
||||
return parseSettingsSection(new URLSearchParams(window.location.search).get("settings"));
|
||||
@@ -17,6 +17,7 @@ export function writeSettingsSection(section: SettingsSection | undefined, optio
|
||||
|
||||
export function parseSettingsSection(value: string | null): SettingsSection | undefined {
|
||||
if (value === "general") return "general";
|
||||
if (value === "sessiond" || value === "sessions") return "sessiond";
|
||||
if (value === "plugins") return "plugins";
|
||||
if (value === "shortcuts" || value === "keyboard" || value === "keyboard-shortcuts") return "shortcuts";
|
||||
return undefined;
|
||||
|
||||
+42
-6
@@ -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, loadPiWebConfig, maxUploadBytes, savePiWebConfig } from "./config.js";
|
||||
import { DEFAULT_MAX_UPLOAD_BYTES, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
|
||||
|
||||
let tempDir: string;
|
||||
let configPath: string;
|
||||
@@ -18,18 +18,18 @@ afterEach(async () => {
|
||||
|
||||
describe("PI WEB config persistence", () => {
|
||||
it("writes and reads the configured PI WEB config path", () => {
|
||||
const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } } }, testOptions());
|
||||
const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } }, testOptions());
|
||||
|
||||
expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } } } });
|
||||
expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } } });
|
||||
expect(loadPiWebConfig(testOptions())).toEqual(saved);
|
||||
});
|
||||
|
||||
it("preserves unrelated config keys while replacing managed keys", async () => {
|
||||
await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, plugins: { info: { enabled: false } }, future: { enabled: true } }, null, 2)}\n`, "utf8");
|
||||
await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, future: { enabled: true } }, null, 2)}\n`, "utf8");
|
||||
|
||||
savePiWebConfig({ port: 9000, allowedHosts: [] }, testOptions());
|
||||
savePiWebConfig({ port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] } }, testOptions());
|
||||
|
||||
expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [] });
|
||||
expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] } });
|
||||
});
|
||||
|
||||
it("rejects invalid plugin config", async () => {
|
||||
@@ -38,6 +38,12 @@ describe("PI WEB config persistence", () => {
|
||||
expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config plugin enabled values must be booleans");
|
||||
});
|
||||
|
||||
it("rejects invalid path access config", async () => {
|
||||
await writeFile(configPath, `${JSON.stringify({ pathAccess: { allowedPaths: [""] } }, null, 2)}\n`, "utf8");
|
||||
|
||||
expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings");
|
||||
});
|
||||
|
||||
it("persists and reads maxUploadBytes", () => {
|
||||
savePiWebConfig({ maxUploadBytes: 1234 }, testOptions());
|
||||
expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234);
|
||||
@@ -58,6 +64,36 @@ describe("maxUploadBytes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawnSessionsEnabled", () => {
|
||||
it("is on by default when nothing is configured", () => {
|
||||
expect(spawnSessionsEnabled({}, {})).toBe(true);
|
||||
});
|
||||
|
||||
it("honors an explicit config opt-out", () => {
|
||||
expect(spawnSessionsEnabled({}, { spawnSessions: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the env var override the config in both directions", () => {
|
||||
expect(spawnSessionsEnabled({ PI_WEB_SPAWN_SESSIONS: "0" }, { spawnSessions: true })).toBe(false);
|
||||
expect(spawnSessionsEnabled({ PI_WEB_SPAWN_SESSIONS: "1" }, { spawnSessions: false })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subsessionsEnabled", () => {
|
||||
it("is off by default while the capability is in beta", () => {
|
||||
expect(subsessionsEnabled({}, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("honors an explicit config opt-in", () => {
|
||||
expect(subsessionsEnabled({}, { subsessions: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("lets the env var override the config in both directions", () => {
|
||||
expect(subsessionsEnabled({ PI_WEB_SUBSESSIONS: "1" }, { subsessions: false })).toBe(true);
|
||||
expect(subsessionsEnabled({ PI_WEB_SUBSESSIONS: "0" }, { subsessions: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function testOptions(): { env: NodeJS.ProcessEnv } {
|
||||
return { env: { PI_WEB_CONFIG: configPath } };
|
||||
}
|
||||
|
||||
@@ -82,6 +82,11 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf
|
||||
...(port !== undefined && port !== "" ? { port: parsePort(port, "PI_WEB_PORT") } : {}),
|
||||
...(allowedHosts !== undefined && allowedHosts !== "" ? { allowedHosts: parseAllowedHostsEnv(allowedHosts) } : {}),
|
||||
...(maxUpload !== undefined && maxUpload !== "" ? { maxUploadBytes: parseMaxUploadBytes(maxUpload, "PI_WEB_MAX_UPLOAD_BYTES") } : {}),
|
||||
// Always resolved (on by default) so the effective config is the single
|
||||
// source of truth for the runtime state and the settings UI toggle.
|
||||
spawnSessions: spawnSessionsEnabled(env, loaded.config),
|
||||
// Beta capability, resolved off by default.
|
||||
subsessions: subsessionsEnabled(env, loaded.config),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -96,7 +101,10 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
|
||||
delete existing["allowedHosts"];
|
||||
delete existing["shortcuts"];
|
||||
delete existing["plugins"];
|
||||
delete existing["pathAccess"];
|
||||
delete existing["maxUploadBytes"];
|
||||
delete existing["spawnSessions"];
|
||||
delete existing["subsessions"];
|
||||
const merged = { ...existing, ...piWebConfigRecord(normalized) };
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
||||
@@ -117,7 +125,10 @@ function piWebConfigRecord(config: PiWebConfig): Record<string, unknown> {
|
||||
...(config.allowedHosts !== undefined ? { allowedHosts: config.allowedHosts } : {}),
|
||||
...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}),
|
||||
...(config.plugins !== undefined ? { plugins: config.plugins } : {}),
|
||||
...(config.pathAccess !== undefined ? { pathAccess: config.pathAccess } : {}),
|
||||
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
|
||||
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
|
||||
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,7 +139,10 @@ function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebCo
|
||||
...(value["allowedHosts"] !== undefined ? { allowedHosts: parseAllowedHosts(value["allowedHosts"], path) } : {}),
|
||||
...(value["shortcuts"] !== undefined ? { shortcuts: parseShortcuts(value["shortcuts"], path) } : {}),
|
||||
...(value["plugins"] !== undefined ? { plugins: parsePlugins(value["plugins"], path) } : {}),
|
||||
...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}),
|
||||
...(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) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,6 +152,42 @@ function parseMaxUploadBytes(value: unknown, key: string, path = "environment"):
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function parseSpawnSessions(value: unknown, path: string): boolean {
|
||||
if (typeof value !== "boolean") throw new Error(`PI WEB config spawnSessions must be a boolean: ${path}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether LLMs may start new sessions via the spawn_session tool. On by default
|
||||
* (spawned sessions appear in the session list, so humans notice them); set the
|
||||
* env var `PI_WEB_SPAWN_SESSIONS` or the `spawnSessions` config key to `false`
|
||||
* to disable. The env var takes precedence over the config file.
|
||||
*/
|
||||
export function spawnSessionsEnabled(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): boolean {
|
||||
const fromEnv = env["PI_WEB_SPAWN_SESSIONS"];
|
||||
if (fromEnv !== undefined && fromEnv !== "") return fromEnv === "1" || fromEnv.toLowerCase() === "true";
|
||||
return config.spawnSessions ?? true;
|
||||
}
|
||||
|
||||
function parseSubsessions(value: unknown, path: string): boolean {
|
||||
if (typeof value !== "boolean") throw new Error(`PI WEB config subsessions must be a boolean: ${path}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beta: whether LLMs may start tracked child sessions via the spawn_subsession
|
||||
* family of tools. Off by default while the capability stabilizes, so it can
|
||||
* ship in main without affecting releases; enable with the env var
|
||||
* `PI_WEB_SUBSESSIONS` or the `subsessions` config key. The env var takes
|
||||
* precedence over the config file. Subsessions also require spawnSessions to be
|
||||
* enabled (they share the same project-scope resolver).
|
||||
*/
|
||||
export function subsessionsEnabled(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): boolean {
|
||||
const fromEnv = env["PI_WEB_SUBSESSIONS"];
|
||||
if (fromEnv !== undefined && fromEnv !== "") return fromEnv === "1" || fromEnv.toLowerCase() === "true";
|
||||
return config.subsessions ?? false;
|
||||
}
|
||||
|
||||
function parseString(value: unknown, key: string, path: string): string {
|
||||
if (typeof value !== "string" || value === "") throw new Error(`PI WEB config ${key} must be a non-empty string: ${path}`);
|
||||
return value;
|
||||
@@ -162,6 +212,19 @@ function parseAllowedHostsEnv(value: string): string[] | true {
|
||||
return value.split(",").map((host) => host.trim()).filter((host) => host !== "");
|
||||
}
|
||||
|
||||
export function parsePathAccessConfig(value: unknown, path: string): NonNullable<PiWebConfigValues["pathAccess"]> {
|
||||
if (!isRecord(value)) throw new Error(`PI WEB config pathAccess must be an object: ${path}`);
|
||||
const allowedPaths = value["allowedPaths"];
|
||||
return {
|
||||
...(allowedPaths !== undefined ? { allowedPaths: parseAllowedPaths(allowedPaths, path) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAllowedPaths(value: unknown, path: string): string[] {
|
||||
if (!isNonEmptyStringArray(value)) throw new Error(`PI WEB config pathAccess.allowedPaths must be an array of non-empty strings: ${path}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseShortcuts(value: unknown, path: string): Record<string, string | null> {
|
||||
if (!isRecord(value)) throw new Error(`PI WEB config shortcuts must be an object: ${path}`);
|
||||
return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => {
|
||||
|
||||
+111
-49
@@ -15,6 +15,7 @@ import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
|
||||
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
import type { Project, Workspace } from "./types.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
@@ -22,12 +23,14 @@ let tempDir: string;
|
||||
let projectDir: string;
|
||||
let remoteClient: MachineClient | undefined;
|
||||
let sessionDaemonRequests: CapturedSessionDaemonRequest[];
|
||||
let piWebConfig: PiWebConfigValues;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-")));
|
||||
projectDir = join(tempDir, "project");
|
||||
remoteClient = undefined;
|
||||
sessionDaemonRequests = [];
|
||||
piWebConfig = {};
|
||||
app = await buildApp({
|
||||
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
|
||||
workspaces: new WorkspaceService(),
|
||||
@@ -48,6 +51,7 @@ beforeEach(async () => {
|
||||
}),
|
||||
}),
|
||||
sessionDaemon: fakeSessionDaemon(),
|
||||
config: fakeConfigService(),
|
||||
piWebPlugins: {
|
||||
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
|
||||
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
|
||||
@@ -206,6 +210,23 @@ describe("buildApp", () => {
|
||||
expect(request).toHaveBeenCalledWith("POST", "/api/projects/p1/workspaces/w1/terminal-command-runs", createBody);
|
||||
});
|
||||
|
||||
it("proxies remote session reloads through the selected machine", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const request = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Readable.from([JSON.stringify({ reloaded: true })]),
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/reload`, payload: { cwd: "/repo" } });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ reloaded: true });
|
||||
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/reload", { cwd: "/repo" });
|
||||
});
|
||||
|
||||
it("forwards remote JSON request bodies and normalizes remote timeouts", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
@@ -479,6 +500,64 @@ describe("buildApp", () => {
|
||||
expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" });
|
||||
});
|
||||
|
||||
it("keeps normal file suggestions workspace-local when path access config is invalid", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
payload: { name: "Local Suggestions", path: projectDir, create: true },
|
||||
});
|
||||
expect(addResponse.statusCode).toBe(200);
|
||||
await writeFile(join(projectDir, "sdk.md"), "local sdk\n");
|
||||
await mkdir(join(projectDir, ".pi-web"), { recursive: true });
|
||||
await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [""] } }, null, 2)}\n`);
|
||||
|
||||
const response = await app.inject({ method: "GET", url: `/api/files?cwd=${encodeURIComponent(projectDir)}&q=sdk&scope=all` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual([{ path: "sdk.md", kind: "other" }]);
|
||||
});
|
||||
|
||||
it("serves project-configured allowed external files through the workspace explorer", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
payload: { name: "External", path: projectDir, create: true },
|
||||
});
|
||||
const project = addResponse.json<Project>();
|
||||
const externalDir = join(tempDir, "external-docs");
|
||||
const deniedFile = join(tempDir, "secret.md");
|
||||
await mkdir(externalDir);
|
||||
await writeFile(join(externalDir, "sdk.md"), "external sdk\n");
|
||||
await writeFile(deniedFile, "secret\n");
|
||||
await mkdir(join(projectDir, ".pi-web"), { recursive: true });
|
||||
await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [externalDir] } }, null, 2)}\n`);
|
||||
|
||||
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
|
||||
const workspace = workspacesResponse.json<Workspace[]>()[0];
|
||||
if (workspace === undefined) throw new Error("Expected workspace");
|
||||
|
||||
const fileResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(join(externalDir, "sdk.md"))}` });
|
||||
const treeResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/tree?path=${encodeURIComponent(externalDir)}` });
|
||||
const suggestionResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=${encodeURIComponent(join(externalDir, "s"))}` });
|
||||
const localSuggestionResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=sdk` });
|
||||
const deniedResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(deniedFile)}` });
|
||||
|
||||
expect(fileResponse.statusCode).toBe(200);
|
||||
expect(fileResponse.json()).toMatchObject({ path: join(externalDir, "sdk.md"), content: "external sdk\n", binary: false });
|
||||
expect(treeResponse.statusCode).toBe(200);
|
||||
expect(treeResponse.json()).toMatchObject({
|
||||
path: externalDir,
|
||||
entries: [expect.objectContaining({ name: "sdk.md", path: join(externalDir, "sdk.md"), type: "file" })],
|
||||
truncated: false,
|
||||
});
|
||||
expect(suggestionResponse.statusCode).toBe(200);
|
||||
expect(suggestionResponse.json()).toEqual([{ path: join(externalDir, "sdk.md"), kind: "other" }]);
|
||||
expect(localSuggestionResponse.statusCode).toBe(200);
|
||||
expect(localSuggestionResponse.json()).toEqual([]);
|
||||
expect(deniedResponse.statusCode).toBe(400);
|
||||
expect(deniedResponse.json()).toEqual({ error: "Path is outside allowed paths" });
|
||||
});
|
||||
|
||||
it("writes workspace files through the HTTP contract", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
@@ -490,7 +569,6 @@ describe("buildApp", () => {
|
||||
const workspace = workspacesResponse.json<Workspace[]>()[0];
|
||||
if (workspace === undefined) throw new Error("Expected workspace");
|
||||
|
||||
// Write a text file
|
||||
const writeTextResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`,
|
||||
@@ -499,15 +577,11 @@ describe("buildApp", () => {
|
||||
});
|
||||
expect(writeTextResponse.statusCode).toBe(200);
|
||||
expect(writeTextResponse.json()).toMatchObject({ path: "hello.txt", created: true });
|
||||
const writeBody = writeTextResponse.json<Record<string, unknown>>();
|
||||
expect(typeof writeBody['size']).toBe("number");
|
||||
expect(typeof writeTextResponse.json<{ size: unknown }>().size).toBe("number");
|
||||
|
||||
// Read it back
|
||||
const readResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}` });
|
||||
const readBody = readResponse.json<Record<string, unknown>>();
|
||||
expect(readBody['content']).toBe("hello world");
|
||||
expect(readResponse.json<{ content: unknown }>().content).toBe("hello world");
|
||||
|
||||
// Write binary content
|
||||
const writeBinaryResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("image.png")}`,
|
||||
@@ -517,7 +591,6 @@ describe("buildApp", () => {
|
||||
expect(writeBinaryResponse.statusCode).toBe(200);
|
||||
expect(writeBinaryResponse.json()).toMatchObject({ path: "image.png", created: true });
|
||||
|
||||
// Create intermediate directories (default)
|
||||
const writeDeepResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}`,
|
||||
@@ -526,12 +599,9 @@ describe("buildApp", () => {
|
||||
});
|
||||
expect(writeDeepResponse.statusCode).toBe(200);
|
||||
|
||||
// Verify the nested file was written
|
||||
const readDeepResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}` });
|
||||
const readDeepBody = readDeepResponse.json<Record<string, unknown>>();
|
||||
expect(readDeepBody['content']).toBe("deep content");
|
||||
expect(readDeepResponse.json<{ content: unknown }>().content).toBe("deep content");
|
||||
|
||||
// Overwrite an existing file (default)
|
||||
const overwriteResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`,
|
||||
@@ -540,7 +610,6 @@ describe("buildApp", () => {
|
||||
});
|
||||
expect(overwriteResponse.json()).toMatchObject({ path: "hello.txt", created: false });
|
||||
|
||||
// Reject overwrite=false when file exists
|
||||
const noOverwriteResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}&overwrite=false`,
|
||||
@@ -548,10 +617,8 @@ describe("buildApp", () => {
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(noOverwriteResponse.statusCode).toBe(400);
|
||||
const noOverwriteBody = noOverwriteResponse.json<Record<string, unknown>>();
|
||||
expect(noOverwriteBody['error']).toContain("File already exists");
|
||||
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
|
||||
|
||||
// Reject path traversal
|
||||
const traversalResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`,
|
||||
@@ -559,10 +626,8 @@ describe("buildApp", () => {
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(traversalResponse.statusCode).toBe(400);
|
||||
const traversalBody = traversalResponse.json<Record<string, unknown>>();
|
||||
expect(traversalBody['error']).toContain("Path traversal");
|
||||
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
|
||||
|
||||
// Reject missing path
|
||||
const noPathResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
|
||||
@@ -570,10 +635,8 @@ describe("buildApp", () => {
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
expect(noPathResponse.statusCode).toBe(400);
|
||||
const noPathBody = noPathResponse.json<Record<string, unknown>>();
|
||||
expect(noPathBody['error']).toContain("path query parameter is required");
|
||||
expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required");
|
||||
|
||||
// Fail when createDirs=false and parent directory does not exist
|
||||
const noDirsResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("nonexistent/parent/file.txt")}&createDirs=false`,
|
||||
@@ -582,7 +645,6 @@ describe("buildApp", () => {
|
||||
});
|
||||
expect(noDirsResponse.statusCode).toBe(400);
|
||||
|
||||
// Reject writing to a directory path
|
||||
await mkdir(join(projectDir, "subdir"), { recursive: true });
|
||||
const dirWriteResponse = await app.inject({
|
||||
method: "PUT",
|
||||
@@ -604,7 +666,6 @@ describe("buildApp", () => {
|
||||
const workspace = workspacesResponse.json<Workspace[]>()[0];
|
||||
if (workspace === undefined) throw new Error("Expected workspace");
|
||||
|
||||
// Write a file first so we can delete it
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`,
|
||||
@@ -612,7 +673,6 @@ describe("buildApp", () => {
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
|
||||
// Delete existing file
|
||||
const deleteResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`,
|
||||
@@ -620,7 +680,6 @@ describe("buildApp", () => {
|
||||
expect(deleteResponse.statusCode).toBe(200);
|
||||
expect(deleteResponse.json()).toMatchObject({ path: "to-delete.txt", existed: true });
|
||||
|
||||
// Delete non-existent file (idempotent)
|
||||
const deleteMissingResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("missing.txt")}`,
|
||||
@@ -628,23 +687,19 @@ describe("buildApp", () => {
|
||||
expect(deleteMissingResponse.statusCode).toBe(200);
|
||||
expect(deleteMissingResponse.json()).toMatchObject({ path: "missing.txt", existed: false });
|
||||
|
||||
// Reject path traversal
|
||||
const traversalResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`,
|
||||
});
|
||||
expect(traversalResponse.statusCode).toBe(400);
|
||||
const deleteTraversalBody = traversalResponse.json<Record<string, unknown>>();
|
||||
expect(deleteTraversalBody['error']).toContain("Path traversal");
|
||||
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
|
||||
|
||||
// Reject missing path
|
||||
const noPathResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
|
||||
});
|
||||
expect(noPathResponse.statusCode).toBe(400);
|
||||
const deleteNoPathBody = noPathResponse.json<Record<string, unknown>>();
|
||||
expect(deleteNoPathBody['error']).toContain("path query parameter is required");
|
||||
expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required");
|
||||
});
|
||||
|
||||
it("moves workspace files through the HTTP contract", async () => {
|
||||
@@ -658,7 +713,6 @@ describe("buildApp", () => {
|
||||
const workspace = workspacesResponse.json<Workspace[]>()[0];
|
||||
if (workspace === undefined) throw new Error("Expected workspace");
|
||||
|
||||
// Write a file first so we can move it
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}`,
|
||||
@@ -666,27 +720,21 @@ describe("buildApp", () => {
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
|
||||
// Move a file to a new path
|
||||
const moveResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("original.txt")}&toPath=${encodeURIComponent("moved.txt")}`,
|
||||
});
|
||||
expect(moveResponse.statusCode).toBe(200);
|
||||
expect(moveResponse.json()).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
|
||||
const moveBody = moveResponse.json<Record<string, unknown>>();
|
||||
expect(typeof moveBody['size']).toBe("number");
|
||||
expect(typeof moveResponse.json<{ size: unknown }>().size).toBe("number");
|
||||
|
||||
// Verify source is gone
|
||||
const readSourceResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}` });
|
||||
expect(readSourceResponse.statusCode).toBe(400);
|
||||
|
||||
// Verify target exists
|
||||
const readTargetResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("moved.txt")}` });
|
||||
expect(readTargetResponse.statusCode).toBe(200);
|
||||
const targetBody = readTargetResponse.json<Record<string, unknown>>();
|
||||
expect(targetBody['content']).toBe("move me");
|
||||
expect(readTargetResponse.json<{ content: unknown }>().content).toBe("move me");
|
||||
|
||||
// Write another file for overwrite test
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source2.txt")}`,
|
||||
@@ -700,14 +748,12 @@ describe("buildApp", () => {
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
|
||||
// Move with overwrite=true succeeds
|
||||
const overwriteResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source2.txt")}&toPath=${encodeURIComponent("target2.txt")}&overwrite=true`,
|
||||
});
|
||||
expect(overwriteResponse.statusCode).toBe(200);
|
||||
|
||||
// Move with overwrite=false (default) fails when target exists
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source3.txt")}`,
|
||||
@@ -725,24 +771,20 @@ describe("buildApp", () => {
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source3.txt")}&toPath=${encodeURIComponent("target3.txt")}`,
|
||||
});
|
||||
expect(noOverwriteResponse.statusCode).toBe(400);
|
||||
const moveNoOverwriteBody = noOverwriteResponse.json<Record<string, unknown>>();
|
||||
expect(moveNoOverwriteBody['error']).toContain("File already exists");
|
||||
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
|
||||
|
||||
// Reject path traversal in fromPath
|
||||
const traversalFromResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("../../etc/passwd")}&toPath=${encodeURIComponent("safe.txt")}`,
|
||||
});
|
||||
expect(traversalFromResponse.statusCode).toBe(400);
|
||||
|
||||
// Reject missing params
|
||||
const noParamsResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move`,
|
||||
});
|
||||
expect(noParamsResponse.statusCode).toBe(400);
|
||||
const noParamsBody = noParamsResponse.json<Record<string, unknown>>();
|
||||
expect(noParamsBody['error']).toContain("fromPath query parameter is required");
|
||||
expect(noParamsResponse.json<{ error: string }>().error).toContain("fromPath query parameter is required");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -752,6 +794,26 @@ interface CapturedSessionDaemonRequest {
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
function fakeConfigService() {
|
||||
return {
|
||||
read: () => piWebConfigResponse(piWebConfig),
|
||||
write: (config: PiWebConfigValues) => {
|
||||
piWebConfig = config;
|
||||
return piWebConfigResponse(config);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
return {
|
||||
path: join(tempDir, "config.json"),
|
||||
exists: false,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeSessionDaemon(): SessionProxyDaemon {
|
||||
return {
|
||||
request: (method, path, body) => {
|
||||
|
||||
+18
-10
@@ -7,7 +7,8 @@ import fastifyWebsocket from "@fastify/websocket";
|
||||
import { ProjectStore } from "./storage/projectStore.js";
|
||||
import { ProjectService } from "./projects/projectService.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||
import { pathAccessForCwd } from "./workspaces/effectivePathAccess.js";
|
||||
import { normalizeRequestCwd } from "./workingDirectory.js";
|
||||
import { listDirectorySuggestions } from "./projects/directorySuggestions.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
@@ -16,7 +17,7 @@ import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
|
||||
import { registerGitRoutes } from "./gitRoutes.js";
|
||||
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
||||
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
|
||||
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import { createFilePiWebConfigService, registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||
import { createPiWebStatusCache } from "./piWebStatusCache.js";
|
||||
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
@@ -76,13 +77,19 @@ function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectServi
|
||||
});
|
||||
}
|
||||
|
||||
function registerLocalFileSuggestionRoutes(app: FastifyInstance, prefix: string): void {
|
||||
interface LocalFileSuggestionRouteOptions {
|
||||
config?: Pick<PiWebConfigService, "read">;
|
||||
}
|
||||
|
||||
function registerLocalFileSuggestionRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string, options: LocalFileSuggestionRouteOptions = {}): void {
|
||||
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>(`${prefix}/files`, async (request, reply) => {
|
||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
try {
|
||||
const cwd = normalizeRequestCwd(request.query.cwd);
|
||||
if (request.query.mode === "path") return await listPathSuggestions(cwd, request.query.q ?? "");
|
||||
return await listFileSuggestions(cwd, request.query.q ?? "", { kind: request.query.kind, scope: request.query.scope });
|
||||
const query = request.query.q ?? "";
|
||||
const pathAccess = isAbsoluteishFileSuggestionQuery(query) ? await pathAccessForCwd(cwd, projects, workspaces, options.config) : undefined;
|
||||
if (request.query.mode === "path") return await listPathSuggestions(cwd, query, pathAccess);
|
||||
return await listFileSuggestions(cwd, query, { kind: request.query.kind, scope: request.query.scope, pathAccess });
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
@@ -96,6 +103,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
const projects = deps.projects ?? new ProjectService(new ProjectStore());
|
||||
const workspaces = deps.workspaces ?? new WorkspaceService();
|
||||
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
|
||||
const configService = deps.config ?? createFilePiWebConfigService();
|
||||
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
|
||||
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
|
||||
onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); },
|
||||
@@ -118,7 +126,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
|
||||
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
|
||||
app.get("/api/plugins", async () => piWebPlugins.plugins());
|
||||
registerConfigRoutes(app, deps.config);
|
||||
registerConfigRoutes(app, configService);
|
||||
|
||||
registerMachineRoutes(app, machines);
|
||||
registerMachinePluginProxyRoutes(app, machines);
|
||||
@@ -128,8 +136,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
|
||||
registerSessionProxyRoutes(app, sessionDaemon);
|
||||
registerSessionProxyRoutes(app, sessionDaemon, "/api/machines/local");
|
||||
registerWorkspaceExplorerRoutes(app, projects, workspaces);
|
||||
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local");
|
||||
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api", { config: configService });
|
||||
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local", { config: configService });
|
||||
registerGitRoutes(app, projects, workspaces);
|
||||
registerGitRoutes(app, projects, workspaces, "/api/machines/local");
|
||||
registerTerminalProxyRoutes(app, projects, workspaces, sessionDaemon);
|
||||
@@ -137,8 +145,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
registerWorkspaceDeletionRoutes(app, projects, workspaces, sessionDaemon);
|
||||
registerWorkspaceDeletionRoutes(app, projects, workspaces, sessionDaemon, "/api/machines/local");
|
||||
|
||||
registerLocalFileSuggestionRoutes(app, "/api");
|
||||
registerLocalFileSuggestionRoutes(app, "/api/machines/local");
|
||||
registerLocalFileSuggestionRoutes(app, projects, workspaces, "/api", { config: configService });
|
||||
registerLocalFileSuggestionRoutes(app, projects, workspaces, "/api/machines/local", { config: configService });
|
||||
|
||||
registerMachineProxyRoutes(app, machines);
|
||||
|
||||
|
||||
@@ -37,11 +37,11 @@ describe("config routes", () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/config",
|
||||
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } } },
|
||||
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } });
|
||||
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 });
|
||||
expect(response.json<PiWebConfigResponse>().config).toEqual(savedConfig);
|
||||
});
|
||||
|
||||
@@ -56,6 +56,30 @@ describe("config routes", () => {
|
||||
expect(response.json()).toHaveProperty("error");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid path access payloads before writing", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/config",
|
||||
payload: { config: { pathAccess: { allowedPaths: [""] } } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toHaveProperty("error");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid max upload bytes before writing", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/config",
|
||||
payload: { config: { maxUploadBytes: 0 } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toHaveProperty("error");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse {
|
||||
@@ -64,6 +88,6 @@ function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigRes
|
||||
exists,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false },
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ function parseConfigRequest(value: unknown): PiWebConfig {
|
||||
const allowedHosts = value["allowedHosts"];
|
||||
const shortcuts = value["shortcuts"];
|
||||
const plugins = value["plugins"];
|
||||
const pathAccess = value["pathAccess"];
|
||||
const maxUploadBytes = value["maxUploadBytes"];
|
||||
const spawnSessions = value["spawnSessions"];
|
||||
const subsessions = value["subsessions"];
|
||||
if (host !== undefined) {
|
||||
if (typeof host !== "string") throw new Error("PI WEB config host must be a string");
|
||||
config.host = host;
|
||||
@@ -69,6 +73,16 @@ function parseConfigRequest(value: unknown): PiWebConfig {
|
||||
if (allowedHosts !== undefined) config.allowedHosts = parseAllowedHostsRequest(allowedHosts);
|
||||
if (shortcuts !== undefined) config.shortcuts = parseShortcutsRequest(shortcuts);
|
||||
if (plugins !== undefined) config.plugins = parsePluginsRequest(plugins);
|
||||
if (pathAccess !== undefined) config.pathAccess = parsePathAccessRequest(pathAccess);
|
||||
if (maxUploadBytes !== undefined) config.maxUploadBytes = parseMaxUploadBytesRequest(maxUploadBytes);
|
||||
if (spawnSessions !== undefined) {
|
||||
if (typeof spawnSessions !== "boolean") throw new Error("PI WEB config spawnSessions must be a boolean");
|
||||
config.spawnSessions = spawnSessions;
|
||||
}
|
||||
if (subsessions !== undefined) {
|
||||
if (typeof subsessions !== "boolean") throw new Error("PI WEB config subsessions must be a boolean");
|
||||
config.subsessions = subsessions;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -88,6 +102,30 @@ function parseShortcutsRequest(value: unknown): Record<string, string | null> {
|
||||
}));
|
||||
}
|
||||
|
||||
function parsePathAccessRequest(value: unknown): NonNullable<PiWebConfig["pathAccess"]> {
|
||||
if (!isRecord(value)) throw new Error("PI WEB config pathAccess must be an object");
|
||||
const allowedPaths = value["allowedPaths"];
|
||||
return {
|
||||
...(allowedPaths === undefined ? {} : { allowedPaths: parseAllowedPathsRequest(allowedPaths) }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAllowedPathsRequest(value: unknown): string[] {
|
||||
if (!isNonEmptyStringArray(value)) {
|
||||
throw new Error("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isNonEmptyStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
|
||||
}
|
||||
|
||||
function parseMaxUploadBytesRequest(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) throw new Error("PI WEB config maxUploadBytes must be a positive integer");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]> {
|
||||
if (!isRecord(value) || Array.isArray(value)) throw new Error("PI WEB config plugins must be an object");
|
||||
return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => {
|
||||
@@ -106,6 +144,8 @@ function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverride
|
||||
host: isEnvSet(env["PI_WEB_HOST"]),
|
||||
port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]),
|
||||
allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]),
|
||||
spawnSessions: isEnvSet(env["PI_WEB_SPAWN_SESSIONS"]),
|
||||
subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+17
-3
@@ -10,20 +10,34 @@ import { AuthService } from "./sessions/authService.js";
|
||||
import { registerAuthRoutes } from "./sessions/authRoutes.js";
|
||||
import { PiSessionService } from "./sessions/piSessionService.js";
|
||||
import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
|
||||
import { ProjectScopedSpawnTargetResolver } from "./sessions/spawnTargetResolver.js";
|
||||
import { ProjectService } from "./projects/projectService.js";
|
||||
import { ProjectStore } from "./storage/projectStore.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { sessiondSocketPath } from "../sessiond/config.js";
|
||||
import { TerminalService } from "./terminals/terminalService.js";
|
||||
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
|
||||
import { getPiWebRuntimeComponent } from "./piWebStatus.js";
|
||||
import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
|
||||
import { maxUploadBytes } from "../config.js";
|
||||
import { effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled, subsessionsEnabled } from "../config.js";
|
||||
|
||||
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes() });
|
||||
const { config } = effectivePiWebConfig();
|
||||
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(process.env, config) });
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
const eventHub = new SessionEventHub();
|
||||
const workspaceActivity = new WorkspaceActivityService(eventHub);
|
||||
const auth = new AuthService();
|
||||
const sessions = new PiSessionService(eventHub, { modelRegistry: auth.modelRegistry, workspaceActivity });
|
||||
const spawnTargets = spawnSessionsEnabled(process.env, config)
|
||||
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
|
||||
: undefined;
|
||||
const sessions = new PiSessionService(eventHub, {
|
||||
modelRegistry: auth.modelRegistry,
|
||||
workspaceActivity,
|
||||
logger: app.log,
|
||||
...(spawnTargets === undefined ? {} : { spawnTargets }),
|
||||
subsessionsEnabled: spawnTargets !== undefined && subsessionsEnabled(process.env, config),
|
||||
});
|
||||
auth.subscribe((change) => { sessions.applyAuthChange(change); });
|
||||
const terminals = new TerminalService(eventHub, workspaceActivity);
|
||||
registerWorkspaceActivityRoutes(app, workspaceActivity);
|
||||
|
||||
@@ -30,12 +30,12 @@ describe("saveAttachmentsToWorkspace", () => {
|
||||
);
|
||||
|
||||
expect(saved).toHaveLength(2);
|
||||
expect(saved[0]?.path.startsWith(`${DEFAULT_ATTACHMENT_FOLDER}/paste-`)).toBe(true);
|
||||
expect(saved[0]?.path.startsWith(`${DEFAULT_ATTACHMENT_FOLDER}/attachment-`)).toBe(true);
|
||||
expect(saved[0]?.path.endsWith(".png")).toBe(true);
|
||||
expect(saved[1]?.path.endsWith(".webp")).toBe(true);
|
||||
expect(saved[0]?.size).toBe(pngBytes.byteLength);
|
||||
|
||||
const folderEntries = await readdir(join(workspace, ".pi-web", "paste"));
|
||||
const folderEntries = await readdir(join(workspace, ".pi-web", "attachments"));
|
||||
expect(folderEntries).toHaveLength(2);
|
||||
|
||||
const firstPath = saved[0]?.path ?? "";
|
||||
|
||||
@@ -10,7 +10,7 @@ import { resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
|
||||
* Default workspace-relative folder used when saving pasted/dropped
|
||||
* attachments for the agent to read with its own tools.
|
||||
*/
|
||||
export const DEFAULT_ATTACHMENT_FOLDER = ".pi-web/paste";
|
||||
export const DEFAULT_ATTACHMENT_FOLDER = ".pi-web/attachments";
|
||||
|
||||
export interface InlineImage {
|
||||
image: ImageContent;
|
||||
@@ -42,7 +42,7 @@ export async function attachmentsToInlineImages(attachments: PromptAttachment[])
|
||||
}
|
||||
|
||||
export interface SaveAttachmentsOptions {
|
||||
/** Workspace-relative folder to write into. Defaults to `.pi-web/paste`. */
|
||||
/** Workspace-relative folder to write into. Defaults to `.pi-web/attachments`. */
|
||||
folder?: string;
|
||||
/** Clock injection for deterministic tests. */
|
||||
now?: () => Date;
|
||||
@@ -66,7 +66,7 @@ export async function saveAttachmentsToWorkspace(
|
||||
const saved: SavedPromptAttachment[] = [];
|
||||
for (const [index, attachment] of attachments.entries()) {
|
||||
const bytes = Buffer.from(attachment.data, "base64");
|
||||
const filename = `paste-${stamp}-${String(index + 1)}.${extensionForImageMimeType(attachment.mimeType)}`;
|
||||
const filename = `attachment-${stamp}-${String(index + 1)}.${extensionForImageMimeType(attachment.mimeType)}`;
|
||||
const relativePath = `${folder}/${filename}`;
|
||||
await writeFile(join(folderTarget, filename), bytes);
|
||||
saved.push({ path: relativePath, mimeType: attachment.mimeType, size: bytes.byteLength });
|
||||
|
||||
@@ -63,9 +63,9 @@ class SettingsAwarePiSessionManagerGateway implements PiSessionManagerGateway {
|
||||
return filterSessionsForCwd(await listSessionsInDir(resolution.sessionDir), cwd);
|
||||
}
|
||||
|
||||
create(cwd: string): PiSessionManager {
|
||||
create(cwd: string, options?: { parentSession?: string }): PiSessionManager {
|
||||
const resolution = this.resolver.resolve(cwd);
|
||||
return SessionManager.create(cwd, resolution.sessionDir);
|
||||
return SessionManager.create(cwd, resolution.sessionDir, options?.parentSession === undefined ? undefined : { parentSession: options.parentSession });
|
||||
}
|
||||
|
||||
listAll(): Promise<PiSessionListEntry[]> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
||||
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import { PiSessionService, type PiAgentSession, type PiSessionManager, type PiSessionRuntime, type PiSessionServiceDependencies } from "./piSessionService.js";
|
||||
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
|
||||
|
||||
class CapturingSessionEventHub extends SessionEventHub {
|
||||
readonly sessionEvents: { sessionId: string; event: SessionUiEvent }[] = [];
|
||||
@@ -49,9 +50,10 @@ function sessionRef(id: string, cwd = "/workspace") {
|
||||
|
||||
function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
|
||||
const promptCalls: { text: string; options: unknown }[] = [];
|
||||
const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = [];
|
||||
const bindExtensionCalls: unknown[] = [];
|
||||
const listeners: ((event: unknown) => void)[] = [];
|
||||
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls };
|
||||
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, sendCustomMessage: customMessageCalls };
|
||||
const session: TestSession = {
|
||||
sessionId,
|
||||
sessionFile: `/tmp/${sessionId}.jsonl`,
|
||||
@@ -86,6 +88,10 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
|
||||
calls.prompt.push({ text, options });
|
||||
return Promise.resolve();
|
||||
},
|
||||
sendCustomMessage: (message: { customType: string; content: string; display: boolean; details?: unknown }, options: unknown) => {
|
||||
calls.sendCustomMessage.push({ message, options });
|
||||
return Promise.resolve();
|
||||
},
|
||||
executeBash: () => Promise.resolve({ output: "", exitCode: 0, cancelled: false, truncated: false }),
|
||||
abort: () => {
|
||||
calls.abort += 1;
|
||||
@@ -158,6 +164,7 @@ describe("PiSessionService", () => {
|
||||
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
|
||||
expect(service.activeCount()).toBe(1);
|
||||
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "session-1")).toBe(true);
|
||||
expect(hub.globalEvents.some((event) => event.type === "session.created" && event.session.id === "session-1" && event.session.cwd === "/workspace")).toBe(true);
|
||||
|
||||
await service.dispose();
|
||||
expect(fake.calls.abort).toBe(1);
|
||||
@@ -540,6 +547,32 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("echoes the user message for direct prompts but not command-forwarded ones", async () => {
|
||||
const fake = fakeRuntime("echo-session", {
|
||||
resourceLoader: { getSkills: () => ({ skills: [{ name: "skill-creator" }] }) },
|
||||
});
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const service = new PiSessionService(hub, {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("echo-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt(sessionRef("echo-session"), "Build the thing");
|
||||
expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1);
|
||||
|
||||
// The client optimistically renders command-forwarded prompts (e.g. /skill:*),
|
||||
// so the server must not publish a second copy via message.append.
|
||||
await service.runCommand(sessionRef("echo-session"), "/skill:skill-creator");
|
||||
expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1);
|
||||
expect(fake.calls.prompt).toEqual([
|
||||
{ text: "Build the thing", options: undefined },
|
||||
{ text: "/skill:skill-creator", options: undefined },
|
||||
]);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("rejects malformed prompt text before opening the runtime", async () => {
|
||||
const fake = fakeRuntime("prompt-session");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
@@ -747,4 +780,206 @@ describe("PiSessionService", () => {
|
||||
expect(fake.calls.clearQueue).toBe(1);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
describe("spawnSession", () => {
|
||||
function spawnService(decision: SpawnTargetDecision) {
|
||||
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
|
||||
const log: { details: Record<string, unknown>; message: string }[] = [];
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
|
||||
logger: { info: (details, message) => { log.push({ details, message }); } },
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
return { fake, service, log };
|
||||
}
|
||||
|
||||
it("starts a session at the resolved target, delivers the prompt, and logs the spawn", async () => {
|
||||
const { fake, service, log } = spawnService({ allowed: true, cwd: "/workspace-feature" });
|
||||
|
||||
const result = await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue the plan", cwd: "/workspace-feature" });
|
||||
|
||||
expect(result).toEqual({ sessionId: "spawned-1", cwd: "/workspace-feature" });
|
||||
expect(fake.calls.prompt).toEqual([{ text: "continue the plan", options: undefined }]);
|
||||
expect(log).toEqual([{ details: { spawningCwd: "/workspace", sessionId: "spawned-1", cwd: "/workspace-feature", promptLength: 17 }, message: "spawn_session started a new session" }]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("rejects an out-of-project target without starting a session", async () => {
|
||||
const { fake, service } = spawnService({ allowed: false, reason: "out-of-project", allowedCwds: ["/workspace"] });
|
||||
|
||||
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: "/elsewhere" }))
|
||||
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /workspace");
|
||||
expect(fake.calls.prompt).toEqual([]);
|
||||
expect(service.activeCount()).toBe(0);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("rejects when the spawning session is not in a registered project", async () => {
|
||||
const { service } = spawnService({ allowed: false, reason: "not-registered" });
|
||||
|
||||
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined }))
|
||||
.rejects.toThrow("Spawning session is not in a registered project");
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("is disabled when no spawn target resolver is configured", async () => {
|
||||
const fake = fakeRuntime("spawned-x");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined }))
|
||||
.rejects.toThrow("Spawning sessions is disabled");
|
||||
await service.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawnSubsession", () => {
|
||||
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) {
|
||||
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" });
|
||||
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") });
|
||||
const created = [parent.runtime, child.runtime];
|
||||
let index = 0;
|
||||
const createAgentRuntime: RuntimeCreator = async () => {
|
||||
await Promise.resolve();
|
||||
const runtime = created[Math.min(index, created.length - 1)] ?? child.runtime;
|
||||
index += 1;
|
||||
return runtime;
|
||||
};
|
||||
const archived = new Map<string, { sessionId: string; cwd: string; archivedAt: string }>();
|
||||
const archiveStore = {
|
||||
list: () => Promise.resolve([...archived.values()]),
|
||||
get: (sessionId: string) => Promise.resolve(archived.get(sessionId)),
|
||||
archive: (input: { sessionId: string; cwd: string }) => {
|
||||
const record = { sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-01T00:00:00.000Z" };
|
||||
archived.set(input.sessionId, record);
|
||||
return Promise.resolve(record);
|
||||
},
|
||||
restore: (sessionId: string) => { archived.delete(sessionId); return Promise.resolve(); },
|
||||
isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)),
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([]),
|
||||
archiveStore,
|
||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
|
||||
heartbeatIntervalMs,
|
||||
});
|
||||
return { parent, child, service };
|
||||
}
|
||||
|
||||
it("records the parent, delivers the prompt, and lists the tracked child", async () => {
|
||||
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
|
||||
await service.start("/workspace"); // bring the parent online so it can be notified
|
||||
|
||||
const result = await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" });
|
||||
|
||||
expect(result).toEqual({ sessionId: "child-1", cwd: "/workspace-feature" });
|
||||
expect(child.calls.prompt).toEqual([{ text: "do the slice", options: undefined }]);
|
||||
await expect(service.listSubsessions("parent-1")).resolves.toEqual([
|
||||
{ sessionId: "child-1", cwd: "/workspace-feature", status: "idle" },
|
||||
]);
|
||||
void parent;
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("notifies the parent once when the tracked child stops working", async () => {
|
||||
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
|
||||
await service.start("/workspace");
|
||||
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
|
||||
parent.calls.prompt.length = 0; // ignore the spawn prompt to the child; focus on the parent notification
|
||||
|
||||
child.session.isStreaming = true;
|
||||
child.emit({ type: "agent_start" }); // arm the notification
|
||||
child.session.isStreaming = false;
|
||||
child.emit({ type: "agent_end" }); // fire once
|
||||
child.emit({ type: "turn_end" }); // must not re-notify
|
||||
await new Promise((resolve) => setTimeout(resolve, 20)); // the parent notification is delivered via the async custom-message path
|
||||
|
||||
expect(parent.calls.sendCustomMessage).toHaveLength(1);
|
||||
expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working");
|
||||
expect(parent.calls.sendCustomMessage[0]?.message.customType).toBe("subsession.completion");
|
||||
expect(parent.calls.sendCustomMessage[0]?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" });
|
||||
expect(parent.calls.prompt).toHaveLength(0); // not a user-authored message
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("notifies via the heartbeat when the child settles without a further event", async () => {
|
||||
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }, 10);
|
||||
await service.start("/workspace");
|
||||
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
|
||||
parent.calls.prompt.length = 0;
|
||||
|
||||
// The child works, then settles silently: agent_end arrives while it still
|
||||
// reports active work, so the event-driven latch does not fire here.
|
||||
child.session.isStreaming = true;
|
||||
child.emit({ type: "agent_start" });
|
||||
child.emit({ type: "agent_end" });
|
||||
expect(parent.calls.sendCustomMessage).toHaveLength(0);
|
||||
|
||||
// Once the session settles, the periodic heartbeat re-check notifies.
|
||||
child.session.isStreaming = false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
|
||||
expect(parent.calls.sendCustomMessage).toHaveLength(1);
|
||||
expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working");
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("does not notify the parent when a tracked child is archived", async () => {
|
||||
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
|
||||
await service.start("/workspace");
|
||||
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
|
||||
// Arm the notification, as a real working child would.
|
||||
child.session.isStreaming = true;
|
||||
child.emit({ type: "agent_start" });
|
||||
child.session.isStreaming = false;
|
||||
parent.calls.sendCustomMessage.length = 0;
|
||||
|
||||
await service.archive("child-1");
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(parent.calls.sendCustomMessage).toHaveLength(0);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("reports an archived child's status in the subsession list", async () => {
|
||||
const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
|
||||
await service.start("/workspace");
|
||||
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
|
||||
|
||||
await service.archive("child-1");
|
||||
|
||||
await expect(service.listSubsessions("parent-1")).resolves.toEqual([
|
||||
{ sessionId: "child-1", cwd: "/workspace-feature", status: "archived" },
|
||||
]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("check_subsession and read_subsession refuse sessions that are not the caller's children", async () => {
|
||||
const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
|
||||
await service.start("/workspace");
|
||||
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
|
||||
|
||||
await expect(service.checkSubsession("someone-else", "child-1")).rejects.toThrow("not one of your subsessions");
|
||||
await expect(service.readSubsession("someone-else", "child-1", {})).rejects.toThrow("not one of your subsessions");
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("is disabled when no spawn target resolver is configured", async () => {
|
||||
const fake = fakeRuntime("nope");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
await expect(service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "p", parentSessionFile: undefined, prompt: "go", cwd: undefined }))
|
||||
.rejects.toThrow("Spawning sessions is disabled");
|
||||
await service.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,11 +31,31 @@ import type { SavedPromptAttachment } from "../../shared/apiTypes.js";
|
||||
|
||||
import { cwdPathsEqual } from "../workingDirectory.js";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
|
||||
import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
||||
import { buildTranscriptView } from "./subsessionTranscript.js";
|
||||
import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js";
|
||||
|
||||
/**
|
||||
* Minimal structured-logging seam, shaped like Fastify's logger so sessiond can
|
||||
* pass `app.log` directly. Defaults to a no-op so the service stays usable
|
||||
* without booting a server (e.g. in tests).
|
||||
*/
|
||||
export interface PiSessionLogger {
|
||||
info(details: Record<string, unknown>, message: string): void;
|
||||
}
|
||||
|
||||
const noopLogger: PiSessionLogger = { info() { /* no-op */ } };
|
||||
|
||||
function noop(): void {
|
||||
// Intentionally empty default unsubscribe callback.
|
||||
}
|
||||
|
||||
function spawnTargetError(decision: Extract<SpawnTargetDecision, { allowed: false }>): Error {
|
||||
if (decision.reason === "not-registered") return new Error("Spawning session is not in a registered project");
|
||||
return new Error(`cwd must be a workspace of this project. Allowed: ${decision.allowedCwds.join(", ")}`);
|
||||
}
|
||||
|
||||
function authLossWarningKey(sessionId: string, provider: string, modelId: string): string {
|
||||
return `${sessionId}:${provider}/${modelId}`;
|
||||
}
|
||||
@@ -58,6 +78,7 @@ interface QueuedPrompt {
|
||||
kind: QueuedPromptKind;
|
||||
text: string;
|
||||
images?: ImageContent[];
|
||||
echoUserMessage?: boolean;
|
||||
}
|
||||
|
||||
function requirePromptText(value: unknown): string {
|
||||
@@ -108,7 +129,7 @@ export interface PiSessionManager {
|
||||
|
||||
export interface PiSessionManagerGateway {
|
||||
list(cwd: string): Promise<PiSessionListEntry[]>;
|
||||
create(cwd: string): PiSessionManager;
|
||||
create(cwd: string, options?: { parentSession?: string }): PiSessionManager;
|
||||
/**
|
||||
* Legacy id-only lookup surface for older clients. This intentionally searches
|
||||
* only Pi's default session store, because custom session directories require
|
||||
@@ -153,6 +174,7 @@ export interface PiAgentSession {
|
||||
getSessionStats(): { sessionId: string; totalMessages: number; userMessages: number; assistantMessages: number; toolCalls: number; tokens: ClientSessionStatus["tokens"]; cost: number };
|
||||
getContextUsage(): ClientSessionStatus["contextUsage"] | undefined;
|
||||
prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] }): Promise<void>;
|
||||
sendCustomMessage(message: { customType: string; content: string; display: boolean; details?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void>;
|
||||
executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode: number | undefined; cancelled: boolean; truncated: boolean; fullOutputPath?: string }>;
|
||||
abort(): Promise<void>;
|
||||
clearQueue(): { steering: string[]; followUp: string[] };
|
||||
@@ -187,10 +209,16 @@ function defaultCreateAgentRuntime(createRuntime: CreateAgentSessionRuntimeFacto
|
||||
return createAgentSessionRuntime(createRuntime, { ...options, sessionManager: options.sessionManager });
|
||||
}
|
||||
|
||||
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance): CreateAgentSessionRuntimeFactory {
|
||||
type SpawnSessionFn = (input: SpawnSessionInvocation) => Promise<SpawnSessionResult>;
|
||||
|
||||
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance, spawn?: SpawnSessionFn, subsessions?: SubsessionToolDeps): CreateAgentSessionRuntimeFactory {
|
||||
return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
|
||||
const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
|
||||
const customTools = [createPiWebEditToolDefinition(cwd)];
|
||||
const customTools = [
|
||||
createPiWebEditToolDefinition(cwd),
|
||||
...(spawn === undefined ? [] : [createSpawnSessionToolDefinition(cwd, { spawn })]),
|
||||
...(subsessions === undefined ? [] : createSubsessionToolDefinitions(cwd, subsessions)),
|
||||
];
|
||||
const options = sessionStartEvent === undefined
|
||||
? { services, sessionManager, customTools }
|
||||
: { services, sessionManager, sessionStartEvent, customTools };
|
||||
@@ -232,6 +260,22 @@ export interface PiSessionServiceDependencies {
|
||||
modelRegistry?: ModelRegistryInstance;
|
||||
heartbeatIntervalMs?: number;
|
||||
workspaceActivity?: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity">;
|
||||
/**
|
||||
* When provided, the `spawn_session` tool is registered on every session,
|
||||
* letting the LLM start new sessions scoped to its project's workspaces.
|
||||
* Omit to keep the capability disabled (the tool is never registered).
|
||||
*/
|
||||
spawnTargets?: SpawnTargetResolver;
|
||||
/**
|
||||
* Beta: when true (and `spawnTargets` is provided), the tracked-subsession
|
||||
* tools (`spawn_subsession`, `list_subsessions`, `check_subsession`,
|
||||
* `read_subsession`) are
|
||||
* registered on every session. Off by default so the capability can ship in
|
||||
* main without being exposed in releases.
|
||||
*/
|
||||
subsessionsEnabled?: boolean;
|
||||
/** Structured logger for notable runtime events (e.g. spawns). */
|
||||
logger?: PiSessionLogger;
|
||||
}
|
||||
|
||||
export class PiSessionService {
|
||||
@@ -242,6 +286,16 @@ export class PiSessionService {
|
||||
private readonly compactionPromptQueues = new Map<string, QueuedPrompt[]>();
|
||||
private readonly compactionDrainTimers = new Map<string, NodeJS.Timeout>();
|
||||
private readonly authLossWarnings = new Set<string>();
|
||||
/** Tracked subsession id -> the parent session id that spawned it. */
|
||||
private readonly subsessionParents = new Map<string, string>();
|
||||
/** Parent session id -> the set of tracked subsession ids it spawned. */
|
||||
private readonly subsessionChildren = new Map<string, Set<string>>();
|
||||
/**
|
||||
* Tracked subsession id -> whether a completion notification is armed.
|
||||
* Armed when the child starts working; firing on completion disarms it so a
|
||||
* child that works again (and stops again) notifies the parent each time.
|
||||
*/
|
||||
private readonly subsessionNotifyArmed = new Map<string, boolean>();
|
||||
private readonly archiveStore: SessionArchiveRepository;
|
||||
private readonly agentDir: string;
|
||||
private readonly sessionManager: PiSessionManagerGateway;
|
||||
@@ -249,19 +303,36 @@ export class PiSessionService {
|
||||
private readonly createAgentRuntime: CreateAgentRuntime;
|
||||
private readonly modelRegistry: ModelRegistryInstance;
|
||||
private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity"> | undefined;
|
||||
private readonly spawnTargets: SpawnTargetResolver | undefined;
|
||||
private readonly logger: PiSessionLogger;
|
||||
|
||||
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
|
||||
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
||||
this.agentDir = deps.agentDir ?? getAgentDir();
|
||||
this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir });
|
||||
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
|
||||
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry);
|
||||
this.spawnTargets = deps.spawnTargets;
|
||||
this.logger = deps.logger ?? noopLogger;
|
||||
// Subsessions are a beta capability gated behind their own flag, and they
|
||||
// also require the spawn capability (they share its project-scope resolver).
|
||||
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
|
||||
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(
|
||||
this.modelRegistry.authStorage,
|
||||
this.modelRegistry,
|
||||
this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input),
|
||||
!subsessionsActive ? undefined : {
|
||||
spawn: (input) => this.spawnSubsession(input),
|
||||
list: (parentSessionId) => this.listSubsessions(parentSessionId),
|
||||
check: (parentSessionId, sessionId) => this.checkSubsession(parentSessionId, sessionId),
|
||||
read: (parentSessionId, sessionId, query) => this.readSubsession(parentSessionId, sessionId, query),
|
||||
},
|
||||
);
|
||||
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
||||
this.workspaceActivity = deps.workspaceActivity;
|
||||
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
|
||||
this.commandService = new SessionCommandService(
|
||||
(sessionId) => this.getActive(sessionId),
|
||||
(sessionId, text) => this.prompt(sessionId, text),
|
||||
(sessionId, text) => this.prompt(sessionId, text, undefined, undefined, { echoUserMessage: false }),
|
||||
events,
|
||||
{
|
||||
onCompactionStart: (session) => {
|
||||
@@ -289,6 +360,9 @@ export class PiSessionService {
|
||||
this.activities.clear();
|
||||
this.compactionPromptQueues.clear();
|
||||
this.authLossWarnings.clear();
|
||||
this.subsessionParents.clear();
|
||||
this.subsessionChildren.clear();
|
||||
this.subsessionNotifyArmed.clear();
|
||||
await Promise.all(activeSessions.map(async (active) => {
|
||||
active.unsubscribe();
|
||||
this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd());
|
||||
@@ -315,10 +389,10 @@ export class PiSessionService {
|
||||
return [...unarchivedSessions, ...archivedSessions];
|
||||
}
|
||||
|
||||
async start(cwd: string): Promise<ClientSession> {
|
||||
const active = await this.create(this.sessionManager.create(cwd), cwd);
|
||||
async start(cwd: string, parentSession?: string): Promise<ClientSession> {
|
||||
const active = await this.create(this.sessionManager.create(cwd, parentSession === undefined ? undefined : { parentSession }), cwd);
|
||||
const { session } = active.runtime;
|
||||
return {
|
||||
const created: ClientSession = {
|
||||
id: session.sessionId,
|
||||
path: session.sessionFile ?? "",
|
||||
cwd,
|
||||
@@ -326,7 +400,163 @@ export class PiSessionService {
|
||||
modified: new Date().toISOString(),
|
||||
messageCount: session.messages.length,
|
||||
firstMessage: "",
|
||||
// Include the parent so listeners can nest the new session in the tree
|
||||
// immediately, instead of showing it flat until the next reload.
|
||||
...(parentSession === undefined ? {} : { parentSessionPath: parentSession }),
|
||||
};
|
||||
// Broadcast so other clients (and the spawning agent's UI) can add the new
|
||||
// session to their list without a manual reload.
|
||||
this.events.publishGlobal({ type: "session.created", session: created });
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new session on behalf of a LLM and deliver an initial prompt to it.
|
||||
* The target cwd is constrained to a workspace of the same registered project
|
||||
* as the spawning session so the new session is visible in the web UI.
|
||||
*/
|
||||
async spawnSession(input: SpawnSessionInvocation): Promise<SpawnSessionResult> {
|
||||
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
|
||||
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
|
||||
if (!decision.allowed) throw spawnTargetError(decision);
|
||||
const created = await this.start(decision.cwd);
|
||||
await this.prompt(created.id, input.prompt);
|
||||
this.logger.info(
|
||||
{ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
|
||||
"spawn_session started a new session",
|
||||
);
|
||||
return { sessionId: created.id, cwd: decision.cwd };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a *tracked* child session on behalf of a LLM. Identical to
|
||||
* {@link spawnSession} in how the target cwd is resolved, but the child
|
||||
* records its parent (so it shows in the session tree) and is registered so
|
||||
* the parent is notified when it stops working and can inspect it later.
|
||||
*/
|
||||
async spawnSubsession(input: SpawnSubsessionInvocation): Promise<SpawnSubsessionResult> {
|
||||
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
|
||||
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
|
||||
if (!decision.allowed) throw spawnTargetError(decision);
|
||||
const created = await this.start(decision.cwd, input.parentSessionFile);
|
||||
this.registerSubsession(input.parentSessionId, created.id);
|
||||
await this.prompt(created.id, input.prompt);
|
||||
this.logger.info(
|
||||
{ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
|
||||
"spawn_subsession started a tracked child session",
|
||||
);
|
||||
return { sessionId: created.id, cwd: decision.cwd };
|
||||
}
|
||||
|
||||
/** Summaries of the tracked subsessions spawned by `parentSessionId`. */
|
||||
async listSubsessions(parentSessionId: string): Promise<SubsessionSummary[]> {
|
||||
const childIds = this.subsessionChildren.get(parentSessionId);
|
||||
if (childIds === undefined) return [];
|
||||
return Promise.all([...childIds].map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
|
||||
}
|
||||
|
||||
/** Status and final result of a subsession, scoped to the caller's children. */
|
||||
async checkSubsession(parentSessionId: string, sessionId: string): Promise<SubsessionCheckResult> {
|
||||
const session = await this.openSubsession(parentSessionId, sessionId);
|
||||
const messages = historyMessages(session);
|
||||
return {
|
||||
sessionId,
|
||||
cwd: session.sessionManager.getCwd(),
|
||||
status: await this.subsessionStatus(session),
|
||||
finalText: finalAssistantText(messages),
|
||||
messageCount: messages.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Filtered, paginated transcript of a subsession, scoped to the caller's children. */
|
||||
async readSubsession(parentSessionId: string, sessionId: string, query: SubsessionReadQuery): Promise<SubsessionReadResult> {
|
||||
const session = await this.openSubsession(parentSessionId, sessionId);
|
||||
const view = buildTranscriptView(historyMessages(session), query);
|
||||
return {
|
||||
sessionId,
|
||||
cwd: session.sessionManager.getCwd(),
|
||||
status: await this.subsessionStatus(session),
|
||||
...view,
|
||||
};
|
||||
}
|
||||
|
||||
/** Open a session after verifying it is one of the caller's tracked children. */
|
||||
private async openSubsession(parentSessionId: string, sessionId: string): Promise<PiAgentSession> {
|
||||
if (this.subsessionParents.get(sessionId) !== parentSessionId) {
|
||||
throw new Error(`Session ${sessionId} is not one of your subsessions`);
|
||||
}
|
||||
return this.getOrOpen(sessionId);
|
||||
}
|
||||
|
||||
private registerSubsession(parentSessionId: string, childSessionId: string): void {
|
||||
this.subsessionParents.set(childSessionId, parentSessionId);
|
||||
const children = this.subsessionChildren.get(parentSessionId) ?? new Set<string>();
|
||||
children.add(childSessionId);
|
||||
this.subsessionChildren.set(parentSessionId, children);
|
||||
this.subsessionNotifyArmed.set(childSessionId, false);
|
||||
}
|
||||
|
||||
private async subsessionSummaryFields(childSessionId: string): Promise<{ cwd: string; status: SubsessionStatus }> {
|
||||
const active = this.active.get(childSessionId);
|
||||
if (active !== undefined) {
|
||||
return { cwd: active.runtime.cwd, status: await this.subsessionStatus(active.runtime.session) };
|
||||
}
|
||||
const archived = await this.archiveStore.get(childSessionId);
|
||||
if (archived !== undefined) return { cwd: archived.cwd, status: "archived" };
|
||||
return { cwd: "", status: "unknown" };
|
||||
}
|
||||
|
||||
private async subsessionStatus(session: PiAgentSession): Promise<SubsessionStatus> {
|
||||
if (await this.archiveStore.isArchived(session.sessionId)) return "archived";
|
||||
if (this.hasActiveWork(session)) return "working";
|
||||
if (this.activities.get(session.sessionId)?.phase === "error") return "error";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive parent notifications from a tracked child's status. Arms a pending
|
||||
* notification while the child is working, and when it stops fires a single
|
||||
* follow-up message to the parent via {@link prompt} (which queues if the
|
||||
* parent is busy and delivers immediately when it is idle).
|
||||
*/
|
||||
private updateSubsessionTracking(session: PiAgentSession): void {
|
||||
const childId = session.sessionId;
|
||||
const parentId = this.subsessionParents.get(childId);
|
||||
if (parentId === undefined) return;
|
||||
if (this.hasActiveWork(session)) {
|
||||
this.subsessionNotifyArmed.set(childId, true);
|
||||
return;
|
||||
}
|
||||
if (this.subsessionNotifyArmed.get(childId) !== true) return;
|
||||
this.subsessionNotifyArmed.set(childId, false);
|
||||
const status: SubsessionStatus = this.activities.get(childId)?.phase === "error" ? "error" : "idle";
|
||||
const finalText = finalAssistantText(historyMessages(session));
|
||||
const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
|
||||
const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nUse check_subsession with sessionId "${childId}" for its status and latest output, or read_subsession to look through its full transcript.`;
|
||||
void this.notifyParentOfSubsession(parentId, childId, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a subsession-completion notice to the parent as a system-authored
|
||||
* custom message rather than a user message, so it is not attributed to the
|
||||
* human in the transcript. It still wakes an idle parent (`triggerTurn`) and
|
||||
* queues behind in-flight work (`deliverAs: "followUp"`), preserving the
|
||||
* established "queue if busy, send and act if idle" behavior.
|
||||
*/
|
||||
private async notifyParentOfSubsession(parentId: string, childId: string, text: string): Promise<void> {
|
||||
try {
|
||||
const session = await this.getOrOpen(parentId);
|
||||
await session.sendCustomMessage(
|
||||
{ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } },
|
||||
{ triggerTurn: true, deliverAs: "followUp" },
|
||||
);
|
||||
this.publishStatus(session);
|
||||
} catch (error: unknown) {
|
||||
this.logger.info(
|
||||
{ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) },
|
||||
"failed to notify parent of subsession completion",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async messages(ref: PiSessionLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage> {
|
||||
@@ -417,8 +647,13 @@ export class PiSessionService {
|
||||
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async prompt(ref: PiSessionLookup, text: unknown, streamingBehavior?: unknown, attachments?: unknown): Promise<void> {
|
||||
async prompt(ref: PiSessionLookup, text: unknown, streamingBehavior?: unknown, attachments?: unknown, options?: { echoUserMessage?: boolean }): Promise<void> {
|
||||
const promptText = requirePromptText(text);
|
||||
// Command-forwarded prompts (e.g. /skill:*) are expanded by the agent, which
|
||||
// streams the canonical message back. The client doesn't render the raw
|
||||
// command text, so the server must not echo it either, or it would show up
|
||||
// as a transient line that vanishes on reload.
|
||||
const echoUserMessage = options?.echoUserMessage !== false;
|
||||
const requestedBehavior = parsePromptStreamingBehavior(streamingBehavior);
|
||||
const parsedAttachments = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
|
||||
const images = (await attachmentsToInlineImages(parsedAttachments)).map((entry) => entry.image);
|
||||
@@ -433,15 +668,15 @@ export class PiSessionService {
|
||||
return;
|
||||
}
|
||||
if (session.isCompacting) {
|
||||
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images);
|
||||
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage);
|
||||
return;
|
||||
}
|
||||
void this.submitPrompt(session, promptText, behavior, images);
|
||||
void this.submitPrompt(session, promptText, behavior, images, echoUserMessage);
|
||||
}
|
||||
|
||||
private submitPrompt(session: PiAgentSession, text: string, behavior: QueuedPromptKind | undefined, images: ImageContent[] = []): Promise<void> {
|
||||
private submitPrompt(session: PiAgentSession, text: string, behavior: QueuedPromptKind | undefined, images: ImageContent[] = [], echoUserMessage = true): Promise<void> {
|
||||
this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
|
||||
if (behavior === undefined) this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
|
||||
if (behavior === undefined && echoUserMessage) this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
|
||||
const promptOptions = buildPromptOptions(behavior, images);
|
||||
const promptPromise = session.prompt(text, promptOptions).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -452,9 +687,9 @@ export class PiSessionService {
|
||||
return promptPromise;
|
||||
}
|
||||
|
||||
private enqueuePromptDuringCompaction(session: PiAgentSession, text: string, kind: QueuedPromptKind, images: ImageContent[] = []): void {
|
||||
private enqueuePromptDuringCompaction(session: PiAgentSession, text: string, kind: QueuedPromptKind, images: ImageContent[] = [], echoUserMessage = true): void {
|
||||
const queue = this.compactionPromptQueues.get(session.sessionId) ?? [];
|
||||
queue.push({ kind, text, ...(images.length > 0 ? { images } : {}) });
|
||||
queue.push({ kind, text, ...(images.length > 0 ? { images } : {}), ...(echoUserMessage ? {} : { echoUserMessage: false }) });
|
||||
this.compactionPromptQueues.set(session.sessionId, queue);
|
||||
this.publishActivity(session, "message queued during compaction", "active");
|
||||
this.publishStatus(session);
|
||||
@@ -684,6 +919,10 @@ export class PiSessionService {
|
||||
this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
|
||||
this.clearAuthLossWarningsForSession(sessionId);
|
||||
this.clearCompactionPromptQueue(sessionId);
|
||||
// Disarm subsession notification before teardown so the abort below cannot
|
||||
// emit a "stopped working" event that notifies the parent (e.g. on archive).
|
||||
// The parent/children link is kept so the parent can still see the child.
|
||||
this.subsessionNotifyArmed.delete(sessionId);
|
||||
clearSessionQueue(active.runtime.session);
|
||||
active.unsubscribe();
|
||||
try {
|
||||
@@ -772,6 +1011,7 @@ export class PiSessionService {
|
||||
if (eventType === "compaction_end") this.scheduleCompactionQueueDrain(session.sessionId);
|
||||
if (eventType === "agent_start" || eventType === "agent_end") this.scheduleCompactionQueueDrain(session.sessionId);
|
||||
this.publishStatus(session);
|
||||
this.updateSubsessionTracking(session);
|
||||
});
|
||||
this.active.set(session.sessionId, active);
|
||||
}
|
||||
@@ -798,14 +1038,14 @@ export class PiSessionService {
|
||||
const queued = this.takeCompactionPromptQueue(sessionId);
|
||||
if (queued.length === 0) return;
|
||||
this.publishStatus(session);
|
||||
for (const prompt of queued) void this.submitPrompt(session, prompt.text, prompt.kind, prompt.images);
|
||||
for (const prompt of queued) void this.submitPrompt(session, prompt.text, prompt.kind, prompt.images, prompt.echoUserMessage ?? true);
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = this.shiftCompactionPrompt(sessionId);
|
||||
if (prompt === undefined) return;
|
||||
this.publishStatus(session);
|
||||
const submitted = this.submitPrompt(session, prompt.text, undefined, prompt.images);
|
||||
const submitted = this.submitPrompt(session, prompt.text, undefined, prompt.images, prompt.echoUserMessage ?? true);
|
||||
void submitted.finally(() => { this.scheduleCompactionQueueDrain(sessionId); });
|
||||
}
|
||||
|
||||
@@ -902,6 +1142,10 @@ export class PiSessionService {
|
||||
private publishHeartbeats(): void {
|
||||
for (const active of this.active.values()) {
|
||||
const { session } = active.runtime;
|
||||
// Re-evaluate subsession completion here too: agent_end can arrive while
|
||||
// the session still reports active work transiently, so the event-driven
|
||||
// latch may not fire. The heartbeat re-checks once the session settles.
|
||||
this.updateSubsessionTracking(session);
|
||||
const activity = this.activities.get(session.sessionId);
|
||||
if (!this.hasActiveWork(session)) {
|
||||
if (activity?.phase === "active") this.publishStatus(session);
|
||||
@@ -1231,6 +1475,33 @@ function historyMessages(session: PiAgentSession): unknown[] {
|
||||
return messages;
|
||||
}
|
||||
|
||||
/** customType marking a parent-facing subsession-completion notice. */
|
||||
const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion";
|
||||
|
||||
const SUBSESSION_NOTIFICATION_PREVIEW_CHARS = 2000;
|
||||
|
||||
function truncateForNotification(text: string): string {
|
||||
if (text.length <= SUBSESSION_NOTIFICATION_PREVIEW_CHARS) return text;
|
||||
return `${text.slice(0, SUBSESSION_NOTIFICATION_PREVIEW_CHARS)}…`;
|
||||
}
|
||||
|
||||
/** Most recent assistant text from a history message list, or "" if none. */
|
||||
function finalAssistantText(messages: readonly unknown[]): string {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (!isRecord(message) || message["role"] !== "assistant") continue;
|
||||
const content = message["content"];
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
const texts: string[] = [];
|
||||
for (const part of content) {
|
||||
if (isRecord(part) && part["type"] === "text" && typeof part["text"] === "string") texts.push(part["text"]);
|
||||
}
|
||||
if (texts.length > 0) return texts.join("\n").trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function toClientEvent(event: unknown): SessionUiEvent {
|
||||
const eventType = getString(event, "type");
|
||||
const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
|
||||
|
||||
@@ -60,7 +60,9 @@ describe("SessionCommandService", () => {
|
||||
const service = new SessionCommandService(() => getActive(active), prompt, eventPublisher());
|
||||
|
||||
await expect(service.run("s1", "/missing")).resolves.toEqual({ type: "unsupported", message: "Unknown command: /missing" });
|
||||
await expect(service.run("s1", "/ext arg")).resolves.toEqual({ type: "done", message: "Accepted /ext arg" });
|
||||
// Forwarded runtime commands return a bare done result: the agent streams
|
||||
// back the canonical expanded message, so no synthetic "Accepted" line.
|
||||
await expect(service.run("s1", "/ext arg")).resolves.toEqual({ type: "done" });
|
||||
await expect(service.run("s1", "/template arg")).resolves.toMatchObject({ type: "done" });
|
||||
await expect(service.run("s1", "/skill:skill-a arg")).resolves.toMatchObject({ type: "done" });
|
||||
expect(prompt).toHaveBeenCalledTimes(3);
|
||||
|
||||
@@ -80,8 +80,12 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
|
||||
|
||||
if (!isBuiltinCommand(name)) {
|
||||
if (this.isRuntimeCommand(session, name)) {
|
||||
// The command is forwarded to the agent, which expands it (e.g. /skill:*
|
||||
// into a skill block) and streams the canonical message back. That is the
|
||||
// authoritative feedback, so we don't synthesize an extra "Accepted" line
|
||||
// that would only vanish on reload.
|
||||
await this.prompt(sessionId, text);
|
||||
return { type: "done", message: `Accepted ${text}` };
|
||||
return { type: "done" };
|
||||
}
|
||||
return { type: "unsupported", message: `Unknown command: /${name}` };
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ class CapturingRouteSessionService extends PiSessionService {
|
||||
override saveAttachments(_lookup: string | PiSessionRef, attachments: unknown, folder?: string) {
|
||||
const list = Array.isArray(attachments) ? attachments : [];
|
||||
return Promise.resolve(list.map((attachment: { mimeType: string; data: string; name?: string }) => ({
|
||||
path: `${folder ?? ".pi-web/paste"}/${attachment.name ?? "file.png"}`,
|
||||
path: `${folder ?? ".pi-web/attachments"}/${attachment.name ?? "file.png"}`,
|
||||
mimeType: attachment.mimeType,
|
||||
size: Buffer.from(attachment.data, "base64").byteLength,
|
||||
})));
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
|
||||
|
||||
// The spawn tool's execute() never reads ctx, so an empty stub is sufficient.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub; execute() does not use ctx.
|
||||
const ctx = {} as ExtensionContext;
|
||||
|
||||
describe("createSpawnSessionToolDefinition", () => {
|
||||
it("passes the spawning cwd and params to the spawn callback and reports success", async () => {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" }));
|
||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||
|
||||
const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctx);
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "do the thing", cwd: "/repos/a-feature" });
|
||||
expect(result.details).toEqual({ sessionId: "new-1", cwd: "/repos/a-feature" });
|
||||
expect(result.content[0]).toMatchObject({ type: "text", text: "Started session new-1 in /repos/a-feature." });
|
||||
});
|
||||
|
||||
it("defaults cwd to undefined so the service falls back to the spawning cwd", async () => {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-2", cwd: "/repos/a" }));
|
||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||
|
||||
await tool.execute("call-2", { prompt: "continue" }, undefined, undefined, ctx);
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined });
|
||||
});
|
||||
|
||||
it("propagates the spawn callback error so the agent loop reports it", async () => {
|
||||
const spawn = vi.fn(() => Promise.reject(new Error("cwd must be a workspace of this project. Allowed: /repos/a")));
|
||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||
|
||||
await expect(tool.execute("call-3", { prompt: "x", cwd: "/elsewhere" }, undefined, undefined, ctx))
|
||||
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /repos/a");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Type } from "typebox";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export interface SpawnSessionResult {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface SpawnSessionInvocation {
|
||||
spawningCwd: string;
|
||||
prompt: string;
|
||||
cwd: string | undefined;
|
||||
}
|
||||
|
||||
export interface SpawnSessionToolDeps {
|
||||
spawn(input: SpawnSessionInvocation): Promise<SpawnSessionResult>;
|
||||
}
|
||||
|
||||
type SpawnSessionToolDetails = SpawnSessionResult;
|
||||
|
||||
const SpawnSessionParams = Type.Object({
|
||||
prompt: Type.String({
|
||||
description: "The first instruction to send to the newly created session. The new session runs independently; you do not receive its output.",
|
||||
}),
|
||||
cwd: Type.Optional(Type.String({
|
||||
description: "Working directory for the new session. Must be a workspace (worktree, or root) of the same project as this session. Defaults to this session's working directory.",
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
* Custom tool that lets the LLM start a new, independent pi-web session and
|
||||
* deliver an initial prompt to it. The spawned session is a normal pi-web session
|
||||
* a human can open and interact with. The tool is constructed per-session, so it
|
||||
* carries the spawning session's cwd for project-scope validation.
|
||||
*/
|
||||
export function createSpawnSessionToolDefinition(spawningCwd: string, deps: SpawnSessionToolDeps) {
|
||||
return defineTool<typeof SpawnSessionParams, SpawnSessionToolDetails>({
|
||||
name: "spawn_session",
|
||||
label: "Spawn session",
|
||||
description: "Start a new, independent pi-web session and send it an initial prompt. Use this to dispatch a fresh agent to continue work or follow a plan. The new session runs on its own and a human can interact with it; you do not receive its output.",
|
||||
promptSnippet: "spawn_session: start a new independent session with a first prompt",
|
||||
parameters: SpawnSessionParams,
|
||||
async execute(_toolCallId, params) {
|
||||
// Failures throw: the agent loop turns the thrown message into an error
|
||||
// tool result the model sees, so the spawning agent can adapt (e.g. pick a
|
||||
// valid workspace) rather than crash.
|
||||
const result = await deps.spawn({ spawningCwd, prompt: params.prompt, cwd: params.cwd });
|
||||
return {
|
||||
content: [{ type: "text", text: `Started session ${result.sessionId} in ${result.cwd}.` }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSubsessionToolDefinitions, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
||||
|
||||
function ctxFor(sessionId: string, sessionFile: string | undefined): ExtensionContext {
|
||||
const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => sessionFile };
|
||||
// The subsession tools only read sessionManager.getSessionId/getSessionFile.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tools use.
|
||||
return { sessionManager } as unknown as ExtensionContext;
|
||||
}
|
||||
|
||||
function tools(deps: Partial<SubsessionToolDeps>) {
|
||||
const full: SubsessionToolDeps = {
|
||||
spawn: deps.spawn ?? vi.fn(() => Promise.resolve({ sessionId: "x", cwd: "/repos/a" })),
|
||||
list: deps.list ?? vi.fn(() => Promise.resolve([])),
|
||||
check: deps.check ?? vi.fn(() => Promise.resolve({ sessionId: "x", cwd: "/repos/a", status: "idle" as const, finalText: "", messageCount: 0 })),
|
||||
read: deps.read ?? vi.fn(() => Promise.resolve({ sessionId: "x", cwd: "/repos/a", status: "idle" as const, entries: [], total: 0, matched: 0, start: 0, hasMore: false })),
|
||||
};
|
||||
const definitions = createSubsessionToolDefinitions("/repos/a", full);
|
||||
const find = (name: string) => {
|
||||
const tool = definitions.find((definition) => definition.name === name);
|
||||
if (tool === undefined) throw new Error(`missing tool ${name}`);
|
||||
return tool;
|
||||
};
|
||||
return { spawn: find("spawn_subsession"), list: find("list_subsessions"), check: find("check_subsession"), read: find("read_subsession") };
|
||||
}
|
||||
|
||||
function firstText(content: readonly (TextContent | ImageContent)[]): string {
|
||||
const first = content[0];
|
||||
return first?.type === "text" ? first.text : "";
|
||||
}
|
||||
|
||||
describe("createSubsessionToolDefinitions", () => {
|
||||
it("spawn_subsession forwards parent identity and params from the live context", async () => {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" }));
|
||||
const { spawn: spawnTool } = tools({ spawn });
|
||||
|
||||
const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith({
|
||||
spawningCwd: "/repos/a",
|
||||
parentSessionId: "parent-1",
|
||||
parentSessionFile: "/sessions/parent-1.jsonl",
|
||||
prompt: "do it",
|
||||
cwd: "/repos/a-feature",
|
||||
});
|
||||
expect(result.details).toEqual({ sessionId: "child-1", cwd: "/repos/a-feature" });
|
||||
expect(firstText(result.content)).toContain("Started subsession child-1");
|
||||
});
|
||||
|
||||
it("list_subsessions reports the caller's subsessions and their status", async () => {
|
||||
const list = vi.fn(() => Promise.resolve([
|
||||
{ sessionId: "child-1", cwd: "/repos/a", status: "working" as const },
|
||||
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" as const },
|
||||
]));
|
||||
const { list: listTool } = tools({ list });
|
||||
|
||||
const result = await listTool.execute("call-2", {}, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
|
||||
expect(list).toHaveBeenCalledWith("parent-1");
|
||||
expect(result.details).toEqual({ subsessions: [
|
||||
{ sessionId: "child-1", cwd: "/repos/a", status: "working" },
|
||||
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" },
|
||||
] });
|
||||
expect(firstText(result.content)).toContain("child-1 [working]");
|
||||
});
|
||||
|
||||
it("list_subsessions reports an empty state", async () => {
|
||||
const { list: listTool } = tools({ list: vi.fn(() => Promise.resolve([])) });
|
||||
const result = await listTool.execute("call-3", {}, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
expect(result.content[0]).toMatchObject({ type: "text", text: "You have not spawned any subsessions." });
|
||||
});
|
||||
|
||||
it("check_subsession scopes by parent and returns the final result", async () => {
|
||||
const check = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a", status: "idle" as const, finalText: "all done", messageCount: 4 }));
|
||||
const { check: checkTool } = tools({ check });
|
||||
|
||||
const result = await checkTool.execute("call-4", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
|
||||
expect(check).toHaveBeenCalledWith("parent-1", "child-1");
|
||||
expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" });
|
||||
expect(firstText(result.content)).toContain("all done");
|
||||
});
|
||||
|
||||
it("check_subsession propagates scope errors so the agent loop reports them", async () => {
|
||||
const check = vi.fn(() => Promise.reject(new Error("Session child-9 is not one of your subsessions")));
|
||||
const { check: checkTool } = tools({ check });
|
||||
|
||||
await expect(checkTool.execute("call-5", { sessionId: "child-9" }, undefined, undefined, ctxFor("parent-1", undefined)))
|
||||
.rejects.toThrow("not one of your subsessions");
|
||||
});
|
||||
|
||||
it("read_subsession forwards filter params and renders the transcript", async () => {
|
||||
const read = vi.fn(() => Promise.resolve({
|
||||
sessionId: "child-1", cwd: "/repos/a", status: "idle" as const,
|
||||
entries: [{ index: 2, role: "assistant" as const, parts: [{ kind: "text" as const, text: "the answer" }] }],
|
||||
total: 5, matched: 1, start: 2, hasMore: false,
|
||||
}));
|
||||
const { read: readTool } = tools({ read });
|
||||
|
||||
const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200 }, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
|
||||
expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200 });
|
||||
expect(result.details).toMatchObject({ sessionId: "child-1", matched: 1 });
|
||||
expect(firstText(result.content)).toContain("the answer");
|
||||
});
|
||||
|
||||
it("read_subsession renders raw tool-call args and the truncation marker in the model-facing text", async () => {
|
||||
const read = vi.fn(() => Promise.resolve({
|
||||
sessionId: "child-1", cwd: "/repos/a", status: "idle" as const,
|
||||
entries: [{
|
||||
index: 1, role: "assistant" as const, parts: [
|
||||
{ kind: "tool_call" as const, toolName: "bash", summary: "ls", args: { command: "ls -la" } },
|
||||
{ kind: "text" as const, text: "clipped", truncated: { shown: 7, full: 50 } },
|
||||
],
|
||||
}],
|
||||
total: 3, matched: 1, start: 1, hasMore: false,
|
||||
}));
|
||||
const { read: readTool } = tools({ read });
|
||||
|
||||
const result = await readTool.execute("call-7", { sessionId: "child-1", includeToolArgs: true }, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
const text = firstText(result.content);
|
||||
expect(text).toContain("command"); // raw args surfaced in text, not only details
|
||||
expect(text).toContain("ls -la");
|
||||
expect(text).toContain("[+43 chars truncated"); // 50 - 7
|
||||
});
|
||||
|
||||
it("read_subsession distinguishes an empty page-window from a zero-match result", async () => {
|
||||
const read = vi.fn(() => Promise.resolve({
|
||||
sessionId: "child-1", cwd: "/repos/a", status: "idle" as const,
|
||||
entries: [], total: 5, matched: 4, start: 0, hasMore: false,
|
||||
}));
|
||||
const { read: readTool } = tools({ read });
|
||||
|
||||
const result = await readTool.execute("call-8", { sessionId: "child-1", before: 0 }, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
const text = firstText(result.content);
|
||||
expect(text).toContain("4 matched"); // not "nothing matched"
|
||||
expect(text).not.toContain("nothing matched");
|
||||
});
|
||||
|
||||
it("read_subsession propagates scope errors so the agent loop reports them", async () => {
|
||||
const read = vi.fn(() => Promise.reject(new Error("Session child-9 is not one of your subsessions")));
|
||||
const { read: readTool } = tools({ read });
|
||||
|
||||
await expect(readTool.execute("call-9", { sessionId: "child-9" }, undefined, undefined, ctxFor("parent-1", undefined)))
|
||||
.rejects.toThrow("not one of your subsessions");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Type } from "typebox";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import type { TranscriptContentKind, TranscriptEntry, TranscriptRole, TranscriptView } from "./subsessionTranscript.js";
|
||||
|
||||
/** Lifecycle phase of a tracked subsession as seen by its parent. */
|
||||
export type SubsessionStatus = "working" | "idle" | "error" | "archived" | "unknown";
|
||||
|
||||
export interface SpawnSubsessionResult {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface SpawnSubsessionInvocation {
|
||||
/** cwd of the session that invoked the tool (used for project-scope checks). */
|
||||
spawningCwd: string;
|
||||
/** Session id of the parent; the spawned session is tracked against it. */
|
||||
parentSessionId: string;
|
||||
/** Session file of the parent, recorded in the child's `parentSession` header. */
|
||||
parentSessionFile: string | undefined;
|
||||
prompt: string;
|
||||
cwd: string | undefined;
|
||||
}
|
||||
|
||||
export interface SubsessionSummary {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
status: SubsessionStatus;
|
||||
}
|
||||
|
||||
/** Quick glance at a subsession: status plus its most recent assistant output. */
|
||||
export interface SubsessionCheckResult {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
status: SubsessionStatus;
|
||||
finalText: string;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
/** Exploratory transcript read: a filtered, paginated slice of the subsession's history. */
|
||||
export interface SubsessionReadResult extends TranscriptView {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
status: SubsessionStatus;
|
||||
}
|
||||
|
||||
/** Filters the parent passes to narrow a transcript read; mirrors {@link TranscriptQuery}. */
|
||||
export interface SubsessionReadQuery {
|
||||
roles?: TranscriptRole[];
|
||||
include?: TranscriptContentKind[];
|
||||
search?: string;
|
||||
maxChars?: number;
|
||||
includeToolArgs?: boolean;
|
||||
before?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SubsessionToolDeps {
|
||||
spawn(input: SpawnSubsessionInvocation): Promise<SpawnSubsessionResult>;
|
||||
list(parentSessionId: string): Promise<SubsessionSummary[]>;
|
||||
check(parentSessionId: string, sessionId: string): Promise<SubsessionCheckResult>;
|
||||
read(parentSessionId: string, sessionId: string, query: SubsessionReadQuery): Promise<SubsessionReadResult>;
|
||||
}
|
||||
|
||||
const SpawnSubsessionParams = Type.Object({
|
||||
prompt: Type.String({
|
||||
description: "The first instruction to send to the new tracked subsession.",
|
||||
}),
|
||||
cwd: Type.Optional(Type.String({
|
||||
description: "Working directory for the subsession. Must be a workspace (worktree, or root) of the same project as this session. Defaults to this session's working directory.",
|
||||
})),
|
||||
});
|
||||
|
||||
const ListSubsessionsParams = Type.Object({});
|
||||
|
||||
const CheckSubsessionParams = Type.Object({
|
||||
sessionId: Type.String({
|
||||
description: "Id of a subsession you spawned (as returned by spawn_subsession or list_subsessions).",
|
||||
}),
|
||||
});
|
||||
|
||||
const ReadSubsessionParams = Type.Object({
|
||||
sessionId: Type.String({
|
||||
description: "Id of a subsession you spawned (as returned by spawn_subsession or list_subsessions).",
|
||||
}),
|
||||
roles: Type.Optional(Type.Array(
|
||||
Type.Union([Type.Literal("assistant"), Type.Literal("user"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("custom")]),
|
||||
{ description: "Message roles to include. Omit for all roles." },
|
||||
)),
|
||||
include: Type.Optional(Type.Array(
|
||||
Type.Union([Type.Literal("text"), Type.Literal("thinking"), Type.Literal("tool_call"), Type.Literal("tool_result"), Type.Literal("image")]),
|
||||
{ description: "Content kinds to keep within messages. Omit for all kinds." },
|
||||
)),
|
||||
search: Type.Optional(Type.String({
|
||||
description: "Case-insensitive substring; keep only messages whose text or tool name matches. Always searches full message content, even when maxChars is set.",
|
||||
})),
|
||||
maxChars: Type.Optional(Type.Integer({
|
||||
minimum: 0,
|
||||
description: "Truncate each text/thinking/tool-result value to this many characters; clipped parts are marked '[+N chars truncated]'. Omit for full, untruncated text (there is no default, so truncation only happens when you ask for it).",
|
||||
})),
|
||||
includeToolArgs: Type.Optional(Type.Boolean({
|
||||
description: "Include raw tool-call arguments (can be large). A compact one-line summary of each call is always shown regardless.",
|
||||
})),
|
||||
before: Type.Optional(Type.Integer({
|
||||
minimum: 0,
|
||||
description: "Return only messages before this transcript index; page backward by passing the previous response's 'start'.",
|
||||
})),
|
||||
limit: Type.Optional(Type.Integer({
|
||||
minimum: 1,
|
||||
description: "Maximum number of most-recent matching messages to return within the window (returned in chronological order). Defaults to 50.",
|
||||
})),
|
||||
});
|
||||
|
||||
function statusLine(summary: SubsessionSummary): string {
|
||||
return `- ${summary.sessionId} [${summary.status}] in ${summary.cwd}`;
|
||||
}
|
||||
|
||||
function renderEntry(entry: TranscriptEntry): string {
|
||||
const header = `#${String(entry.index)} ${entry.role}`;
|
||||
const body = entry.parts.map(renderPart).filter((line) => line !== "").join("\n");
|
||||
return body === "" ? header : `${header}\n${body}`;
|
||||
}
|
||||
|
||||
function clipNotice(part: TranscriptEntry["parts"][number]): string {
|
||||
if ((part.kind === "text" || part.kind === "thinking" || part.kind === "tool_result") && part.truncated !== undefined) {
|
||||
return ` [+${String(part.truncated.full - part.truncated.shown)} chars truncated; re-read with a larger maxChars]`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function renderPart(part: TranscriptEntry["parts"][number]): string {
|
||||
if (part.kind === "text") return `${part.text}${clipNotice(part)}`;
|
||||
if (part.kind === "thinking") return `[thinking] ${part.text}${clipNotice(part)}`;
|
||||
if (part.kind === "tool_call") {
|
||||
// Raw args are only present when the caller asked (includeToolArgs); when
|
||||
// present, surface them in the model-facing text, not just `details`.
|
||||
const args = "args" in part && part.args !== undefined ? `\n args: ${JSON.stringify(part.args)}` : "";
|
||||
return `[tool ${part.toolName}] ${part.summary}${args}`;
|
||||
}
|
||||
if (part.kind === "tool_result") return `[result${part.isError ? " error" : ""}${part.toolName === undefined ? "" : ` ${part.toolName}`}] ${part.text}${clipNotice(part)}`;
|
||||
return "[image]";
|
||||
}
|
||||
|
||||
function renderTranscript(result: SubsessionReadResult): string {
|
||||
const last = result.entries[result.entries.length - 1];
|
||||
// Distinguish "nothing matched at all" (widen filters) from "matches exist but
|
||||
// this page/window is empty" (page differently) so the agent isn't misled.
|
||||
const range = last === undefined
|
||||
? (result.matched === 0
|
||||
? "no messages matched your filters"
|
||||
: `no messages in this window (${String(result.matched)} matched outside it)`)
|
||||
: `messages ${String(result.start)}–${String(last.index)} of ${String(result.total)} (${String(result.matched)} matched)`;
|
||||
const more = result.hasMore ? `\n\nMore matching messages exist earlier; page back with before: ${String(result.start)}.` : "";
|
||||
// Empty entries with matches means the `before` cursor excluded every match
|
||||
// (they all sit at index >= before): the agent paged too far back and should
|
||||
// raise `before` or omit it, not page back further.
|
||||
const body = result.entries.length > 0
|
||||
? result.entries.map(renderEntry).join("\n\n")
|
||||
: (result.matched === 0
|
||||
? "(nothing matched; try widening roles/include, dropping search, or raising limit)"
|
||||
: `(no messages before index ${String(result.start)}; all ${String(result.matched)} matches are later — raise 'before' or omit it)`);
|
||||
return `Subsession ${result.sessionId} [${result.status}] — ${range}:\n\n${body}${more}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools that let an agent spawn *tracked* child sessions and inspect them.
|
||||
*
|
||||
* Unlike `spawn_session` (fire-and-forget peers), a subsession records its
|
||||
* parent in its session header, the parent is notified when it stops working,
|
||||
* and the parent may read its transcript/result. The tools are constructed
|
||||
* per-session, carrying the spawning cwd for project-scope validation; the
|
||||
* parent's identity is taken from the live extension context at call time.
|
||||
*/
|
||||
export function createSubsessionToolDefinitions(spawningCwd: string, deps: SubsessionToolDeps) {
|
||||
const spawnTool = defineTool<typeof SpawnSubsessionParams, SpawnSubsessionResult>({
|
||||
name: "spawn_subsession",
|
||||
label: "Spawn subsession",
|
||||
description: "Start a tracked child session and send it an initial prompt. The subsession runs independently and a human can interact with it, but unlike spawn_session it is linked to you: you are notified when it stops working (finished, idle, or errored), and you can inspect it with list_subsessions, check_subsession (a quick glance at its latest output), and read_subsession (read through its transcript). Use this to delegate work you intend to follow up on.",
|
||||
promptSnippet: "spawn_subsession: start a tracked child session you will be notified about",
|
||||
parameters: SpawnSubsessionParams,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
||||
const result = await deps.spawn({ spawningCwd, parentSessionId, parentSessionFile, prompt: params.prompt, cwd: params.cwd });
|
||||
return {
|
||||
content: [{ type: "text", text: `Started subsession ${result.sessionId} in ${result.cwd}. You will be notified when it stops working.` }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const listTool = defineTool<typeof ListSubsessionsParams, { subsessions: SubsessionSummary[] }>({
|
||||
name: "list_subsessions",
|
||||
label: "List subsessions",
|
||||
description: "List the tracked subsessions you spawned, with their current status (working, idle, error, or unknown).",
|
||||
promptSnippet: "list_subsessions: see the tracked child sessions you spawned",
|
||||
parameters: ListSubsessionsParams,
|
||||
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
const subsessions = await deps.list(parentSessionId);
|
||||
const text = subsessions.length === 0
|
||||
? "You have not spawned any subsessions."
|
||||
: `Your subsessions:\n${subsessions.map(statusLine).join("\n")}`;
|
||||
return { content: [{ type: "text", text }], details: { subsessions } };
|
||||
},
|
||||
});
|
||||
|
||||
const checkTool = defineTool<typeof CheckSubsessionParams, SubsessionCheckResult>({
|
||||
name: "check_subsession",
|
||||
label: "Check subsession",
|
||||
description: "Quick glance at a subsession you spawned: its current status and most recent assistant output. Use this to react to what a subsession produced. When the summary is not enough, use read_subsession to look through its full transcript.",
|
||||
promptSnippet: "check_subsession: glance at a subsession's status and latest output",
|
||||
parameters: CheckSubsessionParams,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
const result = await deps.check(parentSessionId, params.sessionId);
|
||||
const body = result.finalText === "" ? "(no output yet)" : result.finalText;
|
||||
return {
|
||||
content: [{ type: "text", text: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}` }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const readTool = defineTool<typeof ReadSubsessionParams, SubsessionReadResult>({
|
||||
name: "read_subsession",
|
||||
label: "Read subsession",
|
||||
description: "Read through the transcript of a subsession you spawned. Returns its messages filtered and paginated however you ask: choose which roles (assistant, user, tool, system, custom) and content kinds (text, thinking, tool_call, tool_result, image) to include, search for a substring (always over full content), cap each value's length with maxChars (omit for full text; clipped parts are flagged so truncation is never silent), optionally include raw tool args, and page backward with 'before'/'limit'. Start narrow (e.g. assistant text with a small maxChars) and widen the filters, raise maxChars, or page further back if you don't find what you need. For just the final result, use check_subsession instead.",
|
||||
promptSnippet: "read_subsession: read through a subsession's transcript with filters",
|
||||
parameters: ReadSubsessionParams,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
const { sessionId, ...query } = params;
|
||||
const result = await deps.read(parentSessionId, sessionId, query);
|
||||
return {
|
||||
content: [{ type: "text", text: renderTranscript(result) }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return [spawnTool, listTool, checkTool, readTool];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Project, Workspace } from "../types.js";
|
||||
import { ProjectScopedSpawnTargetResolver } from "./spawnTargetResolver.js";
|
||||
|
||||
function project(id: string, path: string): Project {
|
||||
return { id, name: id, path, createdAt: "2026-01-01T00:00:00.000Z" };
|
||||
}
|
||||
|
||||
function workspace(projectId: string, path: string): Workspace {
|
||||
return { id: `${projectId}:${path}`, projectId, path, label: path, isMain: false, isGitRepo: true, isGitWorktree: true };
|
||||
}
|
||||
|
||||
function resolverFor(projects: Project[], workspacesByProject: Record<string, Workspace[]>): ProjectScopedSpawnTargetResolver {
|
||||
return new ProjectScopedSpawnTargetResolver({
|
||||
projects: { list: () => Promise.resolve(projects) },
|
||||
workspaces: { list: (p) => Promise.resolve(workspacesByProject[p.id] ?? []) },
|
||||
});
|
||||
}
|
||||
|
||||
describe("ProjectScopedSpawnTargetResolver", () => {
|
||||
it("allows a target that is a workspace of the spawning session's project", async () => {
|
||||
const resolver = resolverFor([project("a", "/repos/a"), project("b", "/repos/b")], {
|
||||
a: [workspace("a", "/repos/a"), workspace("a", "/repos/a-feature")],
|
||||
b: [workspace("b", "/repos/b")],
|
||||
});
|
||||
|
||||
await expect(resolver.resolveSpawnTarget("/repos/a", "/repos/a-feature")).resolves.toEqual({ allowed: true, cwd: "/repos/a-feature" });
|
||||
});
|
||||
|
||||
it("defaults the target to the spawning cwd when none is requested", async () => {
|
||||
const resolver = resolverFor([project("a", "/repos/a")], { a: [workspace("a", "/repos/a")] });
|
||||
|
||||
await expect(resolver.resolveSpawnTarget("/repos/a", undefined)).resolves.toEqual({ allowed: true, cwd: "/repos/a" });
|
||||
});
|
||||
|
||||
it("returns the canonical workspace path even when the request differs only by trailing slash", async () => {
|
||||
const resolver = resolverFor([project("a", "/repos/a")], { a: [workspace("a", "/repos/a")] });
|
||||
|
||||
await expect(resolver.resolveSpawnTarget("/repos/a", "/repos/a/")).resolves.toEqual({ allowed: true, cwd: "/repos/a" });
|
||||
});
|
||||
|
||||
it("rejects a target outside the project's workspaces and lists the allowed ones", async () => {
|
||||
const resolver = resolverFor([project("a", "/repos/a")], { a: [workspace("a", "/repos/a"), workspace("a", "/repos/a-feature")] });
|
||||
|
||||
await expect(resolver.resolveSpawnTarget("/repos/a", "/elsewhere")).resolves.toEqual({
|
||||
allowed: false,
|
||||
reason: "out-of-project",
|
||||
allowedCwds: ["/repos/a", "/repos/a-feature"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects when the spawning cwd is in no registered project", async () => {
|
||||
const resolver = resolverFor([project("a", "/repos/a")], { a: [workspace("a", "/repos/a")] });
|
||||
|
||||
await expect(resolver.resolveSpawnTarget("/elsewhere", undefined)).resolves.toEqual({ allowed: false, reason: "not-registered" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Project, Workspace } from "../types.js";
|
||||
import { cwdPathsEqual } from "../workingDirectory.js";
|
||||
|
||||
/**
|
||||
* Decision describing whether a LLM-spawned session may target a given cwd.
|
||||
*
|
||||
* - `allowed: true` carries the canonical workspace path to start the session in
|
||||
* (always one of the project's known workspace paths, so it is guaranteed
|
||||
* visible in the web UI).
|
||||
* - `not-registered` means the spawning session's cwd belongs to no registered
|
||||
* project, so spawning must be refused to preserve visibility.
|
||||
* - `out-of-project` means the requested cwd is not a workspace of the spawning
|
||||
* session's project; `allowedCwds` lists the valid targets for the caller to
|
||||
* surface.
|
||||
*/
|
||||
export type SpawnTargetDecision =
|
||||
| { allowed: true; cwd: string }
|
||||
| { allowed: false; reason: "not-registered" }
|
||||
| { allowed: false; reason: "out-of-project"; allowedCwds: string[] };
|
||||
|
||||
/**
|
||||
* Owns the rule that keeps LLM-spawned sessions visible: a spawned session may
|
||||
* only target a workspace (worktree, or root) of the registered project that
|
||||
* owns the spawning session. The rule is evaluated live so a worktree the agent
|
||||
* just created with `git worktree add` is included.
|
||||
*/
|
||||
export interface SpawnTargetResolver {
|
||||
/**
|
||||
* Decide whether a session spawned from `spawningCwd` may target
|
||||
* `requestedCwd` (defaulting to `spawningCwd` when omitted), returning the
|
||||
* canonical target cwd when allowed.
|
||||
*/
|
||||
resolveSpawnTarget(spawningCwd: string, requestedCwd: string | undefined): Promise<SpawnTargetDecision>;
|
||||
}
|
||||
|
||||
interface ProjectLister {
|
||||
list(): Promise<Project[]>;
|
||||
}
|
||||
|
||||
interface WorkspaceLister {
|
||||
list(project: Project): Promise<Workspace[]>;
|
||||
}
|
||||
|
||||
export interface ProjectScopedSpawnTargetResolverDeps {
|
||||
projects: ProjectLister;
|
||||
workspaces: WorkspaceLister;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default resolver composing the project registry and live worktree discovery.
|
||||
* It finds the registered project whose current workspace set contains the
|
||||
* spawning session's cwd, then validates the requested target against that set.
|
||||
*/
|
||||
export class ProjectScopedSpawnTargetResolver implements SpawnTargetResolver {
|
||||
constructor(private readonly deps: ProjectScopedSpawnTargetResolverDeps) {}
|
||||
|
||||
async resolveSpawnTarget(spawningCwd: string, requestedCwd: string | undefined): Promise<SpawnTargetDecision> {
|
||||
const allowedCwds = await this.allowedSpawnTargets(spawningCwd);
|
||||
if (allowedCwds === undefined) return { allowed: false, reason: "not-registered" };
|
||||
const target = requestedCwd === undefined || requestedCwd === "" ? spawningCwd : requestedCwd;
|
||||
const match = allowedCwds.find((path) => cwdPathsEqual(path, target));
|
||||
if (match === undefined) return { allowed: false, reason: "out-of-project", allowedCwds };
|
||||
return { allowed: true, cwd: match };
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace paths of the registered project that owns `spawningCwd`, or
|
||||
* `undefined` when no registered project contains it.
|
||||
*/
|
||||
private async allowedSpawnTargets(spawningCwd: string): Promise<string[] | undefined> {
|
||||
const projects = await this.deps.projects.list();
|
||||
for (const project of projects) {
|
||||
const workspaces = await this.deps.workspaces.list(project);
|
||||
const paths = workspaces.map((workspace) => workspace.path);
|
||||
if (paths.some((path) => cwdPathsEqual(path, spawningCwd))) return paths;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildTranscriptView } from "./subsessionTranscript.js";
|
||||
|
||||
const user = (text: string) => ({ role: "user", content: text });
|
||||
const assistant = (text: string) => ({ role: "assistant", content: [{ type: "text", text }] });
|
||||
const thinking = (text: string) => ({ role: "assistant", content: [{ type: "thinking", thinking: text }] });
|
||||
const toolCall = (name: string, args?: unknown) => ({ role: "assistant", content: [{ type: "toolCall", name, ...(args === undefined ? {} : { arguments: args }) }] });
|
||||
const toolResult = (text: string, toolName = "bash", isError = false) => ({ role: "toolResult", toolName, content: text, isError });
|
||||
const custom = (text: string) => ({ role: "custom", content: text, customType: "subsession.completion" });
|
||||
|
||||
describe("buildTranscriptView", () => {
|
||||
it("returns all entries with stable indices by default", () => {
|
||||
const messages = [user("do it"), thinking("plan"), toolCall("bash"), toolResult("ok"), assistant("done")];
|
||||
const view = buildTranscriptView(messages);
|
||||
|
||||
expect(view.total).toBe(5);
|
||||
expect(view.matched).toBe(5);
|
||||
expect(view.entries.map((entry) => entry.index)).toEqual([0, 1, 2, 3, 4]);
|
||||
expect(view.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("filters by role", () => {
|
||||
const messages = [user("do it"), thinking("plan"), assistant("done")];
|
||||
const view = buildTranscriptView(messages, { roles: ["assistant"] });
|
||||
|
||||
expect(view.matched).toBe(2); // thinking + text are both assistant-role
|
||||
expect(view.entries.every((entry) => entry.role === "assistant")).toBe(true);
|
||||
});
|
||||
|
||||
it("filters by content kind, dropping entries left empty", () => {
|
||||
const messages = [user("do it"), thinking("plan"), assistant("answer"), toolCall("bash")];
|
||||
const view = buildTranscriptView(messages, { include: ["text"] });
|
||||
|
||||
// user text + assistant text survive; thinking-only and tool_call-only entries drop out
|
||||
expect(view.matched).toBe(2);
|
||||
expect(view.entries.flatMap((entry) => entry.parts.map((part) => part.kind))).toEqual(["text", "text"]);
|
||||
});
|
||||
|
||||
it("does not truncate by default and omits tool args", () => {
|
||||
const long = "x".repeat(800);
|
||||
const messages = [assistant(long), toolCall("bash", { command: "ls", extra: "y" })];
|
||||
const view = buildTranscriptView(messages);
|
||||
|
||||
const textPart = view.entries[0]?.parts[0];
|
||||
if (textPart?.kind !== "text") throw new Error("expected text part");
|
||||
expect(textPart.text).toBe(long);
|
||||
expect(textPart.truncated).toBeUndefined();
|
||||
|
||||
const callPart = view.entries[1]?.parts[0];
|
||||
if (callPart?.kind !== "tool_call") throw new Error("expected tool_call part");
|
||||
expect(callPart.summary).toBe("ls");
|
||||
expect("args" in callPart).toBe(false);
|
||||
});
|
||||
|
||||
it("maxChars clips text and flags it with the full length", () => {
|
||||
const long = "x".repeat(800);
|
||||
const messages = [assistant(long)];
|
||||
const view = buildTranscriptView(messages, { maxChars: 100 });
|
||||
|
||||
const textPart = view.entries[0]?.parts[0];
|
||||
if (textPart?.kind !== "text") throw new Error("expected text part");
|
||||
expect(textPart.text).toBe("x".repeat(100));
|
||||
expect(textPart.truncated).toEqual({ shown: 100, full: 800 });
|
||||
});
|
||||
|
||||
it("maxChars does not flag values at or under the cap", () => {
|
||||
const messages = [assistant("short")];
|
||||
const view = buildTranscriptView(messages, { maxChars: 100 });
|
||||
const textPart = view.entries[0]?.parts[0];
|
||||
if (textPart?.kind !== "text") throw new Error("expected text part");
|
||||
expect(textPart.text).toBe("short");
|
||||
expect(textPart.truncated).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includeToolArgs returns raw args alongside the summary", () => {
|
||||
const messages = [toolCall("bash", { command: "ls" })];
|
||||
const view = buildTranscriptView(messages, { includeToolArgs: true });
|
||||
const callPart = view.entries[0]?.parts[0];
|
||||
if (callPart?.kind !== "tool_call") throw new Error("expected tool_call part");
|
||||
expect(callPart.summary).toBe("ls");
|
||||
expect(callPart.args).toEqual({ command: "ls" });
|
||||
});
|
||||
|
||||
it("search keeps only matching entries across text and tool names", () => {
|
||||
const messages = [assistant("the auth flow"), assistant("unrelated"), toolResult("error in auth.ts", "read")];
|
||||
const view = buildTranscriptView(messages, { search: "auth" });
|
||||
|
||||
expect(view.matched).toBe(2);
|
||||
expect(view.entries.map((entry) => entry.index)).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
it("search runs against full content even when maxChars would clip the match away", () => {
|
||||
// The match sits past the clip point; a window-first or clip-first search would miss it.
|
||||
const text = `${"a".repeat(300)} NEEDLE ${"b".repeat(300)}`;
|
||||
const messages = [assistant(text)];
|
||||
const view = buildTranscriptView(messages, { search: "needle", maxChars: 50 });
|
||||
|
||||
expect(view.matched).toBe(1);
|
||||
const textPart = view.entries[0]?.parts[0];
|
||||
if (textPart?.kind !== "text") throw new Error("expected text part");
|
||||
// The match is found, and the returned (clipped) text honestly flags truncation.
|
||||
expect(textPart.truncated).toEqual({ shown: 50, full: text.length });
|
||||
});
|
||||
|
||||
it("search matches tool-call arguments even without includeToolArgs", () => {
|
||||
const messages = [toolCall("bash", { command: "grep NEEDLE src" })];
|
||||
const view = buildTranscriptView(messages, { search: "needle" });
|
||||
expect(view.matched).toBe(1);
|
||||
});
|
||||
|
||||
it("search finds args the display summary would drop (edit/write content, nested, beyond first 3 keys)", () => {
|
||||
// summarizeToolArgs collapses these to 'edit text replacement' / 'object' / first-3-keys,
|
||||
// so matching must serialize the full args, not the summary.
|
||||
const editArgs = { oldText: "before", newText: "NEEDLE_IN_NEWTEXT" };
|
||||
const nestedArgs = { a: 1, b: 2, c: 3, payload: { deep: "NEEDLE_NESTED" } };
|
||||
const messages = [toolCall("edit", editArgs), toolCall("write", nestedArgs)];
|
||||
|
||||
expect(buildTranscriptView(messages, { search: "needle_in_newtext" }).matched).toBe(1);
|
||||
expect(buildTranscriptView(messages, { search: "needle_nested" }).matched).toBe(1);
|
||||
});
|
||||
|
||||
it("maxChars boundary: exact length is not flagged, one over is", () => {
|
||||
const exact = buildTranscriptView([assistant("x".repeat(50))], { maxChars: 50 }).entries[0]?.parts[0];
|
||||
if (exact?.kind !== "text") throw new Error("expected text part");
|
||||
expect(exact.truncated).toBeUndefined();
|
||||
|
||||
const over = buildTranscriptView([assistant("x".repeat(51))], { maxChars: 50 }).entries[0]?.parts[0];
|
||||
if (over?.kind !== "text") throw new Error("expected text part");
|
||||
expect(over.truncated).toEqual({ shown: 50, full: 51 });
|
||||
});
|
||||
|
||||
it("maxChars: 0 clips everything and flags it (not treated as 'no cap')", () => {
|
||||
const part = buildTranscriptView([assistant("abc")], { maxChars: 0 }).entries[0]?.parts[0];
|
||||
if (part?.kind !== "text") throw new Error("expected text part");
|
||||
expect(part.text).toBe("");
|
||||
expect(part.truncated).toEqual({ shown: 0, full: 3 });
|
||||
});
|
||||
|
||||
it("negative or fractional maxChars is coerced to a safe non-negative integer, never 'no cap'", () => {
|
||||
const negative = buildTranscriptView([assistant("abc")], { maxChars: -5 }).entries[0]?.parts[0];
|
||||
if (negative?.kind !== "text") throw new Error("expected text part");
|
||||
expect(negative.text).toBe(""); // coerced to 0, still truncates
|
||||
expect(negative.truncated).toEqual({ shown: 0, full: 3 });
|
||||
|
||||
const fractional = buildTranscriptView([assistant("abcdef")], { maxChars: 2.9 }).entries[0]?.parts[0];
|
||||
if (fractional?.kind !== "text") throw new Error("expected text part");
|
||||
expect(fractional.text).toBe("ab"); // floored to 2
|
||||
expect(fractional.truncated).toEqual({ shown: 2, full: 6 });
|
||||
});
|
||||
|
||||
it("empty window with matches reports matched > 0 (paged past all matches)", () => {
|
||||
const messages = [assistant("a"), assistant("b"), assistant("c")];
|
||||
const view = buildTranscriptView(messages, { before: 0 });
|
||||
expect(view.entries).toEqual([]);
|
||||
expect(view.matched).toBe(3); // matches exist, the window just excluded them
|
||||
expect(view.start).toBe(0);
|
||||
expect(view.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("pages from the end and reports hasMore", () => {
|
||||
const messages = [assistant("a"), assistant("b"), assistant("c"), assistant("d")];
|
||||
const view = buildTranscriptView(messages, { limit: 2 });
|
||||
|
||||
expect(view.entries.map((entry) => entry.index)).toEqual([2, 3]);
|
||||
expect(view.matched).toBe(4);
|
||||
expect(view.start).toBe(2);
|
||||
expect(view.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("pages backward using before: previous start", () => {
|
||||
const messages = [assistant("a"), assistant("b"), assistant("c"), assistant("d")];
|
||||
const view = buildTranscriptView(messages, { limit: 2, before: 2 });
|
||||
|
||||
expect(view.entries.map((entry) => entry.index)).toEqual([0, 1]);
|
||||
expect(view.start).toBe(0);
|
||||
expect(view.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("limit bounds matched entries, not raw messages", () => {
|
||||
const messages = [user("u1"), assistant("a1"), user("u2"), assistant("a2"), user("u3"), assistant("a3")];
|
||||
const view = buildTranscriptView(messages, { roles: ["assistant"], limit: 2 });
|
||||
|
||||
expect(view.matched).toBe(3);
|
||||
expect(view.entries.map((entry) => entry.index)).toEqual([3, 5]);
|
||||
expect(view.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("reports start as before when nothing matches in the window", () => {
|
||||
const messages = [assistant("a"), assistant("b")];
|
||||
const view = buildTranscriptView(messages, { search: "absent" });
|
||||
|
||||
expect(view.entries).toEqual([]);
|
||||
expect(view.matched).toBe(0);
|
||||
expect(view.start).toBe(2);
|
||||
expect(view.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("includes custom and system roles", () => {
|
||||
const messages = [custom("subsession done"), { role: "system", source: "compaction", content: "Compacted history:\n\nstuff" }];
|
||||
const all = buildTranscriptView(messages);
|
||||
expect(all.entries.map((entry) => entry.role)).toEqual(["custom", "system"]);
|
||||
|
||||
const onlyCustom = buildTranscriptView(messages, { roles: ["custom"] });
|
||||
expect(onlyCustom.matched).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Pure helpers for the `read_subsession` tool: turn a subsession's normalized
|
||||
* history (as produced by `historyMessages`) into a filtered, projected,
|
||||
* paginated view the parent agent can explore.
|
||||
*
|
||||
* The agent drives the read: it picks which roles and content kinds it cares
|
||||
* about, how much detail it wants, and how far back to look. If a narrow read
|
||||
* does not answer its question it can widen the filters or page further back,
|
||||
* the same grep-then-read loop it already uses on files. Everything here is a
|
||||
* pure transform over an array so it can be unit-tested without a live session.
|
||||
*/
|
||||
|
||||
/** Message roles the parent can ask for, mapped from raw history roles. */
|
||||
export type TranscriptRole = "assistant" | "user" | "tool" | "system" | "custom";
|
||||
|
||||
/** Content kinds the parent can keep within retained messages. */
|
||||
export type TranscriptContentKind = "text" | "thinking" | "tool_call" | "tool_result" | "image";
|
||||
|
||||
/**
|
||||
* Marks a text value that the caller's `maxChars` clipped. Carries the full
|
||||
* length so the consumer knows *how much* was dropped and can re-read with a
|
||||
* larger `maxChars` (or none). Truncation only ever happens when the caller
|
||||
* passes `maxChars`, so a `truncated` marker is always something they asked
|
||||
* for and should expect, never a silent surprise. Its presence (not a `…`
|
||||
* glyph, which is indistinguishable from real content) is the reliable signal.
|
||||
*/
|
||||
export interface TranscriptTruncation {
|
||||
/** Characters retained in `text`. */
|
||||
shown: number;
|
||||
/** Length of the original, untruncated text. */
|
||||
full: number;
|
||||
}
|
||||
|
||||
export type TranscriptPart =
|
||||
| { kind: "text"; text: string; truncated?: TranscriptTruncation }
|
||||
| { kind: "thinking"; text: string; truncated?: TranscriptTruncation }
|
||||
| { kind: "tool_call"; toolName: string; summary: string; args?: unknown }
|
||||
| { kind: "tool_result"; toolName?: string; text: string; isError: boolean; truncated?: TranscriptTruncation }
|
||||
| { kind: "image" };
|
||||
|
||||
export interface TranscriptEntry {
|
||||
/** Position of this message in the full transcript (stable across reads). */
|
||||
index: number;
|
||||
role: TranscriptRole;
|
||||
parts: TranscriptPart[];
|
||||
}
|
||||
|
||||
export interface TranscriptQuery {
|
||||
/** Message roles to include. Omit for all roles. */
|
||||
roles?: TranscriptRole[];
|
||||
/** Content kinds to keep within retained messages. Omit for all kinds. */
|
||||
include?: TranscriptContentKind[];
|
||||
/** Case-insensitive substring; keep only entries whose text matches. */
|
||||
search?: string;
|
||||
/**
|
||||
* Truncate each text/thinking/tool_result value to this many characters,
|
||||
* flagging clipped parts with `truncated`. Omit for full, untruncated text:
|
||||
* there is deliberately no default, so truncation only happens when asked for
|
||||
* and a `truncated` marker is always expected. `search` always runs against
|
||||
* the full content regardless, so clipping never hides a match.
|
||||
*/
|
||||
maxChars?: number;
|
||||
/** Include raw tool-call arguments (can be large). The compact `summary` is always present. */
|
||||
includeToolArgs?: boolean;
|
||||
/** Upper bound (exclusive) on original index; page backward by passing the previous `start`. */
|
||||
before?: number;
|
||||
/** Keep at most this many of the most-recent matches in the window; entries are returned in chronological order. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface TranscriptView {
|
||||
entries: TranscriptEntry[];
|
||||
/** Total messages in the full transcript, before any filtering. */
|
||||
total: number;
|
||||
/** Entries matching the role/content/search filters across the whole transcript. */
|
||||
matched: number;
|
||||
/** Original index of the first returned entry, or `before` when nothing matched in-window. */
|
||||
start: number;
|
||||
/** True when matching entries exist before `start` (page back with `before: start`). */
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
/**
|
||||
* Build a filtered, projected, paginated view of a normalized transcript.
|
||||
*
|
||||
* Filtering happens before paging for *semantics*, not speed: `search`, the
|
||||
* `matched` count, and "page backward through matches" all require scanning the
|
||||
* whole transcript, so a window-first approach could not answer them. The
|
||||
* tradeoff is an O(total) scan per call (paging a raw window first would be
|
||||
* cheaper), but `total` is a single session's history and this runs once per
|
||||
* tool call, so the scan is negligible. The cost that matters for an LLM tool,
|
||||
* the tokens returned, is bounded by `limit` regardless of ordering; `matched`
|
||||
* is only a count, so the agent learns whether widening or paging is worthwhile
|
||||
* without paying to receive every match.
|
||||
*/
|
||||
export function buildTranscriptView(messages: readonly unknown[], query: TranscriptQuery = {}): TranscriptView {
|
||||
const total = messages.length;
|
||||
// Explicit, caller-owned truncation: only when provided, and a malformed
|
||||
// value (negative/fractional) is coerced to a safe non-negative integer
|
||||
// rather than silently meaning "no cap".
|
||||
const maxChars = query.maxChars === undefined ? undefined : Math.max(0, Math.floor(query.maxChars));
|
||||
const includeToolArgs = query.includeToolArgs === true;
|
||||
const roleFilter = query.roles === undefined ? undefined : new Set(query.roles);
|
||||
const includeFilter = query.include === undefined ? undefined : new Set(query.include);
|
||||
const search = query.search !== undefined && query.search !== "" ? query.search.toLowerCase() : undefined;
|
||||
|
||||
// Extract *full* (untruncated) parts and run all filtering/search on them, so
|
||||
// matching never depends on `maxChars`. Projection (clipping, arg dropping)
|
||||
// happens later and only on the entries we actually return.
|
||||
const matchedEntries: FullEntry[] = [];
|
||||
for (let index = 0; index < total; index++) {
|
||||
const role = roleOf(messages[index]);
|
||||
if (role === undefined) continue;
|
||||
if (roleFilter !== undefined && !roleFilter.has(role)) continue;
|
||||
|
||||
let parts = fullPartsOf(messages[index], role);
|
||||
if (includeFilter !== undefined) parts = parts.filter((part) => includeFilter.has(part.kind));
|
||||
if (parts.length === 0) continue;
|
||||
if (search !== undefined && !partsMatchSearch(parts, search)) continue;
|
||||
|
||||
matchedEntries.push({ index, role, parts });
|
||||
}
|
||||
|
||||
const matched = matchedEntries.length;
|
||||
const before = clampInteger(query.before ?? total, 0, total);
|
||||
const limit = clampInteger(query.limit ?? DEFAULT_LIMIT, 1, MAX_LIMIT);
|
||||
|
||||
const inWindow = matchedEntries.filter((entry) => entry.index < before);
|
||||
const windowed = inWindow.slice(Math.max(0, inWindow.length - limit));
|
||||
const entries = windowed.map((entry) => projectEntry(entry, maxChars, includeToolArgs));
|
||||
const first = windowed[0];
|
||||
const start = first === undefined ? before : first.index;
|
||||
const hasMore = inWindow.length > windowed.length;
|
||||
|
||||
return { entries, total, matched, start, hasMore };
|
||||
}
|
||||
|
||||
/**
|
||||
* A part before projection: tool calls keep their raw `args`, text-bearing
|
||||
* parts keep their full untruncated `text`. Search and filtering run on these
|
||||
* so a match is never hidden by `summary` truncation.
|
||||
*/
|
||||
type FullPart =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "thinking"; text: string }
|
||||
| { kind: "tool_call"; toolName: string; args?: unknown }
|
||||
| { kind: "tool_result"; toolName?: string; text: string; isError: boolean }
|
||||
| { kind: "image" };
|
||||
|
||||
interface FullEntry {
|
||||
index: number;
|
||||
role: TranscriptRole;
|
||||
parts: FullPart[];
|
||||
}
|
||||
|
||||
function partsMatchSearch(parts: readonly FullPart[], needle: string): boolean {
|
||||
return parts.some((part) => {
|
||||
if (part.kind === "text" || part.kind === "thinking") return part.text.toLowerCase().includes(needle);
|
||||
if (part.kind === "tool_result") return part.text.toLowerCase().includes(needle) || (part.toolName?.toLowerCase().includes(needle) ?? false);
|
||||
// Search the *full* serialized args, not the lossy one-line summary, so a
|
||||
// term inside edit/write content, nested objects, or long values is found.
|
||||
if (part.kind === "tool_call") return part.toolName.toLowerCase().includes(needle) || stringifyArgs(part.args).toLowerCase().includes(needle);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/** Full, search-friendly serialization of tool-call args (distinct from the lossy display summary). */
|
||||
function stringifyArgs(args: unknown): string {
|
||||
if (args === undefined) return "";
|
||||
if (typeof args === "string") return args;
|
||||
try {
|
||||
// JSON.stringify can return undefined at runtime (e.g. a function/symbol),
|
||||
// despite its string-typed signature; normalize that to "".
|
||||
const json: unknown = JSON.stringify(args);
|
||||
return typeof json === "string" ? json : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Project a fully-extracted entry into the returned shape, clipping only when `maxChars` is set. */
|
||||
function projectEntry(entry: FullEntry, maxChars: number | undefined, includeToolArgs: boolean): TranscriptEntry {
|
||||
return { index: entry.index, role: entry.role, parts: entry.parts.map((part) => projectPart(part, maxChars, includeToolArgs)) };
|
||||
}
|
||||
|
||||
function projectPart(part: FullPart, maxChars: number | undefined, includeToolArgs: boolean): TranscriptPart {
|
||||
if (part.kind === "text") return { kind: "text", ...clip(part.text, maxChars) };
|
||||
if (part.kind === "thinking") return { kind: "thinking", ...clip(part.text, maxChars) };
|
||||
if (part.kind === "tool_result") {
|
||||
return {
|
||||
kind: "tool_result",
|
||||
...(part.toolName === undefined ? {} : { toolName: part.toolName }),
|
||||
isError: part.isError,
|
||||
...clip(part.text, maxChars),
|
||||
};
|
||||
}
|
||||
if (part.kind === "tool_call") {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
toolName: part.toolName,
|
||||
summary: summarizeToolArgs(part.args),
|
||||
...(includeToolArgs && part.args !== undefined ? { args: part.args } : {}),
|
||||
};
|
||||
}
|
||||
return { kind: "image" };
|
||||
}
|
||||
|
||||
/** Clip text to `maxChars`, attaching a `truncated` marker when it actually shortens. */
|
||||
function clip(text: string, maxChars: number | undefined): { text: string; truncated?: TranscriptTruncation } {
|
||||
if (maxChars === undefined || text.length <= maxChars) return { text };
|
||||
return { text: text.slice(0, maxChars), truncated: { shown: maxChars, full: text.length } };
|
||||
}
|
||||
|
||||
/** Map a raw history message to one of the agent-facing roles, or undefined to drop it. */
|
||||
function roleOf(message: unknown): TranscriptRole | undefined {
|
||||
const role = getString(message, "role");
|
||||
if (role === "assistant") return "assistant";
|
||||
if (role === "user") return "user";
|
||||
if (role === "toolResult") return "tool";
|
||||
if (role === "custom") return "custom";
|
||||
if (role === "system") return "system";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Extract a message's *full* (untruncated) parts; projection happens later. */
|
||||
function fullPartsOf(message: unknown, role: TranscriptRole): FullPart[] {
|
||||
if (role === "tool") return toolResultParts(message);
|
||||
const content = getProperty(message, "content");
|
||||
if (typeof content === "string") return content === "" ? [] : [{ kind: "text", text: content }];
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content.flatMap(contentPart);
|
||||
}
|
||||
|
||||
function toolResultParts(message: unknown): FullPart[] {
|
||||
const text = stringifyContent(getProperty(message, "content")) || (getString(message, "text") ?? "");
|
||||
const toolName = getString(message, "toolName");
|
||||
const isError = getProperty(message, "isError") === true;
|
||||
return [{ kind: "tool_result", ...(toolName === undefined ? {} : { toolName }), text, isError }];
|
||||
}
|
||||
|
||||
function contentPart(part: unknown): FullPart[] {
|
||||
const type = getString(part, "type");
|
||||
if (type === "text") {
|
||||
const text = getString(part, "text") ?? "";
|
||||
return text === "" ? [] : [{ kind: "text", text }];
|
||||
}
|
||||
if (type === "thinking") {
|
||||
const text = getString(part, "thinking") ?? getString(part, "text") ?? "";
|
||||
return text === "" ? [] : [{ kind: "thinking", text }];
|
||||
}
|
||||
if (type === "toolCall") {
|
||||
const toolName = getString(part, "name") ?? "tool";
|
||||
const args = getProperty(part, "arguments");
|
||||
return [{ kind: "tool_call", toolName, ...(args === undefined ? {} : { args }) }];
|
||||
}
|
||||
if (type === "image") return [{ kind: "image" }];
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Compact one-line description of tool arguments (mirrors the UI's summary). */
|
||||
function summarizeToolArgs(args: unknown): string {
|
||||
if (!isRecord(args)) return typeof args === "string" ? args : "";
|
||||
const command = getString(args, "command");
|
||||
if (command !== undefined) return command;
|
||||
const path = getString(args, "path");
|
||||
if (path !== undefined) return path;
|
||||
if (typeof args["oldText"] === "string" && typeof args["newText"] === "string") return "edit text replacement";
|
||||
const edits = args["edits"];
|
||||
if (Array.isArray(edits)) return `${String(edits.length)} edit${edits.length === 1 ? "" : "s"}`;
|
||||
const entries = Object.entries(args).filter(([, value]) => value != null).slice(0, 3);
|
||||
return entries.map(([key, value]) => `${key}: ${shortValue(value)}`).join(" · ");
|
||||
}
|
||||
|
||||
function shortValue(value: unknown): string {
|
||||
if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}…` : value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (Array.isArray(value)) return `${String(value.length)} item${value.length === 1 ? "" : "s"}`;
|
||||
if (typeof value === "object" && value !== null) return "object";
|
||||
return "";
|
||||
}
|
||||
|
||||
function stringifyContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) => (getString(part, "type") === "image" ? "[image]" : getString(part, "text") ?? ""))
|
||||
.filter((text) => text !== "")
|
||||
.join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function clampInteger(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return max;
|
||||
return Math.max(min, Math.min(max, Math.floor(value)));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getProperty(value: unknown, key: string): unknown {
|
||||
return isRecord(value) ? value[key] : undefined;
|
||||
}
|
||||
|
||||
function getString(value: unknown, key: string): string | undefined {
|
||||
const property = getProperty(value, key);
|
||||
return typeof property === "string" ? property : undefined;
|
||||
}
|
||||
@@ -1,23 +1,26 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { ProjectService } from "./projects/projectService.js";
|
||||
import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import type { WriteWorkspaceFileOptions } from "../shared/apiTypes.js";
|
||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
|
||||
import type { PiWebConfigService } from "./configRoutes.js";
|
||||
import type { ProjectService } from "./projects/projectService.js";
|
||||
import { deleteWorkspaceFile, moveWorkspaceFile, readWorkspaceFile, writeWorkspaceFile } from "./workspaces/fileContentService.js";
|
||||
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
|
||||
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
|
||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||
import { pathAccessForWorkspaceContext } from "./workspaces/effectivePathAccess.js";
|
||||
import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
|
||||
export interface WorkspaceExplorerRouteOptions {
|
||||
config?: Pick<PiWebConfigService, "read">;
|
||||
}
|
||||
|
||||
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api", options: WorkspaceExplorerRouteOptions = {}): void {
|
||||
registerWorkspaceFileContentParsers(app);
|
||||
|
||||
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void {
|
||||
// Register content type parsers for workspace file writes.
|
||||
// Fastify's default parser only handles application/json.
|
||||
// Guard against re-registration since this function may be called multiple times.
|
||||
try { app.addContentTypeParser("text/plain", { parseAs: "string" }, (_req, body, done) => { done(null, Buffer.from(body)); }); } catch { /* already registered */ }
|
||||
try { app.addContentTypeParser("application/octet-stream", { parseAs: "buffer" }, (_req, body, done) => { done(null, body); }); } catch { /* already registered */ }
|
||||
try { app.addContentTypeParser(/^([a-z]+\/[a-z0-9.+-]+)$/, { parseAs: "buffer" }, (_req, body, done) => { done(null, body); }); } catch { /* already registered */ }
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/tree`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await listWorkspaceTree(context.root, request.query.path);
|
||||
return await listWorkspaceTree(context.root, request.query.path, await pathAccessForWorkspaceContext(context, options.config));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
@@ -26,7 +29,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await readWorkspaceFile(context.root, request.query.path);
|
||||
return await readWorkspaceFile(context.root, request.query.path, await pathAccessForWorkspaceContext(context, options.config));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
@@ -35,11 +38,11 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
|
||||
app.put<{ Params: { projectId: string; workspaceId: string }; Body: Buffer; Querystring: { path?: string; createDirs?: string; overwrite?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
const options: WriteWorkspaceFileOptions = {
|
||||
const writeOptions: WriteWorkspaceFileOptions = {
|
||||
createDirs: request.query.createDirs !== "false",
|
||||
overwrite: request.query.overwrite !== "false",
|
||||
};
|
||||
return await writeWorkspaceFile(context.root, request.query.path, request.body, options);
|
||||
return await writeWorkspaceFile(context.root, request.query.path, request.body, writeOptions);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
@@ -69,7 +72,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/preview`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
const preview = await readWorkspaceImagePreview(context.root, request.query.path);
|
||||
const preview = await readWorkspaceImagePreview(context.root, request.query.path, await pathAccessForWorkspaceContext(context, options.config));
|
||||
return await reply
|
||||
.type(preview.mimeType)
|
||||
.header("Cache-Control", "private, max-age=3600")
|
||||
@@ -82,4 +85,25 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/files`, async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
const query = request.query.q ?? "";
|
||||
const pathAccess = isAbsoluteishFileSuggestionQuery(query) ? await pathAccessForWorkspaceContext(context, options.config) : undefined;
|
||||
if (request.query.mode === "path") return await listPathSuggestions(context.root, query, pathAccess);
|
||||
return await listFileSuggestions(context.root, query, { kind: request.query.kind, scope: request.query.scope, pathAccess });
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerWorkspaceFileContentParsers(app: FastifyInstance): void {
|
||||
// Fastify's default parser only handles JSON; workspace file writes need to
|
||||
// accept text and arbitrary binary payloads. This route module is registered
|
||||
// for both local aliases, so parser registration must tolerate repeats.
|
||||
try { app.addContentTypeParser("text/plain", { parseAs: "string" }, (_request, body, done) => { done(null, Buffer.from(body)); }); } catch { /* already registered */ }
|
||||
try { app.addContentTypeParser("application/octet-stream", { parseAs: "buffer" }, (_request, body, done) => { done(null, body); }); } catch { /* already registered */ }
|
||||
try { app.addContentTypeParser(/^([a-z]+\/[a-z0-9.+-]+)$/u, { parseAs: "buffer" }, (_request, body, done) => { done(null, body); }); } catch { /* already registered */ }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
import type { PiWebConfigService } from "../configRoutes.js";
|
||||
import type { ProjectService } from "../projects/projectService.js";
|
||||
import type { WorkspaceContext } from "./workspaceContext.js";
|
||||
import type { WorkspaceService } from "./workspaceService.js";
|
||||
import { cwdPathsEqual } from "../workingDirectory.js";
|
||||
import { loadEffectiveProjectPathAccess } from "./projectPiWebConfig.js";
|
||||
|
||||
export async function pathAccessForWorkspaceContext(context: WorkspaceContext, config: Pick<PiWebConfigService, "read"> | undefined): Promise<PiWebPathAccessConfig | undefined> {
|
||||
if (config === undefined) return undefined;
|
||||
const response = await config.read();
|
||||
return loadEffectiveProjectPathAccess(context.project.path, response.effectiveConfig);
|
||||
}
|
||||
|
||||
export async function pathAccessForCwd(cwd: string, projects: ProjectService, workspaces: WorkspaceService, config: Pick<PiWebConfigService, "read"> | undefined): Promise<PiWebPathAccessConfig | undefined> {
|
||||
if (config === undefined) return undefined;
|
||||
const response = await config.read();
|
||||
const projectPath = await projectPathForWorkspaceCwd(cwd, projects, workspaces);
|
||||
if (projectPath === undefined) return response.effectiveConfig.pathAccess;
|
||||
return loadEffectiveProjectPathAccess(projectPath, response.effectiveConfig);
|
||||
}
|
||||
|
||||
async function projectPathForWorkspaceCwd(cwd: string, projects: ProjectService, workspaces: WorkspaceService): Promise<string | undefined> {
|
||||
for (const project of await projects.list()) {
|
||||
if (cwdPathsEqual(project.path, cwd)) return project.path;
|
||||
if ((await workspaces.list(project)).some((workspace) => cwdPathsEqual(workspace.path, cwd))) return project.path;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -50,6 +50,23 @@ describe("readWorkspaceFile", () => {
|
||||
await expect(readWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
|
||||
});
|
||||
|
||||
it("reads allowed absolute files outside the workspace", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const external = await tempWorkspace();
|
||||
await writeFile(join(external, "README.md"), "external docs\n");
|
||||
|
||||
const file = await readWorkspaceFile(root, join(external, "README.md"), { allowedPaths: [external] });
|
||||
|
||||
expect(file).toMatchObject({
|
||||
path: join(external, "README.md"),
|
||||
language: "markdown",
|
||||
content: "external docs\n",
|
||||
truncated: false,
|
||||
binary: false,
|
||||
});
|
||||
await expect(readWorkspaceFile(root, join(external, "README.md"))).rejects.toThrow("Absolute paths are not allowed");
|
||||
});
|
||||
|
||||
it("detects binary files and omits binary content", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "image.bin"), Buffer.from([0x66, 0x6f, 0x00, 0x6f]));
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { lstat, mkdir, open, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import type { DeleteWorkspaceFileResponse, FileContentResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse } from "../../shared/apiTypes.js";
|
||||
import type { DeleteWorkspaceFileResponse, FileContentResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, PiWebPathAccessConfig, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse } from "../../shared/apiTypes.js";
|
||||
import { imageMimeTypeForPath } from "./imagePreviewService.js";
|
||||
import { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
|
||||
import { ensureInside, isNodeErrorWithCode, resolveInsideWorkspace, resolveParentInsideWorkspace } from "./pathSafety.js";
|
||||
|
||||
const MAX_BYTES = 512 * 1024;
|
||||
|
||||
export async function readWorkspaceFile(rootPath: string, path: string | undefined): Promise<FileContentResponse> {
|
||||
export async function readWorkspaceFile(rootPath: string, path: string | undefined, pathAccess?: PiWebPathAccessConfig): Promise<FileContentResponse> {
|
||||
if (path === undefined || path === "") throw new Error("path query parameter is required");
|
||||
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path);
|
||||
const { target, displayPath } = await resolveWorkspacePathAccessTarget(rootPath, path, pathAccess);
|
||||
const s = await stat(target);
|
||||
if (!s.isFile()) throw new Error("Path is not a file");
|
||||
const bytesToRead = Math.min(s.size, MAX_BYTES);
|
||||
const buffer = await readFilePrefix(target, bytesToRead);
|
||||
const media = mediaForPath(relativePath);
|
||||
const media = mediaForPath(displayPath);
|
||||
const binary = media.mediaType === "image" || isProbablyBinary(buffer);
|
||||
return {
|
||||
path: relativePath,
|
||||
...languageForPath(relativePath),
|
||||
path: displayPath,
|
||||
...languageForPath(displayPath),
|
||||
...media,
|
||||
encoding: "utf8",
|
||||
size: s.size,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { listFileSuggestions, type FileSuggestionDependencies } from "./fileSuggestions";
|
||||
import { listFileSuggestions, listPathSuggestions, type FileSuggestionDependencies } from "./fileSuggestions";
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
@@ -12,6 +12,26 @@ async function tempWorkspace(): Promise<string> {
|
||||
return root;
|
||||
}
|
||||
|
||||
function fzfRecords(input: string | Buffer | undefined): string[] {
|
||||
if (typeof input === "string") return input.split("\0").filter(Boolean);
|
||||
if (Buffer.isBuffer(input)) return input.toString("utf8").split("\0").filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
async function trySymlink(target: string, path: string): Promise<boolean> {
|
||||
try {
|
||||
await symlink(target, path, "dir");
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isNodeErrorWithCode(error, "EPERM") || isNodeErrorWithCode(error, "EACCES")) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
@@ -86,6 +106,67 @@ describe("file suggestions", () => {
|
||||
expect(suggestions[0]).toEqual({ path: "MD PRojects here.md", kind: "tracked" });
|
||||
});
|
||||
|
||||
it("uses fzf to filter and rank file suggestions after candidates are gathered", async () => {
|
||||
const fzfInputs: string[][] = [];
|
||||
const deps: FileSuggestionDependencies = {
|
||||
execFile: (file, args) => {
|
||||
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "src/server/app.ts\0scripts/start.ts\0docs/reference.md\0" });
|
||||
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
|
||||
},
|
||||
fzf: (file, args, options) => {
|
||||
expect(file).toBe("fzf");
|
||||
expect(args).toEqual(["--filter", "st", "--read0", "--print0"]);
|
||||
fzfInputs.push(fzfRecords(options.input));
|
||||
return Promise.resolve({ stdout: "scripts/start.ts\0src/server/app.ts\0" });
|
||||
},
|
||||
};
|
||||
|
||||
await expect(listFileSuggestions("/repo", "st", { scope: "tracked" }, deps)).resolves.toEqual([
|
||||
{ path: "scripts/start.ts", kind: "tracked" },
|
||||
{ path: "src/server/app.ts", kind: "tracked" },
|
||||
]);
|
||||
expect(fzfInputs).toEqual([[
|
||||
"src/",
|
||||
"src/server/",
|
||||
"src/server/app.ts",
|
||||
"scripts/",
|
||||
"scripts/start.ts",
|
||||
"docs/",
|
||||
"docs/reference.md",
|
||||
]]);
|
||||
});
|
||||
|
||||
it("falls back to TypeScript file ranking when fzf fails", async () => {
|
||||
let fzfCalls = 0;
|
||||
const deps: FileSuggestionDependencies = {
|
||||
execFile: (file, args) => {
|
||||
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "klingit-go/cli/cmd/dev/main.go\0MD PRojects here.md\0" });
|
||||
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
|
||||
},
|
||||
fzf: () => {
|
||||
fzfCalls += 1;
|
||||
return Promise.reject(Object.assign(new Error("spawn fzf ENOENT"), { code: "ENOENT" }));
|
||||
},
|
||||
};
|
||||
|
||||
const suggestions = await listFileSuggestions("/repo", "MD", { scope: "tracked" }, deps);
|
||||
|
||||
expect(fzfCalls).toBe(1);
|
||||
expect(suggestions[0]).toEqual({ path: "MD PRojects here.md", kind: "tracked" });
|
||||
});
|
||||
|
||||
it("treats an fzf no-match exit as an empty filtered result", async () => {
|
||||
const deps: FileSuggestionDependencies = {
|
||||
execFile: (file, args) => {
|
||||
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "src/app.ts\0" });
|
||||
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
|
||||
},
|
||||
fzf: () => Promise.reject(Object.assign(new Error("no match"), { exitCode: 1 })),
|
||||
};
|
||||
|
||||
await expect(listFileSuggestions("/repo", "app", { scope: "tracked" }, deps)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves git filenames without trimming whitespace", async () => {
|
||||
const deps: FileSuggestionDependencies = {
|
||||
execFile: (file, args) => {
|
||||
@@ -120,4 +201,132 @@ describe("file suggestions", () => {
|
||||
{ path: "src/app.ts", kind: "other" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses allowed roots for absolute-ish file suggestion queries", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const workspace = join(root, "workspace");
|
||||
const external = join(root, "external-docs");
|
||||
await mkdir(workspace);
|
||||
await mkdir(external);
|
||||
await writeFile(join(external, "sdk.md"), "external sdk\n");
|
||||
|
||||
await expect(listFileSuggestions(workspace, join(external, "s"), { pathAccess: { allowedPaths: [external] } })).resolves.toEqual([
|
||||
{ path: join(external, "sdk.md"), kind: "other" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips absolute-ish suggestions that would escape an allowed root through symlinks", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const workspace = join(root, "workspace");
|
||||
const external = join(root, "external-docs");
|
||||
const secret = join(root, "secret");
|
||||
await mkdir(workspace);
|
||||
await mkdir(external);
|
||||
await mkdir(secret);
|
||||
await writeFile(join(external, "sdk.md"), "external sdk\n");
|
||||
await writeFile(join(secret, "token.txt"), "secret\n");
|
||||
if (!await trySymlink(secret, join(external, "escape"))) return;
|
||||
|
||||
await expect(listPathSuggestions(workspace, `${external}/`, { allowedPaths: [external] })).resolves.toEqual([
|
||||
{ path: join(external, "sdk.md"), kind: "other" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps tilde-prefixed allowed-root suggestions matchable by fzf", async () => {
|
||||
const workspace = await tempWorkspace();
|
||||
const homeEntry = await mkdtemp(join(homedir(), ".pi-web-files-"));
|
||||
temporaryRoots.push(homeEntry);
|
||||
const expectedPath = `~/${basename(homeEntry)}/`;
|
||||
const deps: FileSuggestionDependencies = {
|
||||
fzf: (file, args, options) => {
|
||||
expect(file).toBe("fzf");
|
||||
expect(args).toEqual(["--filter", "~/", "--read0", "--print0"]);
|
||||
expect(fzfRecords(options.input)).toContain(expectedPath);
|
||||
return Promise.resolve({ stdout: `${expectedPath}\0` });
|
||||
},
|
||||
};
|
||||
|
||||
await expect(listFileSuggestions(workspace, "~/", { pathAccess: { allowedPaths: ["~/"] } }, deps)).resolves.toEqual([
|
||||
{ path: expectedPath, kind: "other" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps normal file suggestions workspace-local even when allowed roots are configured", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const workspace = join(root, "workspace");
|
||||
const external = join(root, "external-docs");
|
||||
await mkdir(workspace);
|
||||
await mkdir(external);
|
||||
await writeFile(join(external, "sdk.md"), "external sdk\n");
|
||||
|
||||
const deps: FileSuggestionDependencies = {
|
||||
execFile: (file) => Promise.reject(Object.assign(new Error(`spawn ${file} ENOENT`), { code: "ENOENT" })),
|
||||
};
|
||||
|
||||
await expect(listFileSuggestions(workspace, "sdk", { scope: "all", pathAccess: { allowedPaths: [external] } }, deps)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps relative path suggestions workspace-local and skips symlink escapes", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const workspace = join(root, "workspace");
|
||||
const outside = join(root, "outside");
|
||||
await mkdir(workspace);
|
||||
await mkdir(outside);
|
||||
await writeFile(join(workspace, "local.md"), "local\n");
|
||||
await writeFile(join(outside, "outside.txt"), "outside\n");
|
||||
|
||||
await expect(listPathSuggestions(workspace, "../out")).resolves.toEqual([]);
|
||||
if (!await trySymlink(outside, join(workspace, "link"))) return;
|
||||
await expect(listPathSuggestions(workspace, "link/")).resolves.toEqual([]);
|
||||
await expect(listPathSuggestions(workspace, "")).resolves.toEqual([{ path: "local.md", kind: "other" }]);
|
||||
});
|
||||
|
||||
it("uses fzf to filter path suggestions after directory candidates are gathered", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "scripts"));
|
||||
await mkdir(join(root, "src"));
|
||||
await writeFile(join(root, "notes.md"), "notes\n");
|
||||
|
||||
const deps: FileSuggestionDependencies = {
|
||||
fzf: (file, args, options) => {
|
||||
expect(file).toBe("fzf");
|
||||
expect(args).toEqual(["--filter", "sc", "--read0", "--print0"]);
|
||||
expect(fzfRecords(options.input)).toEqual(["scripts/", "src/", "notes.md"]);
|
||||
return Promise.resolve({ stdout: "../secret\0scripts/\0" });
|
||||
},
|
||||
};
|
||||
|
||||
await expect(listPathSuggestions(root, "sc", undefined, deps)).resolves.toEqual([
|
||||
{ path: "scripts/", kind: "other" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to path-prefix ordering when fzf fails", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "scripts"));
|
||||
await mkdir(join(root, "src"));
|
||||
await writeFile(join(root, "server.md"), "server\n");
|
||||
|
||||
const deps: FileSuggestionDependencies = {
|
||||
fzf: () => Promise.reject(Object.assign(new Error("fzf failed"), { exitCode: 2 })),
|
||||
};
|
||||
|
||||
await expect(listPathSuggestions(root, "s", undefined, deps)).resolves.toEqual([
|
||||
{ path: "scripts/", kind: "other" },
|
||||
{ path: "src/", kind: "other" },
|
||||
{ path: "server.md", kind: "other" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("suggests configured allowed roots without reading parent directories", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const workspace = join(root, "workspace");
|
||||
const external = join(root, "external-docs");
|
||||
await mkdir(workspace);
|
||||
await mkdir(external);
|
||||
|
||||
await expect(listPathSuggestions(workspace, external.slice(0, -4), { allowedPaths: [external] })).resolves.toEqual([
|
||||
{ path: `${external}/`, kind: "other" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join, relative, sep, win32 } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { sanitizedGitEnv } from "../git/gitEnv.js";
|
||||
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
import type { ClientFileSuggestion } from "../types.js";
|
||||
import { createPathAccessPolicy, isAbsoluteishPath, resolvePathAccessTarget, type PathAccessPolicy } from "./pathAccessPolicy.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const commandMaxBuffer = 1024 * 1024 * 8;
|
||||
const maxFilesystemFallbackPaths = 20_000;
|
||||
const maxFileSuggestions = 80;
|
||||
|
||||
interface ExecFileOptions {
|
||||
interface CommandRunnerOptions {
|
||||
cwd: string;
|
||||
maxBuffer: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
input?: string | Buffer;
|
||||
}
|
||||
|
||||
type CommandRunner = (file: string, args: string[], options: CommandRunnerOptions) => Promise<{ stdout: string }>;
|
||||
|
||||
class CommandExitError extends Error {
|
||||
readonly exitCode?: number;
|
||||
|
||||
constructor(file: string, code: number | null, stderr: string) {
|
||||
const codeText = code === null ? "unknown" : String(code);
|
||||
super(`${file} exited with code ${codeText}${stderr === "" ? "" : `: ${stderr}`}`);
|
||||
this.name = "CommandExitError";
|
||||
if (code !== null) this.exitCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
export type FileSuggestionScope = "tracked" | "all";
|
||||
@@ -21,56 +38,221 @@ export type FileSuggestionScope = "tracked" | "all";
|
||||
export interface FileSuggestionOptions {
|
||||
kind?: ClientFileSuggestion["kind"] | undefined;
|
||||
scope?: FileSuggestionScope | undefined;
|
||||
pathAccess?: PiWebPathAccessConfig | undefined;
|
||||
}
|
||||
|
||||
export interface FileSuggestionDependencies {
|
||||
execFile?: (file: string, args: string[], options: ExecFileOptions) => Promise<{ stdout: string }>;
|
||||
execFile?: CommandRunner;
|
||||
fzf?: CommandRunner;
|
||||
}
|
||||
|
||||
export function isAbsoluteishFileSuggestionQuery(query = ""): boolean {
|
||||
return isAbsoluteishPath(fileQueryText(query));
|
||||
}
|
||||
|
||||
export async function listFileSuggestions(cwd: string, query = "", options: FileSuggestionOptions = {}, deps: FileSuggestionDependencies = {}): Promise<ClientFileSuggestion[]> {
|
||||
const queryText = fileQueryText(query);
|
||||
if (isAbsoluteishFileSuggestionQuery(query)) {
|
||||
return (await listPathSuggestions(cwd, queryText, options.pathAccess, deps))
|
||||
.filter((file) => options.kind === undefined || file.kind === options.kind)
|
||||
.slice(0, maxFileSuggestions);
|
||||
}
|
||||
|
||||
const normalizedQuery = normalizeFileQuery(query);
|
||||
const exec = deps.execFile ?? execFileAsync;
|
||||
const files = await listFilesForScope(cwd, options.scope, exec);
|
||||
return rankFileSuggestions(
|
||||
const command = deps.execFile ?? runCommand;
|
||||
const files = await listFilesForScope(cwd, options.scope, command);
|
||||
return (await rankFileSuggestionsWithOptionalFzf(
|
||||
cwd,
|
||||
files.filter((file) => options.kind === undefined || file.kind === options.kind),
|
||||
normalizedQuery,
|
||||
).slice(0, maxFileSuggestions);
|
||||
fzfRunnerForDependencies(deps),
|
||||
)).slice(0, maxFileSuggestions);
|
||||
}
|
||||
|
||||
export async function listPathSuggestions(cwd: string, prefix = ""): Promise<ClientFileSuggestion[]> {
|
||||
const normalizedPrefix = prefix.replace(/^@/, "").replace(/\\/g, "/");
|
||||
export async function listPathSuggestions(cwd: string, prefix = "", pathAccess?: PiWebPathAccessConfig, deps: FileSuggestionDependencies = {}): Promise<ClientFileSuggestion[]> {
|
||||
const query = fileQueryText(prefix);
|
||||
const fzf = fzfRunnerForDependencies(deps);
|
||||
if (isAbsoluteishPath(query)) return listAllowedPathSuggestions(cwd, query, pathAccess, fzf);
|
||||
|
||||
const normalizedPrefix = query.replace(/\\/g, "/");
|
||||
const directoryPrefix = normalizedPrefix.endsWith("/") ? normalizedPrefix : dirname(normalizedPrefix) === "." ? "" : `${dirname(normalizedPrefix)}/`;
|
||||
const searchPrefix = normalizedPrefix.endsWith("/") ? "" : basename(normalizedPrefix);
|
||||
const entries = await readdir(join(cwd, directoryPrefix), { withFileTypes: true });
|
||||
const suggestions: ClientFileSuggestion[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) continue;
|
||||
let isDirectory = entry.isDirectory();
|
||||
if (!isDirectory && entry.isSymbolicLink()) {
|
||||
try {
|
||||
isDirectory = (await stat(join(cwd, directoryPrefix, entry.name))).isDirectory();
|
||||
} catch {
|
||||
isDirectory = false;
|
||||
}
|
||||
}
|
||||
suggestions.push({ path: `${directoryPrefix}${entry.name}${isDirectory ? "/" : ""}`, kind: "other" });
|
||||
}
|
||||
return suggestions
|
||||
.sort((a, b) => Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path))
|
||||
.slice(0, 80);
|
||||
const candidates = await listDirectoryEntrySuggestions(cwd, directoryPrefix);
|
||||
return (await rankPathSuggestionsWithOptionalFzf(
|
||||
cwd,
|
||||
candidates,
|
||||
searchPrefix,
|
||||
() => prefixPathSuggestions(candidates, searchPrefix),
|
||||
fzf,
|
||||
)).slice(0, maxFileSuggestions);
|
||||
}
|
||||
|
||||
async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||
async function listDirectoryEntrySuggestions(cwd: string, directoryPrefix: string): Promise<ClientFileSuggestion[]> {
|
||||
const policy = await createPathAccessPolicy(cwd, undefined);
|
||||
const resolved = await resolveWorkspaceSuggestionDirectory(policy, directoryPrefix);
|
||||
if (resolved === undefined) return [];
|
||||
|
||||
const entries = await readdir(resolved.target, { withFileTypes: true });
|
||||
const suggestions: ClientFileSuggestion[] = [];
|
||||
for (const entry of entries.sort(compareDirectoryEntries)) {
|
||||
const childPath = appendRequestPath(resolved.displayPath, entry.name);
|
||||
const isDirectory = await suggestionEntryIsDirectory(policy, childPath, entry);
|
||||
if (isDirectory === undefined) continue;
|
||||
suggestions.push({ path: `${childPath}${isDirectory ? "/" : ""}`, kind: "other" });
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
async function resolveWorkspaceSuggestionDirectory(policy: PathAccessPolicy, directoryPrefix: string) {
|
||||
try {
|
||||
const resolved = await resolvePathAccessTarget(policy, directoryPrefix);
|
||||
return resolved.kind === "workspace" ? resolved : undefined;
|
||||
} catch (error) {
|
||||
if (isPathSuggestionMiss(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function listAllowedPathSuggestions(cwd: string, query: string, pathAccess: PiWebPathAccessConfig | undefined, fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
|
||||
const policy = await createPathAccessPolicy(cwd, pathAccess);
|
||||
if (policy.allowedRoots.length === 0) throw new Error("Absolute paths are not allowed");
|
||||
const rootCandidates = allowedRootSuggestionCandidates(policy, query);
|
||||
const directoryCandidates = await listAllowedDirectoryEntryCandidates(policy, query);
|
||||
return (await rankPathSuggestionsWithOptionalFzf(
|
||||
cwd,
|
||||
mergeSuggestions(rootCandidates, directoryCandidates),
|
||||
query,
|
||||
() => mergeSuggestions(allowedRootPrefixSuggestions(policy, query), prefixPathSuggestions(directoryCandidates, pathSuggestionPrefix(query).searchPrefix)).sort(compareFileSuggestions),
|
||||
fzf,
|
||||
)).slice(0, maxFileSuggestions);
|
||||
}
|
||||
|
||||
function allowedRootPrefixSuggestions(policy: PathAccessPolicy, query: string): ClientFileSuggestion[] {
|
||||
return allowedRootSuggestionCandidates(policy, query).filter((suggestion) => pathStartsWith(suggestion.path, query));
|
||||
}
|
||||
|
||||
function allowedRootSuggestionCandidates(policy: PathAccessPolicy, query: string): ClientFileSuggestion[] {
|
||||
const suggestions: ClientFileSuggestion[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const root of policy.allowedRoots) {
|
||||
for (const displayPath of allowedRootDisplayPaths(root.path, query)) {
|
||||
const path = ensureTrailingPathSeparator(displayPath);
|
||||
if (hasTrailingPathSeparator(query) && stripTrailingPathSeparators(path) === stripTrailingPathSeparators(query)) continue;
|
||||
if (seen.has(path)) continue;
|
||||
seen.add(path);
|
||||
suggestions.push({ path, kind: "other" });
|
||||
}
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
function allowedRootDisplayPaths(rootPath: string, query: string): string[] {
|
||||
if (query !== "~" && !query.startsWith("~/") && !query.startsWith("~\\")) return [rootPath];
|
||||
|
||||
const home = homedir();
|
||||
const homeRelativePath = relative(home, rootPath);
|
||||
if (!isInsideRelativePath(homeRelativePath)) return [rootPath];
|
||||
const separator = query.startsWith("~\\") ? "\\" : "/";
|
||||
const tildePath = homeRelativePath === "" ? "~" : `~${separator}${homeRelativePath.split(/[\\/]+/u).join(separator)}`;
|
||||
return [tildePath, rootPath];
|
||||
}
|
||||
|
||||
async function listAllowedDirectoryEntryCandidates(policy: PathAccessPolicy, query: string): Promise<ClientFileSuggestion[]> {
|
||||
const { directoryPrefix } = pathSuggestionPrefix(query);
|
||||
const resolved = await resolveSuggestionDirectory(policy, directoryPrefix);
|
||||
if (resolved === undefined) return [];
|
||||
|
||||
const entries = await readdir(resolved.target, { withFileTypes: true });
|
||||
const suggestions: ClientFileSuggestion[] = [];
|
||||
for (const entry of entries.sort(compareDirectoryEntries)) {
|
||||
const childPath = appendRequestPath(directoryPrefix, entry.name);
|
||||
const isDirectory = await suggestionEntryIsDirectory(policy, childPath, entry);
|
||||
if (isDirectory === undefined) continue;
|
||||
suggestions.push({ path: `${childPath}${isDirectory ? "/" : ""}`, kind: "other" });
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
async function resolveSuggestionDirectory(policy: PathAccessPolicy, directoryPrefix: string) {
|
||||
try {
|
||||
const resolved = await resolvePathAccessTarget(policy, directoryPrefix);
|
||||
return resolved.kind === "allowed" ? resolved : undefined;
|
||||
} catch (error) {
|
||||
if (isPathSuggestionMiss(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function suggestionEntryIsDirectory(policy: PathAccessPolicy, childPath: string, entry: { isDirectory(): boolean; isSymbolicLink(): boolean }): Promise<boolean | undefined> {
|
||||
if (!entry.isSymbolicLink()) return entry.isDirectory();
|
||||
|
||||
try {
|
||||
const resolved = await resolvePathAccessTarget(policy, childPath);
|
||||
const result = await stat(resolved.target);
|
||||
if (result.isDirectory()) return true;
|
||||
if (result.isFile()) return false;
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
if (isPathSuggestionMiss(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function pathSuggestionPrefix(query: string): { directoryPrefix: string; searchPrefix: string } {
|
||||
if (query === "~" || hasTrailingPathSeparator(query)) return { directoryPrefix: query, searchPrefix: "" };
|
||||
const directory = dirname(query);
|
||||
return { directoryPrefix: directory === "." ? "" : directory, searchPrefix: basename(query) };
|
||||
}
|
||||
|
||||
function appendRequestPath(base: string, name: string): string {
|
||||
if (base === "") return name;
|
||||
if (isAbsolute(base) || win32.isAbsolute(base)) return join(base, name);
|
||||
if (hasTrailingPathSeparator(base)) return `${base}${name}`;
|
||||
return `${base}/${name}`;
|
||||
}
|
||||
|
||||
function pathStartsWith(path: string, query: string): boolean {
|
||||
return path.toLowerCase().startsWith(query.toLowerCase());
|
||||
}
|
||||
|
||||
function ensureTrailingPathSeparator(path: string): string {
|
||||
return hasTrailingPathSeparator(path) ? path : `${path}/`;
|
||||
}
|
||||
|
||||
function hasTrailingPathSeparator(path: string): boolean {
|
||||
return path.endsWith("/") || path.endsWith("\\");
|
||||
}
|
||||
|
||||
function stripTrailingPathSeparators(path: string): string {
|
||||
let end = path.length;
|
||||
while (end > 1 && (path[end - 1] === "/" || path[end - 1] === "\\")) end -= 1;
|
||||
return path.slice(0, end);
|
||||
}
|
||||
|
||||
function isInsideRelativePath(path: string): boolean {
|
||||
return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
|
||||
}
|
||||
|
||||
function isPathSuggestionMiss(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return error.message === "Path is outside allowed paths"
|
||||
|| error.message === "Path does not exist"
|
||||
|| error.message === "Path traversal is not allowed"
|
||||
|| error.message === "Path escapes workspace"
|
||||
|| error.message.startsWith("Path is not absolute:");
|
||||
}
|
||||
|
||||
async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
|
||||
if (scope === "all") return listAllFiles(cwd, exec);
|
||||
if (scope === "tracked") return listTrackedFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, true));
|
||||
return listGitFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, false));
|
||||
}
|
||||
|
||||
async function listTrackedFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||
async function listTrackedFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
|
||||
return withDirectories(nulRecords(await git(cwd, ["ls-files", "-z"], exec)), "tracked");
|
||||
}
|
||||
|
||||
async function listGitFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||
async function listGitFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
|
||||
const [tracked, untracked] = await Promise.all([
|
||||
git(cwd, ["ls-files", "-z"], exec),
|
||||
git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"], exec),
|
||||
@@ -81,7 +263,7 @@ async function listGitFiles(cwd: string, exec: NonNullable<FileSuggestionDepende
|
||||
];
|
||||
}
|
||||
|
||||
async function listAllFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||
async function listAllFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
|
||||
const [gitFiles, plainFiles] = await Promise.all([
|
||||
listGitFiles(cwd, exec).catch((): ClientFileSuggestion[] => []),
|
||||
listPlainFiles(cwd, exec, true),
|
||||
@@ -89,7 +271,7 @@ async function listAllFiles(cwd: string, exec: NonNullable<FileSuggestionDepende
|
||||
return mergeSuggestions(gitFiles, plainFiles);
|
||||
}
|
||||
|
||||
async function listPlainFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>, includeIgnored: boolean): Promise<ClientFileSuggestion[]> {
|
||||
async function listPlainFiles(cwd: string, exec: CommandRunner, includeIgnored: boolean): Promise<ClientFileSuggestion[]> {
|
||||
try {
|
||||
const args = includeIgnored ? ["--files", "--hidden", "--no-ignore", "--glob", "!.git", "--glob", "!.git/**"] : ["--files"];
|
||||
const { stdout } = await exec("rg", args, { cwd, maxBuffer: commandMaxBuffer });
|
||||
@@ -138,13 +320,75 @@ async function isSymlinkedFile(cwd: string, relativePath: string, symbolicLink:
|
||||
}
|
||||
}
|
||||
|
||||
async function git(cwd: string, args: string[], exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<string> {
|
||||
async function git(cwd: string, args: string[], exec: CommandRunner): Promise<string> {
|
||||
const { stdout } = await exec("git", args, { cwd, env: sanitizedGitEnv(), maxBuffer: commandMaxBuffer });
|
||||
return stdout;
|
||||
}
|
||||
|
||||
function normalizeFileQuery(query: string): string {
|
||||
return query.replace(/^!@/, "").replace(/^@\s?/, "").replace(/^"/, "").toLowerCase();
|
||||
return fileQueryText(query).toLowerCase();
|
||||
}
|
||||
|
||||
function fileQueryText(query: string): string {
|
||||
return query.replace(/^!@/, "").replace(/^@\s?/, "").replace(/^"/, "");
|
||||
}
|
||||
|
||||
function fzfRunnerForDependencies(deps: FileSuggestionDependencies): CommandRunner | undefined {
|
||||
return deps.fzf ?? (deps.execFile === undefined ? runCommand : undefined);
|
||||
}
|
||||
|
||||
async function rankFileSuggestionsWithOptionalFzf(cwd: string, files: ClientFileSuggestion[], normalizedQuery: string, fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
|
||||
return rankSuggestionsWithOptionalFzf(cwd, files, normalizedQuery, () => rankFileSuggestions(files, normalizedQuery), fzf);
|
||||
}
|
||||
|
||||
async function rankPathSuggestionsWithOptionalFzf(cwd: string, candidates: ClientFileSuggestion[], query: string, fallback: () => ClientFileSuggestion[], fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
|
||||
return rankSuggestionsWithOptionalFzf(cwd, candidates, query, fallback, fzf);
|
||||
}
|
||||
|
||||
async function rankSuggestionsWithOptionalFzf(cwd: string, candidates: ClientFileSuggestion[], query: string, fallback: () => ClientFileSuggestion[], fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
|
||||
if (fzf === undefined || query === "" || candidates.length === 0) return fallback();
|
||||
|
||||
try {
|
||||
return await fzfFilterSuggestions(cwd, candidates, query, fzf);
|
||||
} catch {
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
|
||||
async function fzfFilterSuggestions(cwd: string, candidates: ClientFileSuggestion[], query: string, fzf: CommandRunner): Promise<ClientFileSuggestion[]> {
|
||||
const byPath = new Map(candidates.map((suggestion) => [suggestion.path, suggestion]));
|
||||
const { stdout } = await runFzf(cwd, [...byPath.keys()], query, fzf);
|
||||
const suggestions: ClientFileSuggestion[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const path of nulRecords(stdout)) {
|
||||
const suggestion = byPath.get(path);
|
||||
if (suggestion === undefined || seen.has(suggestion.path)) continue;
|
||||
seen.add(suggestion.path);
|
||||
suggestions.push(suggestion);
|
||||
}
|
||||
if (suggestions.length === 0 && stdout !== "") throw new Error("fzf returned paths outside the gathered suggestions");
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
async function runFzf(cwd: string, candidates: string[], query: string, fzf: CommandRunner): Promise<{ stdout: string }> {
|
||||
try {
|
||||
return await fzf("fzf", ["--filter", query, "--read0", "--print0"], { cwd, maxBuffer: commandMaxBuffer, input: `${candidates.join("\0")}\0` });
|
||||
} catch (error) {
|
||||
if (errorExitCode(error) === 1) return { stdout: "" };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function prefixPathSuggestions(candidates: ClientFileSuggestion[], searchPrefix: string): ClientFileSuggestion[] {
|
||||
const normalizedSearchPrefix = searchPrefix.toLowerCase();
|
||||
return candidates
|
||||
.filter((suggestion) => pathSuggestionName(suggestion.path).toLowerCase().startsWith(normalizedSearchPrefix))
|
||||
.sort(compareFileSuggestions);
|
||||
}
|
||||
|
||||
function pathSuggestionName(path: string): string {
|
||||
const stripped = stripTrailingPathSeparators(path);
|
||||
return stripped.split(/[\\/]+/u).filter(Boolean).at(-1) ?? stripped;
|
||||
}
|
||||
|
||||
function rankFileSuggestions(files: ClientFileSuggestion[], normalizedQuery: string): ClientFileSuggestion[] {
|
||||
@@ -197,6 +441,10 @@ function compareFileSuggestions(a: ClientFileSuggestion, b: ClientFileSuggestion
|
||||
return Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path);
|
||||
}
|
||||
|
||||
function compareDirectoryEntries(a: { isDirectory(): boolean; name: string }, b: { isDirectory(): boolean; name: string }): number {
|
||||
return Number(!a.isDirectory()) - Number(!b.isDirectory()) || a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
function kindRank(kind: ClientFileSuggestion["kind"]): number {
|
||||
switch (kind) {
|
||||
case "tracked": return 0;
|
||||
@@ -209,6 +457,72 @@ function pathDepth(path: string): number {
|
||||
return path.split("/").filter(Boolean).length;
|
||||
}
|
||||
|
||||
async function runCommand(file: string, args: string[], options: CommandRunnerOptions): Promise<{ stdout: string }> {
|
||||
const { input, ...execOptions } = options;
|
||||
if (input === undefined) return execFileAsync(file, args, execOptions);
|
||||
return runCommandWithInput(file, args, { ...execOptions, input });
|
||||
}
|
||||
|
||||
async function runCommandWithInput(file: string, args: string[], options: CommandRunnerOptions & { input: string | Buffer }): Promise<{ stdout: string }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(file, args, {
|
||||
cwd: options.cwd,
|
||||
...(options.env === undefined ? {} : { env: options.env }),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let settled = false;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdoutBytes += chunk.length;
|
||||
if (stdoutBytes > options.maxBuffer) {
|
||||
child.kill();
|
||||
rejectOnce(new Error(`${file} stdout exceeded maxBuffer`));
|
||||
return;
|
||||
}
|
||||
stdout += chunk.toString("utf8");
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderrBytes += chunk.length;
|
||||
if (stderrBytes > options.maxBuffer) {
|
||||
child.kill();
|
||||
rejectOnce(new Error(`${file} stderr exceeded maxBuffer`));
|
||||
return;
|
||||
}
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
child.on("error", rejectOnce);
|
||||
child.on("close", (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (code === 0) {
|
||||
resolve({ stdout });
|
||||
return;
|
||||
}
|
||||
reject(new CommandExitError(file, code, stderr));
|
||||
});
|
||||
child.stdin.on("error", () => undefined);
|
||||
child.stdin.end(options.input);
|
||||
});
|
||||
}
|
||||
|
||||
function errorExitCode(error: unknown): number | undefined {
|
||||
if (error instanceof CommandExitError) return error.exitCode;
|
||||
if (!(error instanceof Error)) return undefined;
|
||||
if ("exitCode" in error && typeof error.exitCode === "number") return error.exitCode;
|
||||
if ("code" in error && typeof error.code === "number") return error.code;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function textLines(text: string): string[] {
|
||||
return text.split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line).filter((line) => line !== "");
|
||||
}
|
||||
|
||||
@@ -55,6 +55,22 @@ describe("listWorkspaceTree", () => {
|
||||
expect(tree.entries[0]).toMatchObject({ name: "main.ts", path: "src/client/main.ts", type: "file" });
|
||||
});
|
||||
|
||||
it("lists allowed absolute directories outside the workspace", async () => {
|
||||
const root = await tempWorkspace();
|
||||
const external = await tempWorkspace();
|
||||
await mkdir(join(external, "docs"));
|
||||
await writeFile(join(external, "sdk.ts"), "export {};\n");
|
||||
|
||||
const tree = await listWorkspaceTree(root, external, { allowedPaths: [external] });
|
||||
|
||||
expect(tree.path).toBe(external);
|
||||
expect(tree.entries.map((entry) => [entry.name, entry.path, entry.type])).toEqual([
|
||||
["docs", join(external, "docs"), "directory"],
|
||||
["sdk.ts", join(external, "sdk.ts"), "file"],
|
||||
]);
|
||||
await expect(listWorkspaceTree(root, external)).rejects.toThrow("Absolute paths are not allowed");
|
||||
});
|
||||
|
||||
it("rejects non-directory targets and unsafe paths", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await writeFile(join(root, "file.txt"), "content");
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { lstat, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { FileTreeEntry, FileTreeResponse } from "../../shared/apiTypes.js";
|
||||
import { resolveInsideWorkspace } from "./pathSafety.js";
|
||||
import { isAbsolute, join, win32 } from "node:path";
|
||||
import type { FileTreeEntry, FileTreeResponse, PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
import { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
|
||||
|
||||
const MAX_ENTRIES = 1000;
|
||||
|
||||
export async function listWorkspaceTree(rootPath: string, path: string | undefined): Promise<FileTreeResponse> {
|
||||
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path);
|
||||
export async function listWorkspaceTree(rootPath: string, path: string | undefined, pathAccess?: PiWebPathAccessConfig): Promise<FileTreeResponse> {
|
||||
const { target, displayPath } = await resolveWorkspacePathAccessTarget(rootPath, path, pathAccess);
|
||||
const stat = await lstat(target);
|
||||
if (!stat.isDirectory()) throw new Error("Path is not a directory");
|
||||
|
||||
@@ -18,11 +18,18 @@ export async function listWorkspaceTree(rootPath: string, path: string | undefin
|
||||
const selected = sorted.slice(0, MAX_ENTRIES);
|
||||
const entries = await Promise.all(selected.map(async (entry): Promise<FileTreeEntry> => {
|
||||
const absolute = join(target, entry.name);
|
||||
const childRelative = relativePath === "" ? entry.name : `${relativePath}/${entry.name}`;
|
||||
const childPath = appendRequestPath(displayPath, entry.name);
|
||||
const childStat = await lstat(absolute);
|
||||
const type: FileTreeEntry["type"] = entry.isDirectory() ? "directory" : entry.isSymbolicLink() ? "symlink" : "file";
|
||||
return { name: entry.name, path: childRelative, type, size: childStat.size, modifiedAt: childStat.mtime.toISOString() };
|
||||
return { name: entry.name, path: childPath, type, size: childStat.size, modifiedAt: childStat.mtime.toISOString() };
|
||||
}));
|
||||
|
||||
return { path: relativePath, entries, scannedAt: new Date().toISOString(), truncated: sorted.length > selected.length };
|
||||
return { path: displayPath, entries, scannedAt: new Date().toISOString(), truncated: sorted.length > selected.length };
|
||||
}
|
||||
|
||||
function appendRequestPath(base: string, name: string): string {
|
||||
if (base === "") return name;
|
||||
if (isAbsolute(base) || win32.isAbsolute(base)) return join(base, name);
|
||||
if (base.endsWith("/") || base.endsWith("\\")) return `${base}${name}`;
|
||||
return `${base}/${name}`;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createReadStream, type ReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { extname } from "node:path";
|
||||
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES, MAX_IMAGE_PREVIEW_LABEL } from "../../shared/workspaceFiles.js";
|
||||
import { resolveInsideWorkspace } from "./pathSafety.js";
|
||||
import { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
|
||||
|
||||
const IMAGE_MIME_TYPES: Record<string, string | undefined> = {
|
||||
".avif": "image/avif",
|
||||
@@ -28,16 +29,16 @@ export function imageMimeTypeForPath(path: string): string | undefined {
|
||||
return IMAGE_MIME_TYPES[extname(path).toLowerCase()];
|
||||
}
|
||||
|
||||
export async function readWorkspaceImagePreview(rootPath: string, path: string | undefined): Promise<WorkspaceImagePreview> {
|
||||
export async function readWorkspaceImagePreview(rootPath: string, path: string | undefined, pathAccess?: PiWebPathAccessConfig): Promise<WorkspaceImagePreview> {
|
||||
if (path === undefined || path === "") throw new Error("path query parameter is required");
|
||||
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path);
|
||||
const { target, displayPath } = await resolveWorkspacePathAccessTarget(rootPath, path, pathAccess);
|
||||
const s = await stat(target);
|
||||
if (!s.isFile()) throw new Error("Path is not a file");
|
||||
const mimeType = imageMimeTypeForPath(relativePath);
|
||||
const mimeType = imageMimeTypeForPath(displayPath);
|
||||
if (mimeType === undefined) throw new Error("Image preview is not supported for this file type");
|
||||
if (s.size > MAX_IMAGE_PREVIEW_BYTES) throw new Error(`Image is too large to preview (limit ${MAX_IMAGE_PREVIEW_LABEL})`);
|
||||
return {
|
||||
path: relativePath,
|
||||
path: displayPath,
|
||||
mimeType,
|
||||
size: s.size,
|
||||
modifiedAt: s.mtime.toISOString(),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPathAccessPolicy, isAbsoluteishPath, resolvePathAccessTarget, resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
async function tempRoot(prefix = "pi-web-path-access-"): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), prefix));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("path access policy", () => {
|
||||
it("keeps relative requests workspace-local and identifies absolute-ish paths", async () => {
|
||||
const workspace = await tempRoot();
|
||||
await mkdir(join(workspace, "src"));
|
||||
await writeFile(join(workspace, "src", "main.ts"), "export {};\n");
|
||||
const policy = await createPathAccessPolicy(workspace, undefined);
|
||||
|
||||
await expect(resolvePathAccessTarget(policy, "./src//main.ts")).resolves.toMatchObject({
|
||||
kind: "workspace",
|
||||
root: await realpath(workspace),
|
||||
target: await realpath(join(workspace, "src", "main.ts")),
|
||||
displayPath: "src/main.ts",
|
||||
});
|
||||
|
||||
expect(isAbsoluteishPath("src/main.ts")).toBe(false);
|
||||
expect(isAbsoluteishPath("/tmp/file.txt")).toBe(true);
|
||||
expect(isAbsoluteishPath("~/SDKs/readme.md")).toBe(true);
|
||||
expect(isAbsoluteishPath("C:\\Users\\dev\\file.txt")).toBe(true);
|
||||
expect(isAbsoluteishPath("\\\\server\\share\\file.txt")).toBe(true);
|
||||
await expect(resolvePathAccessTarget(policy, join(workspace, "src", "main.ts"))).rejects.toThrow("Absolute paths are not allowed");
|
||||
});
|
||||
|
||||
it("expands and canonicalizes allowed roots before resolving absolute targets", async () => {
|
||||
const root = await tempRoot();
|
||||
const workspace = join(root, "workspace");
|
||||
const home = join(root, "home");
|
||||
const sdk = join(home, "SDKs");
|
||||
await mkdir(workspace);
|
||||
await mkdir(sdk, { recursive: true });
|
||||
await writeFile(join(sdk, "readme.md"), "sdk docs\n");
|
||||
|
||||
const policy = await createPathAccessPolicy(workspace, { allowedPaths: ["~/SDKs"] }, { homeDir: home });
|
||||
|
||||
expect(policy.allowedRoots).toEqual([{ source: "~/SDKs", path: sdk, realPath: await realpath(sdk) }]);
|
||||
await expect(resolvePathAccessTarget(policy, "~/SDKs/readme.md", { homeDir: home })).resolves.toMatchObject({
|
||||
kind: "allowed",
|
||||
root: await realpath(sdk),
|
||||
target: await realpath(join(sdk, "readme.md")),
|
||||
displayPath: join(sdk, "readme.md"),
|
||||
});
|
||||
});
|
||||
|
||||
it("validates configured roots as existing directories", async () => {
|
||||
const root = await tempRoot();
|
||||
const workspace = join(root, "workspace");
|
||||
const fileRoot = join(root, "not-a-directory.txt");
|
||||
await mkdir(workspace);
|
||||
await writeFile(fileRoot, "not a directory");
|
||||
|
||||
await expect(createPathAccessPolicy(workspace, { allowedPaths: [join(root, "missing")] })).rejects.toThrow("does not exist");
|
||||
await expect(createPathAccessPolicy(workspace, { allowedPaths: [fileRoot] })).rejects.toThrow("must be a directory");
|
||||
await expect(createPathAccessPolicy(workspace, { allowedPaths: ["relative/root"] })).rejects.toThrow("Allowed path must be absolute or start with ~");
|
||||
});
|
||||
|
||||
it("does not validate stale allowed roots for workspace-relative requests", async () => {
|
||||
const root = await tempRoot();
|
||||
const workspace = join(root, "workspace");
|
||||
await mkdir(workspace);
|
||||
await writeFile(join(workspace, "local.txt"), "local\n");
|
||||
|
||||
await expect(resolveWorkspacePathAccessTarget(workspace, "local.txt", { allowedPaths: [join(root, "missing")] })).resolves.toMatchObject({
|
||||
kind: "workspace",
|
||||
target: await realpath(join(workspace, "local.txt")),
|
||||
displayPath: "local.txt",
|
||||
});
|
||||
await expect(resolveWorkspacePathAccessTarget(workspace, join(workspace, "local.txt"), { allowedPaths: [join(root, "missing")] })).rejects.toThrow("does not exist");
|
||||
});
|
||||
|
||||
it("denies absolute targets outside allowed roots and through symlink escapes", async () => {
|
||||
const root = await tempRoot();
|
||||
const workspace = join(root, "workspace");
|
||||
const allowed = join(root, "allowed");
|
||||
const secret = join(root, "secret");
|
||||
await mkdir(workspace);
|
||||
await mkdir(allowed);
|
||||
await mkdir(secret);
|
||||
await writeFile(join(secret, "token.txt"), "secret\n");
|
||||
const policy = await createPathAccessPolicy(workspace, { allowedPaths: [allowed] });
|
||||
|
||||
await expect(resolvePathAccessTarget(policy, join(secret, "token.txt"))).rejects.toThrow("Path is outside allowed paths");
|
||||
|
||||
if (await trySymlink(secret, join(allowed, "escape"))) {
|
||||
await expect(resolvePathAccessTarget(policy, join(allowed, "escape", "token.txt"))).rejects.toThrow("Path is outside allowed paths");
|
||||
}
|
||||
});
|
||||
|
||||
it("allows roots configured through symlinks by checking canonical paths", async () => {
|
||||
const root = await tempRoot();
|
||||
const workspace = join(root, "workspace");
|
||||
const realAllowed = join(root, "real-allowed");
|
||||
const linkedAllowed = join(root, "linked-allowed");
|
||||
await mkdir(workspace);
|
||||
await mkdir(realAllowed);
|
||||
await writeFile(join(realAllowed, "data.txt"), "allowed\n");
|
||||
if (!await trySymlink(realAllowed, linkedAllowed)) return;
|
||||
|
||||
const policy = await createPathAccessPolicy(workspace, { allowedPaths: [linkedAllowed] });
|
||||
|
||||
expect(policy.allowedRoots).toEqual([{ source: linkedAllowed, path: linkedAllowed, realPath: await realpath(realAllowed) }]);
|
||||
await expect(resolvePathAccessTarget(policy, join(linkedAllowed, "data.txt"))).resolves.toMatchObject({
|
||||
kind: "allowed",
|
||||
root: await realpath(realAllowed),
|
||||
target: await realpath(join(realAllowed, "data.txt")),
|
||||
displayPath: join(linkedAllowed, "data.txt"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function trySymlink(target: string, path: string): Promise<boolean> {
|
||||
try {
|
||||
await symlink(target, path, "dir");
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isNodeErrorWithCode(error, "EPERM") || isNodeErrorWithCode(error, "EACCES")) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { realpath, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, relative, resolve, sep, win32 } from "node:path";
|
||||
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
import { normalizeRelativePath } from "./pathSafety.js";
|
||||
|
||||
export interface AllowedPathRoot {
|
||||
/** Raw config value for diagnostics. */
|
||||
source: string;
|
||||
/** Host-absolute path after expanding ~ and normalizing syntax. */
|
||||
path: string;
|
||||
/** Canonical directory root used for containment checks. */
|
||||
realPath: string;
|
||||
}
|
||||
|
||||
export interface PathAccessPolicy {
|
||||
workspaceRoot: string;
|
||||
allowedRoots: AllowedPathRoot[];
|
||||
}
|
||||
|
||||
export type PathAccessTargetKind = "workspace" | "allowed";
|
||||
|
||||
export interface ResolvedPathAccessTarget {
|
||||
kind: PathAccessTargetKind;
|
||||
/** Canonical root that granted access: workspace root or allowed root. */
|
||||
root: string;
|
||||
/** Canonical existing target path. */
|
||||
target: string;
|
||||
/** Requestable path returned to clients and used to build child paths. */
|
||||
displayPath: string;
|
||||
}
|
||||
|
||||
export interface PathAccessPolicyOptions {
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
export async function createPathAccessPolicy(workspaceRootPath: string, pathAccess: PiWebPathAccessConfig | undefined, options: PathAccessPolicyOptions = {}): Promise<PathAccessPolicy> {
|
||||
return {
|
||||
workspaceRoot: await canonicalDirectory(workspaceRootPath, "Workspace path"),
|
||||
allowedRoots: await resolveAllowedRoots(pathAccess?.allowedPaths ?? [], options),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveWorkspacePathAccessTarget(rootPath: string, requestedPath: string | undefined, pathAccess?: PiWebPathAccessConfig, options: PathAccessPolicyOptions = {}): Promise<ResolvedPathAccessTarget> {
|
||||
const request = requestedPath ?? "";
|
||||
const workspaceRoot = await canonicalDirectory(rootPath, "Workspace path");
|
||||
const allowedRoots = isAbsoluteishPath(request) ? await resolveAllowedRoots(pathAccess?.allowedPaths ?? [], options) : [];
|
||||
return resolvePathAccessTarget({ workspaceRoot, allowedRoots }, requestedPath, options);
|
||||
}
|
||||
|
||||
export async function resolvePathAccessTarget(policy: PathAccessPolicy, requestedPath: string | undefined, options: PathAccessPolicyOptions = {}): Promise<ResolvedPathAccessTarget> {
|
||||
const request = requestedPath ?? "";
|
||||
if (isAbsoluteishPath(request)) return resolveAllowedTarget(policy, request, options);
|
||||
|
||||
const displayPath = normalizeRelativePath(request);
|
||||
const target = await canonicalExistingPath(resolve(policy.workspaceRoot, displayPath));
|
||||
ensureInside(policy.workspaceRoot, target, "Path escapes workspace");
|
||||
return { kind: "workspace", root: policy.workspaceRoot, target, displayPath };
|
||||
}
|
||||
|
||||
export function isAbsoluteishPath(path: string): boolean {
|
||||
return path === "~" || path.startsWith("~/") || path.startsWith("~\\") || isAbsolute(path) || win32.isAbsolute(path);
|
||||
}
|
||||
|
||||
async function resolveAllowedRoots(allowedPaths: readonly string[], options: PathAccessPolicyOptions): Promise<AllowedPathRoot[]> {
|
||||
const roots: AllowedPathRoot[] = [];
|
||||
for (const source of allowedPaths) {
|
||||
const expanded = expandAbsoluteishPath(source, options, `Allowed path must be absolute or start with ~: ${source}`);
|
||||
const realPath = await canonicalDirectory(expanded, `Allowed path ${source}`);
|
||||
if (roots.some((root) => root.realPath === realPath)) continue;
|
||||
roots.push({ source, path: expanded, realPath });
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
async function resolveAllowedTarget(policy: PathAccessPolicy, request: string, options: PathAccessPolicyOptions): Promise<ResolvedPathAccessTarget> {
|
||||
if (policy.allowedRoots.length === 0) throw new Error("Absolute paths are not allowed");
|
||||
|
||||
const displayPath = expandAbsoluteishPath(request, options, `Path is not absolute: ${request}`);
|
||||
const target = await canonicalExistingPath(displayPath);
|
||||
const root = policy.allowedRoots.find((allowedRoot) => isInsideOrSame(allowedRoot.realPath, target));
|
||||
if (root === undefined) throw new Error("Path is outside allowed paths");
|
||||
return { kind: "allowed", root: root.realPath, target, displayPath };
|
||||
}
|
||||
|
||||
function expandAbsoluteishPath(path: string, options: PathAccessPolicyOptions, relativeMessage: string): string {
|
||||
const home = options.homeDir ?? homedir();
|
||||
if (path === "~") return home;
|
||||
if (path.startsWith("~/") || path.startsWith("~\\")) return resolve(home, path.slice(2));
|
||||
if (isAbsolute(path)) return resolve(path);
|
||||
if (win32.isAbsolute(path)) throw new Error(`Absolute path is not valid on this host: ${path}`);
|
||||
throw new Error(relativeMessage);
|
||||
}
|
||||
|
||||
async function canonicalDirectory(path: string, label: string): Promise<string> {
|
||||
const canonical = await canonicalExistingPath(path, `${label} does not exist`);
|
||||
const result = await stat(canonical);
|
||||
if (!result.isDirectory()) throw new Error(`${label} must be a directory`);
|
||||
return canonical;
|
||||
}
|
||||
|
||||
async function canonicalExistingPath(path: string, missingMessage = "Path does not exist"): Promise<string> {
|
||||
try {
|
||||
return await realpath(path);
|
||||
} catch (error) {
|
||||
if (isNodeErrorWithCode(error, "ENOENT")) throw new Error(missingMessage, { cause: error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureInside(root: string, target: string, message: string): void {
|
||||
if (!isInsideOrSame(root, target)) throw new Error(message);
|
||||
}
|
||||
|
||||
function isInsideOrSame(root: string, target: string): boolean {
|
||||
const rel = relative(root, target);
|
||||
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { loadEffectiveProjectPathAccess, loadProjectPiWebConfig, mergePathAccessConfigs, PROJECT_PI_WEB_CONFIG_PATH } from "./projectPiWebConfig.js";
|
||||
|
||||
let tempDir: string;
|
||||
let projectPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "pi-web-project-config-test-"));
|
||||
projectPath = join(tempDir, "project");
|
||||
await mkdir(projectPath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("project PI WEB config", () => {
|
||||
it("returns an empty config when the project-local config is absent", async () => {
|
||||
await expect(loadProjectPiWebConfig(projectPath)).resolves.toEqual({
|
||||
path: join(projectPath, PROJECT_PI_WEB_CONFIG_PATH),
|
||||
exists: false,
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("loads project-local path access config", async () => {
|
||||
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } });
|
||||
|
||||
await expect(loadProjectPiWebConfig(projectPath)).resolves.toEqual({
|
||||
path: join(projectPath, PROJECT_PI_WEB_CONFIG_PATH),
|
||||
exists: true,
|
||||
config: { version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsupported project config versions", async () => {
|
||||
await writeProjectConfig({ version: 2 });
|
||||
|
||||
await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB project config version must be 1");
|
||||
});
|
||||
|
||||
it("reuses PI WEB path access schema validation", async () => {
|
||||
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: [""] } });
|
||||
|
||||
await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings");
|
||||
});
|
||||
|
||||
it("merges global and project path access in order", async () => {
|
||||
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/project-sdk", "/shared"] } });
|
||||
|
||||
await expect(loadEffectiveProjectPathAccess(projectPath, { pathAccess: { allowedPaths: ["/global-sdk", "/shared"] } })).resolves.toEqual({
|
||||
allowedPaths: ["/global-sdk", "/shared", "/project-sdk"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergePathAccessConfigs", () => {
|
||||
it("returns undefined when no roots are configured", () => {
|
||||
expect(mergePathAccessConfigs(undefined, {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("deduplicates configured roots", () => {
|
||||
expect(mergePathAccessConfigs({ allowedPaths: ["/a", "/b"] }, { allowedPaths: ["/b", "/c"] })).toEqual({ allowedPaths: ["/a", "/b", "/c"] });
|
||||
});
|
||||
});
|
||||
|
||||
async function writeProjectConfig(value: unknown): Promise<void> {
|
||||
const path = join(projectPath, PROJECT_PI_WEB_CONFIG_PATH);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { parsePathAccessConfig, type PiWebConfig } from "../../config.js";
|
||||
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
|
||||
export const PROJECT_PI_WEB_CONFIG_PATH = ".pi-web/config.json";
|
||||
|
||||
export interface ProjectPiWebConfig {
|
||||
version?: 1;
|
||||
pathAccess?: PiWebPathAccessConfig;
|
||||
}
|
||||
|
||||
export interface LoadedProjectPiWebConfig {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
config: ProjectPiWebConfig;
|
||||
}
|
||||
|
||||
export async function loadProjectPiWebConfig(projectPath: string): Promise<LoadedProjectPiWebConfig> {
|
||||
const path = join(projectPath, PROJECT_PI_WEB_CONFIG_PATH);
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
||||
if (!isRecord(parsed)) throw new Error(`PI WEB project config must be a JSON object: ${path}`);
|
||||
return { path, exists: true, config: parseProjectPiWebConfig(parsed, path) };
|
||||
} catch (error) {
|
||||
if (isNodeErrorWithCode(error, "ENOENT")) return { path, exists: false, config: {} };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadEffectiveProjectPathAccess(projectPath: string, globalConfig: PiWebConfig): Promise<PiWebPathAccessConfig | undefined> {
|
||||
const projectConfig = await loadProjectPiWebConfig(projectPath);
|
||||
return mergePathAccessConfigs(globalConfig.pathAccess, projectConfig.config.pathAccess);
|
||||
}
|
||||
|
||||
export function mergePathAccessConfigs(...configs: (PiWebPathAccessConfig | undefined)[]): PiWebPathAccessConfig | undefined {
|
||||
const allowedPaths = dedupe(configs.flatMap((config) => config?.allowedPaths ?? []));
|
||||
return allowedPaths.length === 0 ? undefined : { allowedPaths };
|
||||
}
|
||||
|
||||
function parseProjectPiWebConfig(value: Record<string, unknown>, path: string): ProjectPiWebConfig {
|
||||
const version = value["version"];
|
||||
return {
|
||||
...(version !== undefined ? { version: parseProjectConfigVersion(version, path) } : {}),
|
||||
...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseProjectConfigVersion(value: unknown, path: string): 1 {
|
||||
if (value !== 1) throw new Error(`PI WEB project config version must be 1: ${path}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
function dedupe(values: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
if (seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
result.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
+20
-1
@@ -5,6 +5,7 @@ export const PI_WEB_CAPABILITIES = {
|
||||
sessionsDeleteArchived: "sessions.deleteArchived",
|
||||
sessionsReload: "sessions.reload",
|
||||
promptAttachments: "prompt.attachments",
|
||||
workspaceFileSuggestions: "workspace.fileSuggestions",
|
||||
} as const;
|
||||
|
||||
export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES];
|
||||
@@ -51,14 +52,29 @@ export interface PiWebPluginConfig {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PiWebPathAccessConfig {
|
||||
allowedPaths?: string[];
|
||||
}
|
||||
|
||||
export interface PiWebConfigValues {
|
||||
host?: string;
|
||||
port?: number;
|
||||
allowedHosts?: string[] | true;
|
||||
shortcuts?: PiWebShortcutConfig;
|
||||
plugins?: PiWebPluginConfigMap;
|
||||
/** External filesystem roots PI WEB may expose outside a workspace. */
|
||||
pathAccess?: PiWebPathAccessConfig;
|
||||
/** Maximum accepted HTTP request body size in bytes (uploads/attachments). */
|
||||
maxUploadBytes?: number;
|
||||
/** When true, LLMs can start new sessions via the spawn_session tool. */
|
||||
spawnSessions?: boolean;
|
||||
/**
|
||||
* Beta: when true, LLMs can start tracked child sessions via the
|
||||
* spawn_subsession / list_subsessions / check_subsession / read_subsession
|
||||
* tools. Off by default
|
||||
* while the capability stabilizes. Requires spawnSessions to be enabled.
|
||||
*/
|
||||
subsessions?: boolean;
|
||||
}
|
||||
|
||||
export type PiWebPluginScope = "bundled" | "local" | "user" | "project";
|
||||
@@ -80,6 +96,8 @@ export interface PiWebConfigEnvOverrides {
|
||||
host: boolean;
|
||||
port: boolean;
|
||||
allowedHosts: boolean;
|
||||
spawnSessions: boolean;
|
||||
subsessions: boolean;
|
||||
}
|
||||
|
||||
export interface PiWebConfigResponse {
|
||||
@@ -527,7 +545,8 @@ export type SessionUiEvent =
|
||||
| { type: "command.output"; level: "info" | "success" | "error"; message: string }
|
||||
| { type: "session.error"; message: string }
|
||||
| { type: "session.name"; sessionId: string; name?: string }
|
||||
| { type: "session.created"; session: SessionInfo }
|
||||
| { type: "pi.event"; eventType: string };
|
||||
|
||||
export type GlobalSessionEvent = Extract<SessionUiEvent, { type: "status.update" | "activity.update" | "session.name" }>;
|
||||
export type GlobalSessionEvent = Extract<SessionUiEvent, { type: "status.update" | "activity.update" | "session.name" | "session.created" }>;
|
||||
export type RealtimeEvent = GlobalSessionEvent | TerminalUiEvent | WorkspaceActivityUiEvent;
|
||||
|
||||
@@ -6,13 +6,14 @@ export type { PiWebCapability };
|
||||
export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES);
|
||||
const knownPiWebCapabilities: ReadonlySet<string> = new Set(KNOWN_PI_WEB_CAPABILITIES);
|
||||
|
||||
export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
|
||||
export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.workspaceFileSuggestions] as const satisfies readonly PiWebCapability[];
|
||||
export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
|
||||
|
||||
const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
|
||||
[PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
|
||||
} as const satisfies Record<PiWebCapability, readonly PiWebServiceComponent[]>;
|
||||
|
||||
export function isPiWebCapability(value: unknown): value is PiWebCapability {
|
||||
|
||||
@@ -19,6 +19,7 @@ export const FEDERATED_HTTP_ROUTES = [
|
||||
{ method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId/file" },
|
||||
{ method: "POST", path: "/projects/:projectId/workspaces/:workspaceId/file/move" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file/preview" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/files" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/status" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/diff" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/terminals" },
|
||||
@@ -54,6 +55,7 @@ export const FEDERATED_HTTP_ROUTES = [
|
||||
{ method: "POST", path: "/sessions/:sessionId/archive-tree" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/restore" },
|
||||
{ method: "DELETE", path: "/sessions/:sessionId" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/reload" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/detach-parent" },
|
||||
{ method: "GET", path: "/auth/providers" },
|
||||
{ method: "POST", path: "/auth/api-key" },
|
||||
|
||||
Reference in New Issue
Block a user