@piagent/platform 1.17.0 → 1.19.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 +33 -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 +13 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +13 -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 +14 -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 +14 -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/call-purpose.mjs +3 -2
- package/packages/piagent-core/managed/command-tools.mjs +59 -0
- package/packages/piagent-core/managed/container-engine.mjs +102 -0
- package/packages/piagent-core/managed/continuation.mjs +131 -0
- package/packages/piagent-core/managed/doctor.mjs +7 -0
- package/packages/piagent-core/managed/language-environment.mjs +57 -0
- package/packages/piagent-core/managed/linux-sandbox.mjs +3 -1
- package/packages/piagent-core/managed/native-catalog.mjs +14 -1
- package/packages/piagent-core/managed/patch-snapshot.mjs +25 -9
- package/packages/piagent-core/managed/repository-operation.mjs +20 -0
- package/packages/piagent-core/managed/request-stream.mjs +28 -0
- package/packages/piagent-core/managed/review-snapshot-worker.mjs +11 -4
- package/packages/piagent-core/managed/sandbox-diagnostic.mjs +6 -1
- package/packages/piagent-core/managed/session.mjs +52 -63
- package/packages/piagent-core/managed/tool-boundary.mjs +21 -10
- package/packages/piagent-core/managed/tool-worker.mjs +3 -3
- package/packages/piagent-core/managed/workflow.mjs +20 -20
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-CeQ79h30.js → MarkdownMessage-nggiOt0N.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{index-BNQK7rHr.js → index-CM9cs-gP.js} +16 -14
- package/packages/piagent-webui/dist/client/index.html +1 -1
- package/packages/piagent-webui/gateway/company-relay.ts +14 -2
- package/packages/piagent-webui/gateway/gateway-service.ts +38 -0
- package/packages/piagent-webui/gateway/git-branches.ts +94 -0
- package/packages/piagent-webui/gateway/git-repositories.ts +46 -0
- package/packages/piagent-webui/gateway/managed-projection.ts +9 -2
- package/packages/piagent-webui/gateway/project-registry.ts +10 -6
- package/packages/piagent-webui/gateway/session-catalog.ts +13 -6
- 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 +41 -0
- package/packages/piagent-webui/server/tool-call-summary.ts +3 -1
- package/packages/piagent-webui/server/transcript-projection.ts +20 -4
- package/packages/piagent-webui/shared/process-copy.ts +3 -1
- package/packages/piagent-webui/shared/turn-end.ts +68 -0
- package/schemas/piagent-webui/session-catalog-v1.schema.json +11 -1
- package/schemas/piagent-webui/transcript-v1.schema.json +23 -2
- package/scripts/company-terminal.mjs +15 -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-CM9cs-gP.js"></script>
|
|
11
11
|
<link rel="stylesheet" crossorigin href="/assets/index-Z91z6SlN.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
@@ -4,6 +4,8 @@ import { WebSocket } from "ws";
|
|
|
4
4
|
import type { Catalog, SessionRow } from "../contracts/generated/session-catalog-v1.ts";
|
|
5
5
|
import { webUiModelRef } from "../../piagent-core/runtime/inspection/webui-snapshot.ts";
|
|
6
6
|
import { requestGatewayControl } from "./control-socket.ts";
|
|
7
|
+
import { gitHeadReader } from "./git-head.ts";
|
|
8
|
+
import { childRepositoryReader } from "./git-repositories.ts";
|
|
7
9
|
import type { GatewayEventKind, GatewayEventStore } from "./gateway-events.ts";
|
|
8
10
|
|
|
9
11
|
// The personal dashboard lists and drives company sessions through this relay.
|
|
@@ -250,12 +252,22 @@ export class CompanyRelay {
|
|
|
250
252
|
const company = await this.catalog();
|
|
251
253
|
if (personal.state !== "ready" || !personal.catalogRevision) return personal;
|
|
252
254
|
const ready = this.#state === "ready";
|
|
255
|
+
// The Git facts of a company conversation's folder are read here, on the
|
|
256
|
+
// same Mac: the company Gateway may run an earlier release (Agent Watch
|
|
257
|
+
// restarts it only now and then) that does not report them.
|
|
258
|
+
const readHead = gitHeadReader(), readRepositories = childRepositoryReader();
|
|
253
259
|
const rows: SessionRow[] = (company?.state === "ready" ? company.sessions : []).map((row) => {
|
|
254
260
|
const folder = this.#paths.get(row.projectRef);
|
|
255
|
-
|
|
261
|
+
const { gitBranch: _branch, gitRepositories: _repositories, ...rest } = row;
|
|
262
|
+
const head = folder ? readHead(folder) : null, repositories = folder && !head ? readRepositories(folder) : [];
|
|
263
|
+
return { ...rest, projectRef: folder ? this.#hubProject(folder) : row.projectRef, composerAvailable: ready && row.composerAvailable,
|
|
264
|
+
...(folder ? {} : _branch ? { gitBranch: _branch } : {}), ...(folder ? {} : _repositories ? { gitRepositories: _repositories } : {}),
|
|
265
|
+
...(head ? { gitBranch: head } : {}),
|
|
266
|
+
...(repositories.length ? { gitRepositories: repositories.map((repository) => ({ name: repository.name, branch: repository.head })) } : {}) };
|
|
256
267
|
});
|
|
257
268
|
const sessions = [...personal.sessions, ...rows].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
|
|
258
|
-
const digest = createHash("sha256").update(JSON.stringify([personal.catalogRevision, company?.catalogRevision ?? null, ready
|
|
269
|
+
const digest = createHash("sha256").update(JSON.stringify([personal.catalogRevision, company?.catalogRevision ?? null, ready,
|
|
270
|
+
rows.map((row) => [row.gitBranch ?? null, row.gitRepositories ?? null])])).digest("hex");
|
|
259
271
|
return { ...personal, catalogRevision: `rev_${digest}`, sessions,
|
|
260
272
|
page: { ...personal.page, returned: sessions.length, total: personal.page.total + rows.length } };
|
|
261
273
|
}
|
|
@@ -20,6 +20,8 @@ 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";
|
|
24
|
+
import { childRepositories, childRepositoryFolder } from "./git-repositories.ts";
|
|
23
25
|
import { listAgentCommands } from "../../piagent-core/runtime/resources/agent-resources.mjs";
|
|
24
26
|
import { sessionRefForPath } from "../ownership/session-refs.ts";
|
|
25
27
|
import { SessionMetadataStore } from "./session-metadata-store.ts";
|
|
@@ -229,6 +231,8 @@ export async function startPiagentGateway(options: {
|
|
|
229
231
|
?? (await runtimes!.listSessions()).find((info) => typeof info.cwd === "string" && projectRefForCwd(key, info.cwd) === projectRef)?.cwd
|
|
230
232
|
?? (relay ? (await relay.folders()).find((folder) => projectRefForCwd(key, folder) === projectRef) : undefined);
|
|
231
233
|
const relayPost = (path: string, body: unknown) => relay!.json("POST", path, body);
|
|
234
|
+
const runningInProject = async (projectRef: string) => (await hubCatalog()).sessions.filter((row) => row.projectRef === projectRef
|
|
235
|
+
&& !row.archived && ["running", "waiting-approval", "paused"].includes(row.liveState)).length;
|
|
232
236
|
const commands = new SessionCommandController({ catalog: hubCatalog, runtimes, metadata,
|
|
233
237
|
store: new SessionCommandStore(state.root, key), events,
|
|
234
238
|
// Resolved at call time: the attachment registry is built after this
|
|
@@ -303,6 +307,40 @@ export async function startPiagentGateway(options: {
|
|
|
303
307
|
const root = await projectFolder(projectRef);
|
|
304
308
|
return root ? { commands: listAgentCommands({ cwd: root }) } : null;
|
|
305
309
|
},
|
|
310
|
+
// The project's Git branches and a switch between them. A switch waits
|
|
311
|
+
// until no conversation in the folder runs (company ones included): the
|
|
312
|
+
// agent would find its files changed under it.
|
|
313
|
+
// A folder that is not a repository answers with the repositories one
|
|
314
|
+
// level down; `repository` then names the one to read or switch.
|
|
315
|
+
listBranches: async (projectRef, repository) => {
|
|
316
|
+
const root = await projectFolder(projectRef);
|
|
317
|
+
if (!root) return null;
|
|
318
|
+
const folder = repository === null ? root : childRepositoryFolder(root, repository);
|
|
319
|
+
if (!folder) throw new GitBranchError("not-a-git-repository");
|
|
320
|
+
const [list, running] = await Promise.all([listBranches(folder), runningInProject(projectRef)]);
|
|
321
|
+
if (list) return { ...list, running, ...(repository === null ? {} : { name: repository }) };
|
|
322
|
+
const repositories = repository === null ? await Promise.all(childRepositories(root).map(async (child) => {
|
|
323
|
+
const branches = await listBranches(path.join(root, child.name));
|
|
324
|
+
return { name: child.name, head: child.head, changedFiles: branches?.changedFiles ?? 0 };
|
|
325
|
+
})) : [];
|
|
326
|
+
return { repository: false, running, repositories };
|
|
327
|
+
},
|
|
328
|
+
switchBranch: async (projectRef, request) => {
|
|
329
|
+
const root = await projectFolder(projectRef);
|
|
330
|
+
if (!root) return null;
|
|
331
|
+
const value = request as (Partial<SwitchRequest> & { repository?: string }) | null;
|
|
332
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.branch !== "string"
|
|
333
|
+
|| Object.keys(value).some((name) => !["branch", "create", "remote", "repository"].includes(name))
|
|
334
|
+
|| (value.repository !== undefined && typeof value.repository !== "string")
|
|
335
|
+
|| (value.create !== undefined && typeof value.create !== "boolean")
|
|
336
|
+
|| (value.remote !== undefined && value.remote !== null && typeof value.remote !== "string")) throw new GitBranchError("branch-request-invalid");
|
|
337
|
+
const folder = value.repository === undefined ? root : childRepositoryFolder(root, value.repository);
|
|
338
|
+
if (!folder || !(await listBranches(folder))) throw new GitBranchError("not-a-git-repository");
|
|
339
|
+
if (await runningInProject(projectRef) > 0) throw new GitBranchError("branch-switch-blocked-running");
|
|
340
|
+
const head = await switchBranch(folder, { branch: value.branch, create: value.create === true, remote: value.remote ?? null });
|
|
341
|
+
events.publish("catalog.changed", { reasonCode: "git-branch-switched" });
|
|
342
|
+
return { head, branches: await listBranches(folder) };
|
|
343
|
+
},
|
|
306
344
|
updates: { status: () => updates!.status(), check: () => updates!.check(), apply: (request) => updates!.apply(request) },
|
|
307
345
|
answerSessionQuestion: (sessionRef, questionRef, answer) => company(sessionRef)
|
|
308
346
|
? 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,46 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { readGitHead, type GitHead } from "./git-head.ts";
|
|
5
|
+
|
|
6
|
+
// A project folder that is not itself in a repository may hold several (a
|
|
7
|
+
// workspace folder with the front end, the back end and other projects side
|
|
8
|
+
// by side, each its own repository). Like an editor's source control view,
|
|
9
|
+
// the dashboard looks one level down for them: each child folder with its
|
|
10
|
+
// own `.git` (a folder, or a worktree's file) is a repository, shown with
|
|
11
|
+
// its branch. Hidden folders and dependency folders are skipped.
|
|
12
|
+
export type ChildRepository = { name: string; path: string; head: GitHead | null };
|
|
13
|
+
|
|
14
|
+
const MAX_REPOSITORIES = 30;
|
|
15
|
+
const SKIPPED = new Set(["node_modules", "vendor", "target", "dist", "build", "Pods", "venv", "__pycache__"]);
|
|
16
|
+
|
|
17
|
+
export function childRepositories(cwd: string): ChildRepository[] {
|
|
18
|
+
if (!cwd || readGitHead(cwd)) return [];
|
|
19
|
+
let entries: fs.Dirent[];
|
|
20
|
+
try { entries = fs.readdirSync(cwd, { withFileTypes: true }); } catch { return []; }
|
|
21
|
+
const found: ChildRepository[] = [];
|
|
22
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
23
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || SKIPPED.has(entry.name)) continue;
|
|
24
|
+
const folder = path.join(cwd, entry.name);
|
|
25
|
+
try { fs.lstatSync(path.join(folder, ".git")); } catch { continue; }
|
|
26
|
+
found.push({ name: entry.name, path: entry.name, head: readGitHead(folder) });
|
|
27
|
+
if (found.length === MAX_REPOSITORIES) break;
|
|
28
|
+
}
|
|
29
|
+
return found;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The folder of one child repository, or null when `name` is not one: a
|
|
33
|
+
// single folder name, never a path that climbs out of the project.
|
|
34
|
+
export function childRepositoryFolder(cwd: string, name: string): string | null {
|
|
35
|
+
if (!name || name.length > 255 || name.startsWith(".") || name.includes("/") || name.includes("\\") || /[\u0000-\u001f\u007f]/.test(name)) return null;
|
|
36
|
+
return childRepositories(cwd).some((repository) => repository.name === name) ? path.join(cwd, name) : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// One read per project folder while a catalog is built.
|
|
40
|
+
export function childRepositoryReader(): (cwd: string) => ChildRepository[] {
|
|
41
|
+
const seen = new Map<string, ChildRepository[]>();
|
|
42
|
+
return (cwd) => {
|
|
43
|
+
if (!seen.has(cwd)) seen.set(cwd, childRepositories(cwd));
|
|
44
|
+
return seen.get(cwd)!;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { turnEndOf } from '../shared/turn-end.ts';
|
|
1
2
|
// Public managed state contains no provider/model route or credential.
|
|
2
|
-
const STATUS = ['pending', 'in_progress', 'completed'];
|
|
3
|
+
const STATUS = ['pending', 'in_progress', 'completed', 'deferred'];
|
|
3
4
|
const OUTCOMES = ['no_change', 'interrupted', 'disputed', 'blocking_open', 'unverified', 'review_unavailable', 'unreviewed', 'clean'];
|
|
4
5
|
export function managedProjection(context: any, entries: any[]) {
|
|
5
6
|
if (context?.model?.provider !== 'agent_watch_managed') return {};
|
|
@@ -14,6 +15,12 @@ export function managedProjection(context: any, entries: any[]) {
|
|
|
14
15
|
const final = entries.filter(e => e.type === 'custom_message' && e.customType === 'agent-watch-process' && e.details?.phase === 'final').at(-1)?.details;
|
|
15
16
|
const process = final && OUTCOMES.includes(final.outcome) ? { outcome: final.outcome, verified: final.verified === true, reviewed: final.reviewed === true,
|
|
16
17
|
blockingOpen: Number.isInteger(final.blockingOpen) && final.blockingOpen >= 0 ? Math.min(final.blockingOpen, 1000) : 0, ...(final.planSkipped === true ? { planSkipped: true as const } : {}) } : null;
|
|
18
|
+
// How the member's last message ended, unless a newer message is running.
|
|
19
|
+
const lastIndex = (match: (e: any) => boolean) => { for (let i = entries.length - 1; i >= 0; i -= 1) if (match(entries[i])) return i; return -1; };
|
|
20
|
+
const lastUser = lastIndex(e => e.type === 'message' && e.message?.role === 'user');
|
|
21
|
+
const endAt = lastIndex(e => e.type === 'custom' && e.customType === 'agent-watch-turn-end');
|
|
22
|
+
const end = endAt > lastUser ? turnEndOf(entries[endAt].data) : null;
|
|
23
|
+
const turnEnd = end ? { state: end.state, planSteps: end.planSteps, planDone: end.planDone, ...(end.planDeferred ? { planDeferred: end.planDeferred } : {}), ...(end.reason ? { reason: end.reason } : {}) } : null;
|
|
17
24
|
// Helpers the Harness enables (up to four); a conversation recorded before
|
|
18
25
|
// that was known says two.
|
|
19
26
|
const maximum = Number.isInteger(counter?.maximum) && counter.maximum >= 0 && counter.maximum <= 4 ? counter.maximum : 2;
|
|
@@ -21,5 +28,5 @@ export function managedProjection(context: any, entries: any[]) {
|
|
|
21
28
|
const permission = last('agent-watch-permission')?.mode === 'trusted-full-access' ? 'trusted-full-access' : 'workspace-write';
|
|
22
29
|
return {modelLabel:'agent-watch-auto', managedPermission: permission, managedHelpers:{active:Number.isInteger(counter?.active) && counter.active>=0 && counter.active<=maximum ? counter.active : 0, maximum},
|
|
23
30
|
...(Array.isArray(capabilities)?{managedThinkingLevels:levels.filter(level=>capabilities.includes(level))}:{}),
|
|
24
|
-
...(steps ? {managedPlan:{steps}} : {}), ...(process ? {managedProcess:process} : {})};
|
|
31
|
+
...(steps ? {managedPlan:{steps}} : {}), ...(process ? {managedProcess:process} : {}), ...(turnEnd ? {managedTurnEnd:turnEnd} : {})};
|
|
25
32
|
}
|
|
@@ -78,12 +78,16 @@ export class ProjectRegistry {
|
|
|
78
78
|
if (!stat.isDirectory() || stat.isSymbolicLink() || cwd === path.parse(cwd).root) throw new Error("project-import-folder-invalid");
|
|
79
79
|
const current = this.#read(), projectRef = projectRefForCwd(this.#key, cwd);
|
|
80
80
|
const existing = current.projects.find((item) => item.projectRef === projectRef);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
81
|
+
// Added again: it moves to the newest place, so it outlives older folders.
|
|
82
|
+
const others = current.projects.filter((item) => item.projectRef !== projectRef);
|
|
83
|
+
const item = { projectRef, cwd, label: existing?.label ?? cleanLabel(path.basename(cwd)), importedAt: now.toISOString() };
|
|
84
|
+
// The list is the menu's memory of added folders, not where conversations
|
|
85
|
+
// live (their folders come from the sessions): a full list makes room by
|
|
86
|
+
// forgetting folders gone from the disk, then the folders added longest ago.
|
|
87
|
+
const kept = others.length < MAX_PROJECTS ? others : others.filter((other) => {
|
|
88
|
+
try { return fs.realpathSync(other.cwd) === other.cwd && fs.lstatSync(other.cwd).isDirectory(); } catch { return false; }
|
|
89
|
+
}).sort((left, right) => Date.parse(left.importedAt) - Date.parse(right.importedAt)).slice(-(MAX_PROJECTS - 1));
|
|
90
|
+
this.#write({ ...current, projects: [...kept, item] });
|
|
87
91
|
return { projectRef: item.projectRef, placeRef: item.projectRef, label: item.label };
|
|
88
92
|
}
|
|
89
93
|
|
|
@@ -7,6 +7,7 @@ import type { Catalog, SessionRow } from "../contracts/generated/session-catalog
|
|
|
7
7
|
import type { MetadataSnapshot, SessionMetadata } from "./session-metadata-store.ts";
|
|
8
8
|
import { projectRefForCwd, sessionRefForPath } from "../ownership/session-refs.ts";
|
|
9
9
|
import { gitHeadReader, type GitHead } from "./git-head.ts";
|
|
10
|
+
import { childRepositoryReader, type ChildRepository } from "./git-repositories.ts";
|
|
10
11
|
|
|
11
12
|
export { projectRefForCwd, sessionRefForPath } from "../ownership/session-refs.ts";
|
|
12
13
|
|
|
@@ -48,7 +49,7 @@ function catalogRef(key: Buffer, namespace: string, value: string): string {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
export type SessionOwnerProjection = Pick<SessionRow, "state" | "liveState" | "composerAvailable" | "needsAttention" | "owner" | "reasonCode">;
|
|
51
|
-
export type SessionOptionProjection = Pick<SessionRow, "modelLabel" | "thinkingLevel" | "managedHelpers" | "managedThinkingLevels" | "managedPermission" | "managedPlan" | "managedProcess">;
|
|
52
|
+
export type SessionOptionProjection = Pick<SessionRow, "modelLabel" | "thinkingLevel" | "managedHelpers" | "managedThinkingLevels" | "managedPermission" | "managedPlan" | "managedProcess" | "managedTurnEnd">;
|
|
52
53
|
|
|
53
54
|
function display(value: unknown, maximum: number, fallback: string): string {
|
|
54
55
|
const clean = redactSensitiveText(String(value ?? "")).text
|
|
@@ -106,8 +107,11 @@ function revision(key: Buffer, value: unknown): string {
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
function row(key: Buffer, info: PiSessionInfo, metadata: SessionMetadata | undefined, metadataReason: string | null,
|
|
109
|
-
ownership?: SessionOwnerProjection, sessionOptions?: SessionOptionProjection, gitHead?: GitHead | null): SessionRow {
|
|
110
|
+
ownership?: SessionOwnerProjection, sessionOptions?: SessionOptionProjection, gitHead?: GitHead | null, repositories: ChildRepository[] = []): SessionRow {
|
|
110
111
|
const gitBranch = gitHead ? { name: display(gitHead.name, 200, "HEAD"), detached: gitHead.detached } : null;
|
|
112
|
+
// A folder that is not a repository but holds some (front end, back end…).
|
|
113
|
+
const gitRepositories = gitBranch ? [] : repositories.map((repository) => ({ name: display(repository.name, 255, "repository").replace(/[/\\]/g, "-"),
|
|
114
|
+
branch: repository.head ? { name: display(repository.head.name, 200, "HEAD"), detached: repository.head.detached } : null }));
|
|
111
115
|
const projectLabel = display(info.cwd ? path.basename(info.cwd) : "", 120, "Unknown project");
|
|
112
116
|
const projected = projectedSessionTitle(info);
|
|
113
117
|
return {
|
|
@@ -126,12 +130,14 @@ function row(key: Buffer, info: PiSessionInfo, metadata: SessionMetadata | undef
|
|
|
126
130
|
composerAvailable: metadata?.archived ? false : ownership?.composerAvailable ?? false,
|
|
127
131
|
needsAttention: metadata?.archived ? false : ownership?.needsAttention ?? false,
|
|
128
132
|
...(gitBranch ? { gitBranch } : {}),
|
|
133
|
+
...(gitRepositories.length ? { gitRepositories } : {}),
|
|
129
134
|
...(sessionOptions?.managedHelpers ? {managedHelpers:{...sessionOptions.managedHelpers,
|
|
130
135
|
active: ownership?.liveState === 'idle' ? 0 : ownership?.liveState === 'running' || ownership?.liveState === 'waiting-approval' ? sessionOptions.managedHelpers.active : null}} : {}),
|
|
131
136
|
...(sessionOptions?.managedThinkingLevels ? {managedThinkingLevels:sessionOptions.managedThinkingLevels} : {}),
|
|
132
137
|
...(sessionOptions?.managedPermission ? {managedPermission:sessionOptions.managedPermission} : {}),
|
|
133
138
|
...(sessionOptions?.managedPlan ? {managedPlan:sessionOptions.managedPlan} : {}),
|
|
134
139
|
...(sessionOptions?.managedProcess ? {managedProcess:sessionOptions.managedProcess} : {}),
|
|
140
|
+
...(sessionOptions?.managedTurnEnd ? {managedTurnEnd:sessionOptions.managedTurnEnd} : {}),
|
|
135
141
|
modelLabel: sessionOptions?.modelLabel ?? null,
|
|
136
142
|
thinkingLevel: sessionOptions?.thinkingLevel ?? "unknown",
|
|
137
143
|
contextUsage: { usedTokens: null, contextWindow: null, ratio: null, state: "unknown" },
|
|
@@ -140,8 +146,8 @@ function row(key: Buffer, info: PiSessionInfo, metadata: SessionMetadata | undef
|
|
|
140
146
|
? { kind: "none", ownerEpoch: null, gatewayInstanceRef: null, runtimeInstanceRef: null, continuity: "released" }
|
|
141
147
|
: ownership?.owner ?? { kind: "none", ownerEpoch: null, gatewayInstanceRef: null, runtimeInstanceRef: null, continuity: "unknown" },
|
|
142
148
|
sessionRevision: revision(key, [info.path, info.modified.toISOString(), info.messageCount, info.name ?? null, metadata?.revision ?? null,
|
|
143
|
-
sessionOptions?.modelLabel ?? null, sessionOptions?.thinkingLevel ?? "unknown", sessionOptions?.managedHelpers, sessionOptions?.managedThinkingLevels, sessionOptions?.managedPermission ?? null, sessionOptions?.managedPlan, sessionOptions?.managedProcess,
|
|
144
|
-
gitBranch,
|
|
149
|
+
sessionOptions?.modelLabel ?? null, sessionOptions?.thinkingLevel ?? "unknown", sessionOptions?.managedHelpers, sessionOptions?.managedThinkingLevels, sessionOptions?.managedPermission ?? null, sessionOptions?.managedPlan, sessionOptions?.managedProcess, sessionOptions?.managedTurnEnd,
|
|
150
|
+
gitBranch, gitRepositories,
|
|
145
151
|
metadata?.archived ? "archived" : ownership ? [ownership.state, ownership.liveState, ownership.owner.kind,
|
|
146
152
|
ownership.owner.ownerEpoch, ownership.owner.runtimeInstanceRef, ownership.reasonCode] : "offline"]),
|
|
147
153
|
reasonCode: metadata?.archived ? metadataReason : ownership?.reasonCode ?? metadataReason
|
|
@@ -156,17 +162,18 @@ export async function buildSessionCatalog(options: {
|
|
|
156
162
|
readOwnership?(sessionRef: string): SessionOwnerProjection;
|
|
157
163
|
readSessionOptions?(info: PiSessionInfo): SessionOptionProjection;
|
|
158
164
|
readGitHead?(cwd: string): GitHead | null;
|
|
165
|
+
readChildRepositories?(cwd: string): ChildRepository[];
|
|
159
166
|
limit?: number;
|
|
160
167
|
}): Promise<Catalog> {
|
|
161
168
|
const limit = Math.max(1, Math.min(200, options.limit ?? 200));
|
|
162
169
|
try {
|
|
163
170
|
const found = (await options.listSessions()).filter(isUserConversationSession);
|
|
164
|
-
const readHead = options.readGitHead ?? gitHeadReader();
|
|
171
|
+
const readHead = options.readGitHead ?? gitHeadReader(), readRepositories = options.readChildRepositories ?? childRepositoryReader();
|
|
165
172
|
const metadata = options.readMetadata?.() ?? { state: "ready", revision: null, sessions: new Map(), reasonCode: null };
|
|
166
173
|
const sessions = found.map((info) => {
|
|
167
174
|
const sessionRef = sessionRefForPath(options.key, info.path);
|
|
168
175
|
return row(options.key, info, metadata.sessions.get(sessionRef), metadata.reasonCode, options.readOwnership?.(sessionRef),
|
|
169
|
-
options.readSessionOptions?.(info), readHead(info.cwd));
|
|
176
|
+
options.readSessionOptions?.(info), readHead(info.cwd), readRepositories(info.cwd));
|
|
170
177
|
}).sort((left, right) => Number(right.pinned) - Number(left.pinned)
|
|
171
178
|
|| Date.parse(right.updatedAt) - Date.parse(left.updatedAt)).slice(0, limit);
|
|
172
179
|
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, repository: string | null) => 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, repository: string | null) => 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,37 @@ 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
|
+
const repository = url.searchParams.get("repository");
|
|
115
|
+
if (!CURSOR.test(projectRef) || repository !== null && (repository.length === 0 || repository.length > 255 || /[\x00-\x1f/\\]/.test(repository))) {
|
|
116
|
+
errorResponse(response, 400, "invalid-project-ref"); return true;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const value = await options.listBranches!(projectRef, repository);
|
|
120
|
+
if (value === null) errorResponse(response, 404, "project-not-found"); else jsonResponse(response, 200, value);
|
|
121
|
+
} catch { errorResponse(response, 503, "branches-unavailable"); }
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
if (!authorizeChange(context)) return true;
|
|
125
|
+
if (!CURSOR.test(projectRef)) { errorResponse(response, 400, "invalid-project-ref"); return true; }
|
|
126
|
+
const body = await jsonBody(context, "branch-request-invalid");
|
|
127
|
+
if (!body.ok) return true;
|
|
128
|
+
try {
|
|
129
|
+
const value = await options.switchBranch!(projectRef, body.value);
|
|
130
|
+
if (value === null) errorResponse(response, 404, "project-not-found"); else jsonResponse(response, 200, value);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const code = error instanceof Error ? error.message : "branch-switch-failed";
|
|
133
|
+
const detail = (error as { detail?: unknown })?.detail;
|
|
134
|
+
jsonResponse(response, BRANCH_STATUS[code] ?? 500, { error: { code: BRANCH_STATUS[code] || code === "branch-switch-failed" || code === "branch-switch-timeout" ? code : "branch-switch-failed",
|
|
135
|
+
...(typeof detail === "string" ? { detail } : {}) } });
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
99
140
|
if (!options.updates || !url.pathname.startsWith("/api/v1/updates")) return false;
|
|
100
141
|
const updates = options.updates;
|
|
101
142
|
if (request.method === "GET" && url.pathname === "/api/v1/updates") {
|
|
@@ -8,7 +8,7 @@ import { redactSensitiveText } from "../../piagent-core/extensions/redaction-cor
|
|
|
8
8
|
// and a bounded result preview. Every string is ANSI-stripped and redacted;
|
|
9
9
|
// raw arguments and results never leave the server.
|
|
10
10
|
export type ToolKind = "read" | "write" | "edit" | "command" | "search" | "list" | "web-search" | "web-fetch"
|
|
11
|
-
| "subagent" | "network" | "git" | "plan" | "other";
|
|
11
|
+
| "subagent" | "network" | "docker" | "git" | "plan" | "other";
|
|
12
12
|
export type ToolSummary = { kind: ToolKind; target: string | null; detail: string | null };
|
|
13
13
|
export type ToolChange = { added: number; removed: number; preview: string; truncated: boolean };
|
|
14
14
|
export type ToolResult = { text: string; truncated: boolean; isError: boolean };
|
|
@@ -139,6 +139,8 @@ export function summarizeToolCall(name: unknown, input: unknown, cwd?: string):
|
|
|
139
139
|
}
|
|
140
140
|
case "run_with_network":
|
|
141
141
|
return { summary: { kind: "network", target: commandParts(args.command, cwd).target, detail: clean(args.reason, DETAIL_CHARS) } };
|
|
142
|
+
case "run_with_docker":
|
|
143
|
+
return { summary: { kind: "docker", target: commandParts(args.command, cwd).target, detail: clean(args.reason, DETAIL_CHARS) } };
|
|
142
144
|
case "grep": case "find": {
|
|
143
145
|
const where = [displayPath(args.path, cwd), clean(args.glob, 120)].filter(Boolean).join(" · ");
|
|
144
146
|
return { summary: { kind: "search", target: clean(args.pattern, TARGET_CHARS), detail: where || null } };
|
|
@@ -5,6 +5,7 @@ import { looksLikeCompletionClaim } from "../../piagent-core/runtime/session/com
|
|
|
5
5
|
import { isLightweightNonAuthorizingChangeLanguage } from "../../piagent-core/runtime/workflows/change-clarification.ts";
|
|
6
6
|
import { workflowCommandPattern } from "../../piagent-core/runtime/workflows/webui-workflow.ts";
|
|
7
7
|
import { hasVisibleText } from "../shared/text-visibility.ts";
|
|
8
|
+
import { turnEndOf, type TurnEnd } from "../shared/turn-end.ts";
|
|
8
9
|
import { effectiveStopReason } from "./message-stop-reason.ts";
|
|
9
10
|
import { WEBUI_MESSAGE_CORRELATION_ENTRY_TYPE, webUiMessageCorrelationEntry,
|
|
10
11
|
type WebUiMessageCorrelation } from "../shared/message-correlation.ts";
|
|
@@ -33,11 +34,11 @@ type TranscriptItem = { messageRef: string; parentMessageRef: string | null; rol
|
|
|
33
34
|
agentOperationId: string | null; messageRequestId?: string; turnIndex: number | null; content: TranscriptContent;
|
|
34
35
|
attachments?: TranscriptAttachment[];
|
|
35
36
|
toolCalls: Array<{ toolCallRef: string; toolName: string; state: "requested" | "completed" | "failed" | "unknown";
|
|
36
|
-
summary?: ToolSummary; change?: ToolChange; result?: ToolResult }>; usage?: TurnUsage; model?: string; process?: HarnessProcess };
|
|
37
|
+
summary?: ToolSummary; change?: ToolChange; result?: ToolResult }>; usage?: TurnUsage; model?: string; process?: HarnessProcess; turnEnd?: TurnEnd };
|
|
37
38
|
type HarnessFinding = { severity: "blocking" | "major" | "minor"; file: string; line: number | null; issue: string };
|
|
38
39
|
type HarnessIssue = { kind: "wrong_premise" | "conflicts_with_request" | "ambiguous" | "out_of_scope"; detail: string };
|
|
39
|
-
type HarnessProcess = { phase: "plan" | "verify" | "review" | "final" | "objection" | "answer" | "claims" | "rejudge" | "dispute"; outcome?: string; loop?: number; maxLoops?: number;
|
|
40
|
-
verified?: boolean; reviewed?: boolean; blockingOpen?: number; planOpen?: number; planSkipped?: true; verifyPolicy?: string; reviewPolicy?: string; reviewUnavailable?: string;
|
|
40
|
+
type HarnessProcess = { phase: "plan" | "verify" | "review" | "final" | "objection" | "answer" | "claims" | "rejudge" | "dispute" | "continue"; outcome?: string; loop?: number; maxLoops?: number;
|
|
41
|
+
verified?: boolean; reviewed?: boolean; blockingOpen?: number; planOpen?: number; planDone?: number; planSteps?: number; round?: number; maxRounds?: number; idle?: number; planSkipped?: true; verifyPolicy?: string; reviewPolicy?: string; reviewUnavailable?: string;
|
|
41
42
|
findings?: HarnessFinding[]; role?: "scout" | "research" | "verify" | "review"; by?: "main" | "harness"; disputes?: number; unchanged?: true; issues?: HarnessIssue[]; claims?: { claim: string }[] };
|
|
42
43
|
export type TranscriptDocument = { schemaVersion: 1; version: "piagent-webui-transcript-v1"; generatedAt: string; identity: TranscriptIdentity;
|
|
43
44
|
revision: TranscriptRevision; eventCursor: string; state: "ready" | "unavailable"; items: TranscriptItem[];
|
|
@@ -173,7 +174,8 @@ function toolCalls(message: any, sessionRef: string, role: TranscriptItem["role"
|
|
|
173
174
|
// agent before a code-changing turn ended, and how that turn ended.
|
|
174
175
|
const PROCESS_OUTCOMES = ["no_change", "interrupted", "disputed", "blocking_open", "unverified", "review_unavailable", "unreviewed", "clean"];
|
|
175
176
|
// A helper's objection to its brief, a failed claim, a disagreement handed to the member.
|
|
176
|
-
|
|
177
|
+
// "continue": the harness sent the agent back to a checklist with open steps.
|
|
178
|
+
const PROCESS_PHASES = ["plan", "verify", "review", "final", "objection", "answer", "claims", "rejudge", "dispute", "continue"];
|
|
177
179
|
const ISSUE_KINDS = ["wrong_premise", "conflicts_with_request", "ambiguous", "out_of_scope"], HELPER_ROLES = ["scout", "research", "verify", "review"];
|
|
178
180
|
const POLICY_MODES = ["off", "suggest", "require"];
|
|
179
181
|
function harnessProcess(details: any): HarnessProcess | null {
|
|
@@ -187,6 +189,12 @@ function harnessProcess(details: any): HarnessProcess | null {
|
|
|
187
189
|
if (typeof details.reviewed === "boolean") out.reviewed = details.reviewed;
|
|
188
190
|
if (Number.isInteger(details.blockingOpen) && details.blockingOpen >= 0) out.blockingOpen = Math.min(details.blockingOpen, 1000);
|
|
189
191
|
if (Number.isInteger(details.planOpen) && details.planOpen >= 1 && details.planOpen <= 30) out.planOpen = details.planOpen;
|
|
192
|
+
const upTo = (n: unknown, low: number, high: number): n is number => Number.isInteger(n) && (n as number) >= low && (n as number) <= high;
|
|
193
|
+
if (upTo(details.planDone, 0, 30)) out.planDone = details.planDone;
|
|
194
|
+
if (upTo(details.planSteps, 1, 30)) out.planSteps = details.planSteps;
|
|
195
|
+
if (upTo(details.round, 1, 30)) out.round = details.round;
|
|
196
|
+
if (upTo(details.maxRounds, 1, 30)) out.maxRounds = details.maxRounds;
|
|
197
|
+
if (upTo(details.idle, 1, 2)) out.idle = details.idle;
|
|
190
198
|
if (details.planSkipped === true) out.planSkipped = true;
|
|
191
199
|
if (POLICY_MODES.includes(details.policy?.verify)) out.verifyPolicy = details.policy.verify;
|
|
192
200
|
if (POLICY_MODES.includes(details.policy?.review)) out.reviewPolicy = details.policy.review;
|
|
@@ -262,6 +270,14 @@ function correlatedTranscriptItems(entries: unknown[], identity: TranscriptIdent
|
|
|
262
270
|
pending = webUiMessageCorrelationEntry(entry);
|
|
263
271
|
continue;
|
|
264
272
|
}
|
|
273
|
+
// How the member's message ended: an entry, not a message the model reads.
|
|
274
|
+
if (entry?.type === "custom" && entry.customType === "agent-watch-turn-end") {
|
|
275
|
+
const turnEnd = turnEndOf(entry.data);
|
|
276
|
+
const projected = turnEnd && item({ ...entry, type: "message", message: { role: "custom", content: "" } }, identity, true);
|
|
277
|
+
if (projected) values.push({ entry, item: { ...projected, turnEnd, ...(turn ? { agentOperationId: turn.operationRef, messageRequestId: turn.messageRequestId } : {}) },
|
|
278
|
+
cursor: opaque("transcript", [identity.sessionRef, entry.id]) });
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
265
281
|
if (entry?.type === "custom_message" && entry.customType === "agent-watch-process") {
|
|
266
282
|
const process = harnessProcess(entry.details);
|
|
267
283
|
const projected = process && item({ ...entry, type: "message", message: { role: "custom", content: entry.content } }, identity, true);
|
|
@@ -4,7 +4,7 @@ import { failureRoleText, type CopyLocale, type FailureRole } from "./company-co
|
|
|
4
4
|
|
|
5
5
|
export type ProcessCopyInput = {
|
|
6
6
|
phase?: string; outcome?: string; loop?: number; maxLoops?: number; verified?: boolean; reviewed?: boolean; blockingOpen?: number;
|
|
7
|
-
planOpen?: number; planSkipped?: boolean; verifyPolicy?: string; reviewPolicy?: string; reviewUnavailable?: string; role?: FailureRole;
|
|
7
|
+
planOpen?: number; planDone?: number; planSteps?: number; round?: number; maxRounds?: number; idle?: number; planSkipped?: boolean; verifyPolicy?: string; reviewPolicy?: string; reviewUnavailable?: string; role?: FailureRole;
|
|
8
8
|
disputes?: number; unchanged?: boolean;
|
|
9
9
|
findings?: Array<{ severity: string; file?: string; line?: number | null; issue: string }>;
|
|
10
10
|
claims?: Array<{ claim: string }>; issues?: Array<{ kind: string; detail: string }>;
|
|
@@ -41,6 +41,8 @@ export function headlineText(process: ProcessCopyInput, locale: CopyLocale): str
|
|
|
41
41
|
const round = process.loop && process.maxLoops ? pick(locale, ` (vòng ${process.loop}/${process.maxLoops})`, ` (round ${process.loop}/${process.maxLoops})`) : "";
|
|
42
42
|
const who = failureRoleText(process.role ?? "review", locale), count = (process.findings ?? process.claims ?? process.issues ?? []).length;
|
|
43
43
|
switch (process.phase) {
|
|
44
|
+
case "continue": return pick(locale, `Harness: checklist còn ${process.planOpen ?? 0} bước (${process.planDone ?? 0}/${process.planSteps ?? 0} xong), cho main agent làm tiếp · vòng ${process.round ?? 1}${process.idle ? " (vòng trước không xong thêm bước nào)" : ""}`,
|
|
45
|
+
`Harness: the checklist has ${process.planOpen ?? 0} open step(s) (${process.planDone ?? 0}/${process.planSteps ?? 0} done); sent the main agent on · round ${process.round ?? 1}${process.idle ? " (the previous round finished no step)" : ""}`);
|
|
44
46
|
case "plan": return pick(locale, `Harness: plan còn ${process.planOpen ?? 0} bước chưa đánh dấu xong, yêu cầu agent cập nhật`, `Harness: ${process.planOpen ?? 0} plan step(s) not marked completed; asked the agent to update the plan`);
|
|
45
47
|
case "verify": return pick(locale, `Harness: chưa có check nào pass trên code hiện tại, yêu cầu agent chạy check${round}`, `Harness: no check has passed on the current code; asked the agent to run checks${round}`);
|
|
46
48
|
case "review": return pick(locale, `Harness: review tìm thấy ${count} lỗi blocking, gửi lại agent để fix hoặc giải thích${round}`, `Harness: the review found ${count} blocking issue(s); sent back to the agent to fix or answer${round}`);
|