@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.
Files changed (98) hide show
  1. package/CHANGELOG.md +33 -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 +13 -0
  17. package/docs-site/content/index.html +4 -4
  18. package/docs-site/content/whats-new.html +13 -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 +14 -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 +14 -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/call-purpose.mjs +3 -2
  60. package/packages/piagent-core/managed/command-tools.mjs +59 -0
  61. package/packages/piagent-core/managed/container-engine.mjs +102 -0
  62. package/packages/piagent-core/managed/continuation.mjs +131 -0
  63. package/packages/piagent-core/managed/doctor.mjs +7 -0
  64. package/packages/piagent-core/managed/language-environment.mjs +57 -0
  65. package/packages/piagent-core/managed/linux-sandbox.mjs +3 -1
  66. package/packages/piagent-core/managed/native-catalog.mjs +14 -1
  67. package/packages/piagent-core/managed/patch-snapshot.mjs +25 -9
  68. package/packages/piagent-core/managed/repository-operation.mjs +20 -0
  69. package/packages/piagent-core/managed/request-stream.mjs +28 -0
  70. package/packages/piagent-core/managed/review-snapshot-worker.mjs +11 -4
  71. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +6 -1
  72. package/packages/piagent-core/managed/session.mjs +52 -63
  73. package/packages/piagent-core/managed/tool-boundary.mjs +21 -10
  74. package/packages/piagent-core/managed/tool-worker.mjs +3 -3
  75. package/packages/piagent-core/managed/workflow.mjs +20 -20
  76. package/packages/piagent-core/package.json +1 -1
  77. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-CeQ79h30.js → MarkdownMessage-nggiOt0N.js} +1 -1
  78. package/packages/piagent-webui/dist/client/assets/{index-BNQK7rHr.js → index-CM9cs-gP.js} +16 -14
  79. package/packages/piagent-webui/dist/client/index.html +1 -1
  80. package/packages/piagent-webui/gateway/company-relay.ts +14 -2
  81. package/packages/piagent-webui/gateway/gateway-service.ts +38 -0
  82. package/packages/piagent-webui/gateway/git-branches.ts +94 -0
  83. package/packages/piagent-webui/gateway/git-repositories.ts +46 -0
  84. package/packages/piagent-webui/gateway/managed-projection.ts +9 -2
  85. package/packages/piagent-webui/gateway/project-registry.ts +10 -6
  86. package/packages/piagent-webui/gateway/session-catalog.ts +13 -6
  87. package/packages/piagent-webui/gateway/session-operation-lifecycle.ts +10 -3
  88. package/packages/piagent-webui/server/loopback-server.ts +9 -2
  89. package/packages/piagent-webui/server/member-routes.ts +41 -0
  90. package/packages/piagent-webui/server/tool-call-summary.ts +3 -1
  91. package/packages/piagent-webui/server/transcript-projection.ts +20 -4
  92. package/packages/piagent-webui/shared/process-copy.ts +3 -1
  93. package/packages/piagent-webui/shared/turn-end.ts +68 -0
  94. package/schemas/piagent-webui/session-catalog-v1.schema.json +11 -1
  95. package/schemas/piagent-webui/transcript-v1.schema.json +23 -2
  96. package/scripts/company-terminal.mjs +15 -0
  97. package/scripts/configure-model-scope.sh +1 -0
  98. 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-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
- return { ...row, projectRef: folder ? this.#hubProject(folder) : row.projectRef, composerAvailable: ready && row.composerAvailable };
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])).digest("hex");
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
- if (!existing) {
82
- if (current.projects.length >= MAX_PROJECTS) throw new Error("project-registry-limit");
83
- current.projects.push({ projectRef, cwd, label: cleanLabel(path.basename(cwd)), importedAt: now.toISOString() });
84
- this.#write(current);
85
- }
86
- const item = existing ?? current.projects.at(-1)!;
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. 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, 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, 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, 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
- const PROCESS_PHASES = ["plan", "verify", "review", "final", "objection", "answer", "claims", "rejudge", "dispute"];
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}`);