@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/README.vi.md +1 -1
  4. package/catalog/claude-model-additions.json +49 -2
  5. package/docs/command-reference-vietnamese.md +2 -2
  6. package/docs/distribution-standard.md +6 -6
  7. package/docs/languages.json +2 -2
  8. package/docs/operator-manual-vietnamese.md +7 -7
  9. package/docs/publishing-for-teams.md +2 -0
  10. package/docs/release-install-policy.md +2 -2
  11. package/docs/team-onboarding.md +2 -2
  12. package/docs-site/architecture.html +1 -1
  13. package/docs-site/benchmark.html +1 -1
  14. package/docs-site/commands.html +1 -1
  15. package/docs-site/content/en/index.html +4 -4
  16. package/docs-site/content/en/whats-new.html +12 -0
  17. package/docs-site/content/index.html +4 -4
  18. package/docs-site/content/whats-new.html +12 -0
  19. package/docs-site/context-engine.html +1 -1
  20. package/docs-site/ecosystem.html +1 -1
  21. package/docs-site/en/architecture.html +1 -1
  22. package/docs-site/en/benchmark.html +1 -1
  23. package/docs-site/en/commands.html +1 -1
  24. package/docs-site/en/context-engine.html +1 -1
  25. package/docs-site/en/ecosystem.html +1 -1
  26. package/docs-site/en/index.html +5 -5
  27. package/docs-site/en/mcp-approval.html +1 -1
  28. package/docs-site/en/mcp-auth.html +1 -1
  29. package/docs-site/en/mcp-servers.html +1 -1
  30. package/docs-site/en/mcp.html +1 -1
  31. package/docs-site/en/permissions.html +1 -1
  32. package/docs-site/en/profiles.html +1 -1
  33. package/docs-site/en/quickstart.html +1 -1
  34. package/docs-site/en/runtime.html +1 -1
  35. package/docs-site/en/scope.html +1 -1
  36. package/docs-site/en/security.html +1 -1
  37. package/docs-site/en/team.html +1 -1
  38. package/docs-site/en/whats-new.html +13 -1
  39. package/docs-site/en/windows.html +1 -1
  40. package/docs-site/en/workflows.html +1 -1
  41. package/docs-site/index.html +5 -5
  42. package/docs-site/mcp-approval.html +1 -1
  43. package/docs-site/mcp-auth.html +1 -1
  44. package/docs-site/mcp-servers.html +1 -1
  45. package/docs-site/mcp.html +1 -1
  46. package/docs-site/permissions.html +1 -1
  47. package/docs-site/profiles.html +1 -1
  48. package/docs-site/quickstart.html +1 -1
  49. package/docs-site/runtime.html +1 -1
  50. package/docs-site/scope.html +1 -1
  51. package/docs-site/security.html +1 -1
  52. package/docs-site/team.html +1 -1
  53. package/docs-site/whats-new.html +13 -1
  54. package/docs-site/windows.html +1 -1
  55. package/docs-site/workflows.html +1 -1
  56. package/evals/private-holdout-v1/public-exposure.v2.json +3 -3
  57. package/evals/runtime-conformance-v1/runner.mjs +13 -17
  58. package/package.json +1 -1
  59. package/packages/piagent-core/managed/native-catalog.mjs +14 -1
  60. package/packages/piagent-core/managed/request-stream.mjs +28 -0
  61. package/packages/piagent-core/managed/session.mjs +2 -1
  62. package/packages/piagent-core/package.json +1 -1
  63. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-BDdLJYnZ.js → MarkdownMessage-DlbuyiQ3.js} +1 -1
  64. package/packages/piagent-webui/dist/client/assets/{index-DJGByyfL.js → index-BGxsZHB5.js} +14 -14
  65. package/packages/piagent-webui/dist/client/index.html +1 -1
  66. package/packages/piagent-webui/gateway/gateway-service.ts +26 -0
  67. package/packages/piagent-webui/gateway/git-branches.ts +94 -0
  68. package/packages/piagent-webui/gateway/git-head.ts +52 -0
  69. package/packages/piagent-webui/gateway/session-catalog.ts +8 -2
  70. package/packages/piagent-webui/gateway/session-operation-lifecycle.ts +10 -3
  71. package/packages/piagent-webui/server/loopback-server.ts +9 -2
  72. package/packages/piagent-webui/server/member-routes.ts +38 -0
  73. package/schemas/piagent-webui/session-catalog-v1.schema.json +3 -0
  74. package/scripts/configure-model-scope.sh +1 -0
  75. 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-DJGByyfL.js"></script>
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. One automatic retry
22
- // absorbs an ordinary transient failure without replaying a persistent one.
23
- maximumAttempts: 1,
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, and Piagent's own updates.
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" },
@@ -135,6 +135,7 @@ const codexModels = [
135
135
 
136
136
  const claudeModels = [
137
137
  "anthropic/claude-haiku-4-5:low",
138
+ "anthropic/claude-haiku-5-5:medium",
138
139
  "anthropic/claude-sonnet-4-5:high",
139
140
  "anthropic/claude-sonnet-4-6:max",
140
141
  "anthropic/claude-sonnet-5:xhigh",
@@ -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,