@piagent/platform 1.16.3 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/README.vi.md +1 -1
- package/catalog/claude-model-additions.json +49 -2
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +6 -6
- package/docs/languages.json +2 -2
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/team-onboarding.md +2 -2
- package/docs-site/architecture.html +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/en/index.html +4 -4
- package/docs-site/content/en/whats-new.html +12 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +12 -0
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +5 -5
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/whats-new.html +13 -1
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +5 -5
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/whats-new.html +13 -1
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +3 -3
- package/evals/runtime-conformance-v1/runner.mjs +13 -17
- package/package.json +1 -1
- package/packages/piagent-core/managed/native-catalog.mjs +14 -1
- package/packages/piagent-core/managed/request-stream.mjs +28 -0
- package/packages/piagent-core/managed/session.mjs +2 -1
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-BDdLJYnZ.js → MarkdownMessage-DlbuyiQ3.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{index-DJGByyfL.js → index-BGxsZHB5.js} +14 -14
- package/packages/piagent-webui/dist/client/index.html +1 -1
- package/packages/piagent-webui/gateway/gateway-service.ts +26 -0
- package/packages/piagent-webui/gateway/git-branches.ts +94 -0
- package/packages/piagent-webui/gateway/git-head.ts +52 -0
- package/packages/piagent-webui/gateway/session-catalog.ts +8 -2
- package/packages/piagent-webui/gateway/session-operation-lifecycle.ts +10 -3
- package/packages/piagent-webui/server/loopback-server.ts +9 -2
- package/packages/piagent-webui/server/member-routes.ts +38 -0
- package/schemas/piagent-webui/session-catalog-v1.schema.json +3 -0
- package/scripts/configure-model-scope.sh +1 -0
- package/templates/global/settings.json +2 -1
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<meta name="csp-nonce" content="__PIAGENT_CSP_NONCE__" />
|
|
8
8
|
<meta name="piagent-webui-mode" content="__PIAGENT_WEBUI_MODE__" />
|
|
9
9
|
<title>Piagent WebUI</title>
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-BGxsZHB5.js"></script>
|
|
11
11
|
<link rel="stylesheet" crossorigin href="/assets/index-Z91z6SlN.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "./profile-state.ts";
|
|
21
21
|
import { buildSessionCatalog, projectRefForCwd } from "./session-catalog.ts";
|
|
22
22
|
import { suggestPaths } from "./path-suggestions.ts";
|
|
23
|
+
import { GitBranchError, listBranches, switchBranch, type SwitchRequest } from "./git-branches.ts";
|
|
23
24
|
import { listAgentCommands } from "../../piagent-core/runtime/resources/agent-resources.mjs";
|
|
24
25
|
import { sessionRefForPath } from "../ownership/session-refs.ts";
|
|
25
26
|
import { SessionMetadataStore } from "./session-metadata-store.ts";
|
|
@@ -229,6 +230,8 @@ export async function startPiagentGateway(options: {
|
|
|
229
230
|
?? (await runtimes!.listSessions()).find((info) => typeof info.cwd === "string" && projectRefForCwd(key, info.cwd) === projectRef)?.cwd
|
|
230
231
|
?? (relay ? (await relay.folders()).find((folder) => projectRefForCwd(key, folder) === projectRef) : undefined);
|
|
231
232
|
const relayPost = (path: string, body: unknown) => relay!.json("POST", path, body);
|
|
233
|
+
const runningInProject = async (projectRef: string) => (await hubCatalog()).sessions.filter((row) => row.projectRef === projectRef
|
|
234
|
+
&& !row.archived && ["running", "waiting-approval", "paused"].includes(row.liveState)).length;
|
|
232
235
|
const commands = new SessionCommandController({ catalog: hubCatalog, runtimes, metadata,
|
|
233
236
|
store: new SessionCommandStore(state.root, key), events,
|
|
234
237
|
// Resolved at call time: the attachment registry is built after this
|
|
@@ -303,6 +306,29 @@ export async function startPiagentGateway(options: {
|
|
|
303
306
|
const root = await projectFolder(projectRef);
|
|
304
307
|
return root ? { commands: listAgentCommands({ cwd: root }) } : null;
|
|
305
308
|
},
|
|
309
|
+
// The project's Git branches and a switch between them. A switch waits
|
|
310
|
+
// until no conversation in the folder runs (company ones included): the
|
|
311
|
+
// agent would find its files changed under it.
|
|
312
|
+
listBranches: async (projectRef) => {
|
|
313
|
+
const root = await projectFolder(projectRef);
|
|
314
|
+
if (!root) return null;
|
|
315
|
+
const [list, running] = await Promise.all([listBranches(root), runningInProject(projectRef)]);
|
|
316
|
+
return list ? { ...list, running } : { repository: false, running };
|
|
317
|
+
},
|
|
318
|
+
switchBranch: async (projectRef, request) => {
|
|
319
|
+
const root = await projectFolder(projectRef);
|
|
320
|
+
if (!root) return null;
|
|
321
|
+
const value = request as Partial<SwitchRequest> | null;
|
|
322
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.branch !== "string"
|
|
323
|
+
|| Object.keys(value).some((name) => !["branch", "create", "remote"].includes(name))
|
|
324
|
+
|| (value.create !== undefined && typeof value.create !== "boolean")
|
|
325
|
+
|| (value.remote !== undefined && value.remote !== null && typeof value.remote !== "string")) throw new GitBranchError("branch-request-invalid");
|
|
326
|
+
if (!(await listBranches(root))) throw new GitBranchError("not-a-git-repository");
|
|
327
|
+
if (await runningInProject(projectRef) > 0) throw new GitBranchError("branch-switch-blocked-running");
|
|
328
|
+
const head = await switchBranch(root, { branch: value.branch, create: value.create === true, remote: value.remote ?? null });
|
|
329
|
+
events.publish("catalog.changed", { reasonCode: "git-branch-switched" });
|
|
330
|
+
return { head, branches: await listBranches(root) };
|
|
331
|
+
},
|
|
306
332
|
updates: { status: () => updates!.status(), check: () => updates!.check(), apply: (request) => updates!.apply(request) },
|
|
307
333
|
answerSessionQuestion: (sessionRef, questionRef, answer) => company(sessionRef)
|
|
308
334
|
? relayPost(`/api/v1/sessions/${encodeURIComponent(sessionRef)}/questions/${encodeURIComponent(questionRef)}/answer`, answer)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { readGitHead, type GitHead } from "./git-head.ts";
|
|
4
|
+
|
|
5
|
+
// The branches of a project folder and a switch between them, for the branch
|
|
6
|
+
// menu in the dashboard. Git itself does the switch (`git switch`), so it
|
|
7
|
+
// refuses exactly what it refuses in a Terminal: local changes a checkout
|
|
8
|
+
// would overwrite, a name that already exists, a branch that is not there.
|
|
9
|
+
export type Branch = { name: string; current: boolean; remote: string | null; committedAt: string | null };
|
|
10
|
+
export type BranchList = { repository: true; head: GitHead | null; branches: Branch[]; truncated: boolean; changedFiles: number };
|
|
11
|
+
export type SwitchRequest = { branch: string; create?: boolean; remote?: string | null };
|
|
12
|
+
|
|
13
|
+
const GIT = process.platform === "darwin" ? "/usr/bin/git" : "git";
|
|
14
|
+
const LIMIT = 200;
|
|
15
|
+
const SEP = "\u0000";
|
|
16
|
+
|
|
17
|
+
export class GitBranchError extends Error {
|
|
18
|
+
readonly detail: string | null;
|
|
19
|
+
constructor(code: string, detail: string | null = null) { super(code); this.detail = detail; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function git(cwd: string, args: string[], timeout = 15_000): Promise<string> {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
execFile(GIT, ["-C", cwd, ...args], { timeout, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
|
|
25
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" } }, (error, stdout, stderr) => {
|
|
26
|
+
if (error) reject(Object.assign(error, { stderr: String(stderr ?? "") }));
|
|
27
|
+
else resolve(String(stdout));
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A branch name git accepts that cannot be read as an option.
|
|
33
|
+
export async function validBranchName(cwd: string, name: string): Promise<boolean> {
|
|
34
|
+
if (!name || name.length > 200 || name.startsWith("-") || /[\u0000-\u001f\u007f]/.test(name)) return false;
|
|
35
|
+
try { await git(cwd, ["check-ref-format", "--branch", name], 5_000); return true; } catch { return false; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function listBranches(cwd: string): Promise<BranchList | null> {
|
|
39
|
+
const head = readGitHead(cwd);
|
|
40
|
+
if (!head) {
|
|
41
|
+
try { await git(cwd, ["rev-parse", "--git-dir"], 5_000); } catch { return null; }
|
|
42
|
+
}
|
|
43
|
+
let refs: string;
|
|
44
|
+
try {
|
|
45
|
+
refs = await git(cwd, ["for-each-ref", "--sort=-committerdate", `--count=${LIMIT * 2}`,
|
|
46
|
+
"--format=%(refname)%00%(committerdate:iso-strict)", "refs/heads", "refs/remotes"]);
|
|
47
|
+
} catch { return null; }
|
|
48
|
+
const local = new Map<string, Branch>(), remote: Branch[] = [];
|
|
49
|
+
for (const line of refs.split("\n")) {
|
|
50
|
+
const [ref, date] = line.split(SEP);
|
|
51
|
+
if (!ref) continue;
|
|
52
|
+
const committedAt = date && Number.isFinite(Date.parse(date)) ? new Date(date).toISOString() : null;
|
|
53
|
+
if (ref.startsWith("refs/heads/")) {
|
|
54
|
+
const name = ref.slice("refs/heads/".length);
|
|
55
|
+
local.set(name, { name, current: !head?.detached && head?.name === name, remote: null, committedAt });
|
|
56
|
+
} else if (ref.startsWith("refs/remotes/")) {
|
|
57
|
+
const rest = ref.slice("refs/remotes/".length), slash = rest.indexOf("/");
|
|
58
|
+
if (slash <= 0 || rest.endsWith("/HEAD")) continue;
|
|
59
|
+
remote.push({ name: rest.slice(slash + 1), current: false, remote: rest.slice(0, slash), committedAt });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// A remote branch shows only when no local branch has its name: switching
|
|
63
|
+
// to it creates the local branch that tracks it.
|
|
64
|
+
const offered = remote.filter((item) => !local.has(item.name)).filter((item, index, all) =>
|
|
65
|
+
all.findIndex((other) => other.name === item.name) === index);
|
|
66
|
+
const all = [...local.values(), ...offered];
|
|
67
|
+
let changedFiles = 0;
|
|
68
|
+
try { changedFiles = (await git(cwd, ["status", "--porcelain=v1", "--untracked-files=no"], 10_000)).split("\n").filter(Boolean).length; }
|
|
69
|
+
catch { /* the count is advisory */ }
|
|
70
|
+
return { repository: true, head, branches: all.slice(0, LIMIT), truncated: all.length > LIMIT, changedFiles };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function failure(error: unknown): GitBranchError {
|
|
74
|
+
const stderr = String((error as { stderr?: unknown })?.stderr ?? "");
|
|
75
|
+
const detail = stderr.split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 6).join("\n").slice(0, 600) || null;
|
|
76
|
+
if (/would be overwritten by checkout|Please commit your changes or stash them/i.test(stderr)) return new GitBranchError("branch-switch-local-changes", detail);
|
|
77
|
+
if (/already exists/i.test(stderr)) return new GitBranchError("branch-exists", detail);
|
|
78
|
+
if (/invalid reference|did not match any|not a commit|no such branch/i.test(stderr)) return new GitBranchError("branch-not-found", detail);
|
|
79
|
+
if (/you need to resolve your current index first|unmerged|in the middle of/i.test(stderr)) return new GitBranchError("branch-switch-unfinished-merge", detail);
|
|
80
|
+
if ((error as { killed?: boolean })?.killed) return new GitBranchError("branch-switch-timeout", detail);
|
|
81
|
+
return new GitBranchError("branch-switch-failed", detail);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function switchBranch(cwd: string, request: SwitchRequest): Promise<GitHead | null> {
|
|
85
|
+
const name = request.branch.trim();
|
|
86
|
+
if (!(await validBranchName(cwd, name))) throw new GitBranchError("branch-name-invalid");
|
|
87
|
+
const remote = request.remote ?? null;
|
|
88
|
+
if (remote !== null && (!/^[A-Za-z0-9._-]{1,100}$/.test(remote) || request.create)) throw new GitBranchError("branch-request-invalid");
|
|
89
|
+
const args = request.create ? ["switch", "-c", name]
|
|
90
|
+
: remote ? ["switch", "-c", name, "--track", `${remote}/${name}`]
|
|
91
|
+
: ["switch", name];
|
|
92
|
+
try { await git(cwd, args, 60_000); } catch (error) { throw failure(error); }
|
|
93
|
+
return readGitHead(cwd);
|
|
94
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export type GitHead = { name: string; detached: boolean };
|
|
5
|
+
|
|
6
|
+
const MAX_DEPTH = 40;
|
|
7
|
+
|
|
8
|
+
// The branch a project folder stands on, read from the repository's HEAD file
|
|
9
|
+
// rather than by running git: the catalog asks for every project each time the
|
|
10
|
+
// dashboard refreshes, and a file read costs nothing. A linked worktree's
|
|
11
|
+
// `.git` is a file naming its own git directory, which holds its own HEAD.
|
|
12
|
+
// Null when the folder is not inside a repository or HEAD cannot be read.
|
|
13
|
+
export function readGitHead(cwd: string): GitHead | null {
|
|
14
|
+
if (!cwd) return null;
|
|
15
|
+
let folder = path.resolve(cwd);
|
|
16
|
+
for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
|
|
17
|
+
const dotGit = path.join(folder, ".git");
|
|
18
|
+
let stat: fs.Stats | null = null;
|
|
19
|
+
try { stat = fs.statSync(dotGit); } catch { /* keep walking up */ }
|
|
20
|
+
if (stat) return headAt(stat.isDirectory() ? dotGit : linkedGitDir(dotGit, folder));
|
|
21
|
+
const parent = path.dirname(folder);
|
|
22
|
+
if (parent === folder) return null;
|
|
23
|
+
folder = parent;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function linkedGitDir(dotGitFile: string, folder: string): string | null {
|
|
29
|
+
try {
|
|
30
|
+
const match = /^gitdir:\s*(.+?)\s*$/m.exec(fs.readFileSync(dotGitFile, "utf8").slice(0, 4096));
|
|
31
|
+
return match ? path.resolve(folder, match[1]) : null;
|
|
32
|
+
} catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function headAt(gitDir: string | null): GitHead | null {
|
|
36
|
+
if (!gitDir) return null;
|
|
37
|
+
let head: string;
|
|
38
|
+
try { head = fs.readFileSync(path.join(gitDir, "HEAD"), "utf8").slice(0, 1024).trim(); } catch { return null; }
|
|
39
|
+
const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
|
|
40
|
+
if (ref) return { name: ref[1], detached: false };
|
|
41
|
+
if (/^[0-9a-f]{40,64}$/i.test(head)) return { name: head.slice(0, 7), detached: true };
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// One read per project folder while a catalog is built.
|
|
46
|
+
export function gitHeadReader(): (cwd: string) => GitHead | null {
|
|
47
|
+
const seen = new Map<string, GitHead | null>();
|
|
48
|
+
return (cwd) => {
|
|
49
|
+
if (!seen.has(cwd)) seen.set(cwd, readGitHead(cwd));
|
|
50
|
+
return seen.get(cwd)!;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -6,6 +6,7 @@ import { workflowCommandPattern } from "../../piagent-core/runtime/workflows/web
|
|
|
6
6
|
import type { Catalog, SessionRow } from "../contracts/generated/session-catalog-v1.ts";
|
|
7
7
|
import type { MetadataSnapshot, SessionMetadata } from "./session-metadata-store.ts";
|
|
8
8
|
import { projectRefForCwd, sessionRefForPath } from "../ownership/session-refs.ts";
|
|
9
|
+
import { gitHeadReader, type GitHead } from "./git-head.ts";
|
|
9
10
|
|
|
10
11
|
export { projectRefForCwd, sessionRefForPath } from "../ownership/session-refs.ts";
|
|
11
12
|
|
|
@@ -105,7 +106,8 @@ function revision(key: Buffer, value: unknown): string {
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
function row(key: Buffer, info: PiSessionInfo, metadata: SessionMetadata | undefined, metadataReason: string | null,
|
|
108
|
-
ownership?: SessionOwnerProjection, sessionOptions?: SessionOptionProjection): SessionRow {
|
|
109
|
+
ownership?: SessionOwnerProjection, sessionOptions?: SessionOptionProjection, gitHead?: GitHead | null): SessionRow {
|
|
110
|
+
const gitBranch = gitHead ? { name: display(gitHead.name, 200, "HEAD"), detached: gitHead.detached } : null;
|
|
109
111
|
const projectLabel = display(info.cwd ? path.basename(info.cwd) : "", 120, "Unknown project");
|
|
110
112
|
const projected = projectedSessionTitle(info);
|
|
111
113
|
return {
|
|
@@ -123,6 +125,7 @@ function row(key: Buffer, info: PiSessionInfo, metadata: SessionMetadata | undef
|
|
|
123
125
|
unread: metadata?.unread ?? false,
|
|
124
126
|
composerAvailable: metadata?.archived ? false : ownership?.composerAvailable ?? false,
|
|
125
127
|
needsAttention: metadata?.archived ? false : ownership?.needsAttention ?? false,
|
|
128
|
+
...(gitBranch ? { gitBranch } : {}),
|
|
126
129
|
...(sessionOptions?.managedHelpers ? {managedHelpers:{...sessionOptions.managedHelpers,
|
|
127
130
|
active: ownership?.liveState === 'idle' ? 0 : ownership?.liveState === 'running' || ownership?.liveState === 'waiting-approval' ? sessionOptions.managedHelpers.active : null}} : {}),
|
|
128
131
|
...(sessionOptions?.managedThinkingLevels ? {managedThinkingLevels:sessionOptions.managedThinkingLevels} : {}),
|
|
@@ -138,6 +141,7 @@ function row(key: Buffer, info: PiSessionInfo, metadata: SessionMetadata | undef
|
|
|
138
141
|
: ownership?.owner ?? { kind: "none", ownerEpoch: null, gatewayInstanceRef: null, runtimeInstanceRef: null, continuity: "unknown" },
|
|
139
142
|
sessionRevision: revision(key, [info.path, info.modified.toISOString(), info.messageCount, info.name ?? null, metadata?.revision ?? null,
|
|
140
143
|
sessionOptions?.modelLabel ?? null, sessionOptions?.thinkingLevel ?? "unknown", sessionOptions?.managedHelpers, sessionOptions?.managedThinkingLevels, sessionOptions?.managedPermission ?? null, sessionOptions?.managedPlan, sessionOptions?.managedProcess,
|
|
144
|
+
gitBranch,
|
|
141
145
|
metadata?.archived ? "archived" : ownership ? [ownership.state, ownership.liveState, ownership.owner.kind,
|
|
142
146
|
ownership.owner.ownerEpoch, ownership.owner.runtimeInstanceRef, ownership.reasonCode] : "offline"]),
|
|
143
147
|
reasonCode: metadata?.archived ? metadataReason : ownership?.reasonCode ?? metadataReason
|
|
@@ -151,16 +155,18 @@ export async function buildSessionCatalog(options: {
|
|
|
151
155
|
readMetadata?(): MetadataSnapshot;
|
|
152
156
|
readOwnership?(sessionRef: string): SessionOwnerProjection;
|
|
153
157
|
readSessionOptions?(info: PiSessionInfo): SessionOptionProjection;
|
|
158
|
+
readGitHead?(cwd: string): GitHead | null;
|
|
154
159
|
limit?: number;
|
|
155
160
|
}): Promise<Catalog> {
|
|
156
161
|
const limit = Math.max(1, Math.min(200, options.limit ?? 200));
|
|
157
162
|
try {
|
|
158
163
|
const found = (await options.listSessions()).filter(isUserConversationSession);
|
|
164
|
+
const readHead = options.readGitHead ?? gitHeadReader();
|
|
159
165
|
const metadata = options.readMetadata?.() ?? { state: "ready", revision: null, sessions: new Map(), reasonCode: null };
|
|
160
166
|
const sessions = found.map((info) => {
|
|
161
167
|
const sessionRef = sessionRefForPath(options.key, info.path);
|
|
162
168
|
return row(options.key, info, metadata.sessions.get(sessionRef), metadata.reasonCode, options.readOwnership?.(sessionRef),
|
|
163
|
-
options.readSessionOptions?.(info));
|
|
169
|
+
options.readSessionOptions?.(info), readHead(info.cwd));
|
|
164
170
|
}).sort((left, right) => Number(right.pinned) - Number(left.pinned)
|
|
165
171
|
|| Date.parse(right.updatedAt) - Date.parse(left.updatedAt)).slice(0, limit);
|
|
166
172
|
return {
|
|
@@ -18,9 +18,10 @@ export type SessionOperationObservation = Readonly<{
|
|
|
18
18
|
|
|
19
19
|
const DEFAULT_RETRY_POLICY: SessionOperationRetryPolicy = Object.freeze({
|
|
20
20
|
// Pi's provider retry is useful before a response exists, but a long retry
|
|
21
|
-
// chain burns quota and keeps the browser in Working.
|
|
22
|
-
//
|
|
23
|
-
|
|
21
|
+
// chain burns quota and keeps the browser in Working. Two automatic retries
|
|
22
|
+
// (the company turn retry's budget, managed/request-stream.mjs) absorb a transient
|
|
23
|
+
// failure without replaying a persistent one for long.
|
|
24
|
+
maximumAttempts: 2,
|
|
24
25
|
maximumDelayMs: 8_000
|
|
25
26
|
});
|
|
26
27
|
|
|
@@ -145,6 +146,12 @@ export class SessionOperationLifecycle {
|
|
|
145
146
|
if (this.#phase === "pending") this.#phase = "running";
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
// Replay safety belongs to the model call that failed: Pi retries only
|
|
150
|
+
// that call, after the tools of earlier ones ran once. A new answer
|
|
151
|
+
// starts clean; output or a tool of this answer makes it unsafe.
|
|
152
|
+
if (type === "message_start" && event?.message?.role === "assistant") {
|
|
153
|
+
this.#visibleAssistantOutput = false; this.#toolCallObserved = false;
|
|
154
|
+
}
|
|
148
155
|
if (type === "message_update") {
|
|
149
156
|
const update = event?.assistantMessageEvent;
|
|
150
157
|
if (update?.type === "text_delta" && hasVisibleText(update.delta)) this.#visibleAssistantOutput = true;
|
|
@@ -72,9 +72,15 @@ export async function startLoopbackServer(options: {
|
|
|
72
72
|
answerSessionQuestion?: (sessionRef: string, questionRef: string, answer: unknown) => unknown | Promise<unknown>;
|
|
73
73
|
suggestPaths?: (projectRef: string, query: string) => unknown | Promise<unknown>;
|
|
74
74
|
listCommands?: (projectRef: string) => unknown | Promise<unknown>;
|
|
75
|
+
listBranches?: (projectRef: string) => unknown | Promise<unknown>;
|
|
76
|
+
switchBranch?: (projectRef: string, request: unknown) => unknown | Promise<unknown>;
|
|
75
77
|
updates?: UpdateRoutes;
|
|
76
78
|
bootstrapTtlMs?: number;
|
|
77
79
|
sessionTtlMs?: number;
|
|
80
|
+
// Requests a minute from one address before it has a browser session (the
|
|
81
|
+
// page, its assets, the bootstrap). A test harness opening dozens of fresh
|
|
82
|
+
// pages from one address raises it; Piagent itself never does.
|
|
83
|
+
anonymousRequestsPerMinute?: number;
|
|
78
84
|
}): Promise<LoopbackServer> {
|
|
79
85
|
const assets = new Map(loadStaticBundle(options.staticRoot));
|
|
80
86
|
const styleNonce = randomBytes(16).toString("base64");
|
|
@@ -112,7 +118,7 @@ export async function startLoopbackServer(options: {
|
|
|
112
118
|
if (now - rate.windowStart >= 60_000) Object.assign(rate, { windowStart: now, requests: 0, bootstrapFailures: 0 });
|
|
113
119
|
rate.requests += 1; rates.set(rateKey, rate);
|
|
114
120
|
while (rates.size > 128) rates.delete(rates.keys().next().value as string);
|
|
115
|
-
if (rate.requests > (browserSession ? SESSION_REQUESTS_PER_MINUTE : REQUESTS_PER_MINUTE)) return errorResponse(response, 429, "rate-limit");
|
|
121
|
+
if (rate.requests > (browserSession ? SESSION_REQUESTS_PER_MINUTE : options.anonymousRequestsPerMinute ?? REQUESTS_PER_MINUTE)) return errorResponse(response, 429, "rate-limit");
|
|
116
122
|
let url: URL;
|
|
117
123
|
try { url = new URL(request.url ?? "/", origin); }
|
|
118
124
|
catch { return errorResponse(response, 400, "invalid-url"); }
|
|
@@ -294,7 +300,8 @@ export async function startLoopbackServer(options: {
|
|
|
294
300
|
catch { return errorResponse(response, 503, "attachment-runtime-unavailable"); }
|
|
295
301
|
}
|
|
296
302
|
|
|
297
|
-
// The main agent's questions to the member,
|
|
303
|
+
// The main agent's questions to the member, a project's Git branches, and
|
|
304
|
+
// Piagent's own updates.
|
|
298
305
|
if (await routeMemberRequest({ request, response, url, origin, requestOrigin, auth, now, consumeControl }, options)) return;
|
|
299
306
|
|
|
300
307
|
if (request.method === "POST" && url.pathname.startsWith("/api/v1/approvals/") && url.pathname.endsWith("/decision") && options.executeApproval) {
|
|
@@ -12,6 +12,7 @@ const MAX_CONTROL_BODY_BYTES = 70_000;
|
|
|
12
12
|
const CURSOR = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
|
|
13
13
|
const QUESTION_PATH = /^\/api\/v1\/sessions\/([^/]+)\/questions(?:\/([^/]+)\/answer)?$/;
|
|
14
14
|
const PROJECT_PATH = /^\/api\/v1\/projects\/([^/]+)\/(paths|commands)$/;
|
|
15
|
+
const BRANCH_PATH = /^\/api\/v1\/projects\/([^/]+)\/branches(\/switch)?$/;
|
|
15
16
|
|
|
16
17
|
export type UpdateRoutes = {
|
|
17
18
|
// What is installed, what is available, and the last update run.
|
|
@@ -34,6 +35,10 @@ type Options = {
|
|
|
34
35
|
suggestPaths?: (projectRef: string, query: string) => unknown | Promise<unknown>;
|
|
35
36
|
// The project's and the member's commands and skills; null as above.
|
|
36
37
|
listCommands?: (projectRef: string) => unknown | Promise<unknown>;
|
|
38
|
+
// The project's Git branches, and a switch to one of them (or a new one);
|
|
39
|
+
// null as above.
|
|
40
|
+
listBranches?: (projectRef: string) => unknown | Promise<unknown>;
|
|
41
|
+
switchBranch?: (projectRef: string, request: unknown) => unknown | Promise<unknown>;
|
|
37
42
|
updates?: UpdateRoutes;
|
|
38
43
|
};
|
|
39
44
|
|
|
@@ -53,6 +58,11 @@ async function jsonBody(context: Context, code: string): Promise<{ ok: true; val
|
|
|
53
58
|
catch (error) { errorResponse(context.response, (error as Error).message === "body-limit" ? 413 : 400, code); return { ok: false }; }
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
const BRANCH_STATUS: Record<string, number> = {
|
|
62
|
+
"branch-request-invalid": 400, "branch-name-invalid": 400, "not-a-git-repository": 404, "branch-not-found": 404,
|
|
63
|
+
"branch-switch-blocked-running": 409, "branch-switch-local-changes": 409, "branch-exists": 409, "branch-switch-unfinished-merge": 409
|
|
64
|
+
};
|
|
65
|
+
|
|
56
66
|
const UPDATE_STATUS: Record<string, number> = {
|
|
57
67
|
"update-not-installable": 409, "update-blocked-running": 409, "update-already-running": 409,
|
|
58
68
|
"update-version-changed": 409, "update-not-available": 409, "update-request-invalid": 400
|
|
@@ -96,6 +106,34 @@ export async function routeMemberRequest(context: Context, options: Options): Pr
|
|
|
96
106
|
return true;
|
|
97
107
|
}
|
|
98
108
|
|
|
109
|
+
const branches = BRANCH_PATH.exec(url.pathname);
|
|
110
|
+
if (branches && (request.method === "GET" && !branches[2] && options.listBranches || request.method === "POST" && branches[2] && options.switchBranch)) {
|
|
111
|
+
const projectRef = decodeURIComponent(branches[1]);
|
|
112
|
+
if (request.method === "GET") {
|
|
113
|
+
if (!auth.authenticate(request)) { errorResponse(response, 401, "authentication-required"); return true; }
|
|
114
|
+
if (!CURSOR.test(projectRef)) { errorResponse(response, 400, "invalid-project-ref"); return true; }
|
|
115
|
+
try {
|
|
116
|
+
const value = await options.listBranches!(projectRef);
|
|
117
|
+
if (value === null) errorResponse(response, 404, "project-not-found"); else jsonResponse(response, 200, value);
|
|
118
|
+
} catch { errorResponse(response, 503, "branches-unavailable"); }
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
if (!authorizeChange(context)) return true;
|
|
122
|
+
if (!CURSOR.test(projectRef)) { errorResponse(response, 400, "invalid-project-ref"); return true; }
|
|
123
|
+
const body = await jsonBody(context, "branch-request-invalid");
|
|
124
|
+
if (!body.ok) return true;
|
|
125
|
+
try {
|
|
126
|
+
const value = await options.switchBranch!(projectRef, body.value);
|
|
127
|
+
if (value === null) errorResponse(response, 404, "project-not-found"); else jsonResponse(response, 200, value);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
const code = error instanceof Error ? error.message : "branch-switch-failed";
|
|
130
|
+
const detail = (error as { detail?: unknown })?.detail;
|
|
131
|
+
jsonResponse(response, BRANCH_STATUS[code] ?? 500, { error: { code: BRANCH_STATUS[code] || code === "branch-switch-failed" || code === "branch-switch-timeout" ? code : "branch-switch-failed",
|
|
132
|
+
...(typeof detail === "string" ? { detail } : {}) } });
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
99
137
|
if (!options.updates || !url.pathname.startsWith("/api/v1/updates")) return false;
|
|
100
138
|
const updates = options.updates;
|
|
101
139
|
if (request.method === "GET" && url.pathname === "/api/v1/updates") {
|
|
@@ -99,6 +99,9 @@
|
|
|
99
99
|
"managedProcess": { "type": "object", "additionalProperties": false, "required": ["outcome", "verified", "reviewed", "blockingOpen"], "properties": {
|
|
100
100
|
"outcome": { "enum": ["no_change", "interrupted", "disputed", "blocking_open", "unverified", "review_unavailable", "unreviewed", "clean"] },
|
|
101
101
|
"verified": { "type": "boolean" }, "reviewed": { "type": "boolean" }, "blockingOpen": { "type": "integer", "minimum": 0, "maximum": 1000 }, "planSkipped": { "const": true } } },
|
|
102
|
+
"gitBranch": { "type": "object", "additionalProperties": false, "required": ["name", "detached"], "properties": {
|
|
103
|
+
"name": { "type": "string", "minLength": 1, "maxLength": 200, "pattern": "^[^\\u0000-\\u001F\\u007F]*$" },
|
|
104
|
+
"detached": { "type": "boolean" } } },
|
|
102
105
|
"modelLabel": { "$ref": "common-v1.schema.json#/$defs/nullableDisplayText" },
|
|
103
106
|
"thinkingLevel": { "enum": ["off", "minimal", "low", "medium", "high", "xhigh", "max", "unknown"] },
|
|
104
107
|
"contextUsage": { "$ref": "#/$defs/contextUsage" },
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
"anthropic/claude-opus-4-8:xhigh",
|
|
28
28
|
"anthropic/claude-fable-5:xhigh",
|
|
29
29
|
"anthropic/claude-sonnet-5-5:high",
|
|
30
|
-
"anthropic/claude-opus-5-5:high"
|
|
30
|
+
"anthropic/claude-opus-5-5:high",
|
|
31
|
+
"anthropic/claude-haiku-5-5:medium"
|
|
31
32
|
],
|
|
32
33
|
"compaction": {
|
|
33
34
|
"enabled": true,
|