@piagent/platform 1.17.0 → 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 (72) hide show
  1. package/CHANGELOG.md +11 -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 +1 -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 +6 -0
  17. package/docs-site/content/index.html +4 -4
  18. package/docs-site/content/whats-new.html +6 -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 +7 -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 +7 -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 +2 -2
  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-CeQ79h30.js → MarkdownMessage-DlbuyiQ3.js} +1 -1
  64. package/packages/piagent-webui/dist/client/assets/{index-BNQK7rHr.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/session-operation-lifecycle.ts +10 -3
  69. package/packages/piagent-webui/server/loopback-server.ts +9 -2
  70. package/packages/piagent-webui/server/member-routes.ts +38 -0
  71. package/scripts/configure-model-scope.sh +1 -0
  72. 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-BNQK7rHr.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
+ }
@@ -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") {
@@ -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,