@piagent/platform 1.18.0 → 1.20.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 (93) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +1 -1
  3. package/README.vi.md +1 -1
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +6 -6
  6. package/docs/languages.json +2 -2
  7. package/docs/operator-manual-vietnamese.md +7 -7
  8. package/docs/publishing-for-teams.md +2 -0
  9. package/docs/release-install-policy.md +2 -2
  10. package/docs/team-onboarding.md +2 -2
  11. package/docs-site/architecture.html +1 -1
  12. package/docs-site/benchmark.html +1 -1
  13. package/docs-site/commands.html +1 -1
  14. package/docs-site/content/en/index.html +4 -4
  15. package/docs-site/content/en/whats-new.html +15 -0
  16. package/docs-site/content/index.html +4 -4
  17. package/docs-site/content/whats-new.html +15 -0
  18. package/docs-site/context-engine.html +1 -1
  19. package/docs-site/ecosystem.html +1 -1
  20. package/docs-site/en/architecture.html +1 -1
  21. package/docs-site/en/benchmark.html +1 -1
  22. package/docs-site/en/commands.html +1 -1
  23. package/docs-site/en/context-engine.html +1 -1
  24. package/docs-site/en/ecosystem.html +1 -1
  25. package/docs-site/en/index.html +5 -5
  26. package/docs-site/en/mcp-approval.html +1 -1
  27. package/docs-site/en/mcp-auth.html +1 -1
  28. package/docs-site/en/mcp-servers.html +1 -1
  29. package/docs-site/en/mcp.html +1 -1
  30. package/docs-site/en/permissions.html +1 -1
  31. package/docs-site/en/profiles.html +1 -1
  32. package/docs-site/en/quickstart.html +1 -1
  33. package/docs-site/en/runtime.html +1 -1
  34. package/docs-site/en/scope.html +1 -1
  35. package/docs-site/en/security.html +1 -1
  36. package/docs-site/en/team.html +1 -1
  37. package/docs-site/en/whats-new.html +16 -1
  38. package/docs-site/en/windows.html +1 -1
  39. package/docs-site/en/workflows.html +1 -1
  40. package/docs-site/index.html +5 -5
  41. package/docs-site/mcp-approval.html +1 -1
  42. package/docs-site/mcp-auth.html +1 -1
  43. package/docs-site/mcp-servers.html +1 -1
  44. package/docs-site/mcp.html +1 -1
  45. package/docs-site/permissions.html +1 -1
  46. package/docs-site/profiles.html +1 -1
  47. package/docs-site/quickstart.html +1 -1
  48. package/docs-site/runtime.html +1 -1
  49. package/docs-site/scope.html +1 -1
  50. package/docs-site/security.html +1 -1
  51. package/docs-site/team.html +1 -1
  52. package/docs-site/whats-new.html +16 -1
  53. package/docs-site/windows.html +1 -1
  54. package/docs-site/workflows.html +1 -1
  55. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  56. package/package.json +1 -1
  57. package/packages/piagent-core/managed/call-purpose.mjs +3 -2
  58. package/packages/piagent-core/managed/command-tools.mjs +56 -0
  59. package/packages/piagent-core/managed/container-engine.mjs +68 -0
  60. package/packages/piagent-core/managed/continuation.mjs +131 -0
  61. package/packages/piagent-core/managed/doctor.mjs +12 -0
  62. package/packages/piagent-core/managed/helper-roles.mjs +1 -1
  63. package/packages/piagent-core/managed/helper-run.mjs +24 -6
  64. package/packages/piagent-core/managed/language-environment.mjs +57 -0
  65. package/packages/piagent-core/managed/linux-sandbox.mjs +6 -1
  66. package/packages/piagent-core/managed/package-proxy.mjs +181 -0
  67. package/packages/piagent-core/managed/patch-snapshot.mjs +53 -9
  68. package/packages/piagent-core/managed/repository-operation.mjs +20 -0
  69. package/packages/piagent-core/managed/review-snapshot-worker.mjs +29 -4
  70. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +7 -2
  71. package/packages/piagent-core/managed/session.mjs +60 -67
  72. package/packages/piagent-core/managed/tool-boundary.mjs +70 -11
  73. package/packages/piagent-core/managed/tool-worker.mjs +18 -3
  74. package/packages/piagent-core/managed/workflow.mjs +20 -20
  75. package/packages/piagent-core/package.json +1 -1
  76. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-DlbuyiQ3.js → MarkdownMessage-nggiOt0N.js} +1 -1
  77. package/packages/piagent-webui/dist/client/assets/{index-BGxsZHB5.js → index-CM9cs-gP.js} +16 -14
  78. package/packages/piagent-webui/dist/client/index.html +1 -1
  79. package/packages/piagent-webui/gateway/company-relay.ts +14 -2
  80. package/packages/piagent-webui/gateway/gateway-service.ts +20 -8
  81. package/packages/piagent-webui/gateway/git-repositories.ts +46 -0
  82. package/packages/piagent-webui/gateway/managed-projection.ts +9 -2
  83. package/packages/piagent-webui/gateway/project-registry.ts +10 -6
  84. package/packages/piagent-webui/gateway/session-catalog.ts +13 -6
  85. package/packages/piagent-webui/server/loopback-server.ts +1 -1
  86. package/packages/piagent-webui/server/member-routes.ts +6 -3
  87. package/packages/piagent-webui/server/tool-call-summary.ts +3 -1
  88. package/packages/piagent-webui/server/transcript-projection.ts +20 -4
  89. package/packages/piagent-webui/shared/process-copy.ts +3 -1
  90. package/packages/piagent-webui/shared/turn-end.ts +68 -0
  91. package/schemas/piagent-webui/session-catalog-v1.schema.json +11 -1
  92. package/schemas/piagent-webui/transcript-v1.schema.json +23 -2
  93. package/scripts/company-terminal.mjs +15 -0
@@ -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-BGxsZHB5.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
  }
@@ -21,6 +21,7 @@ import {
21
21
  import { buildSessionCatalog, projectRefForCwd } from "./session-catalog.ts";
22
22
  import { suggestPaths } from "./path-suggestions.ts";
23
23
  import { GitBranchError, listBranches, switchBranch, type SwitchRequest } from "./git-branches.ts";
24
+ import { childRepositories, childRepositoryFolder } from "./git-repositories.ts";
24
25
  import { listAgentCommands } from "../../piagent-core/runtime/resources/agent-resources.mjs";
25
26
  import { sessionRefForPath } from "../ownership/session-refs.ts";
26
27
  import { SessionMetadataStore } from "./session-metadata-store.ts";
@@ -309,25 +310,36 @@ export async function startPiagentGateway(options: {
309
310
  // The project's Git branches and a switch between them. A switch waits
310
311
  // until no conversation in the folder runs (company ones included): the
311
312
  // agent would find its files changed under it.
312
- listBranches: async (projectRef) => {
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) => {
313
316
  const root = await projectFolder(projectRef);
314
317
  if (!root) return null;
315
- const [list, running] = await Promise.all([listBranches(root), runningInProject(projectRef)]);
316
- return list ? { ...list, running } : { repository: false, running };
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 };
317
327
  },
318
328
  switchBranch: async (projectRef, request) => {
319
329
  const root = await projectFolder(projectRef);
320
330
  if (!root) return null;
321
- const value = request as Partial<SwitchRequest> | null;
331
+ const value = request as (Partial<SwitchRequest> & { repository?: string }) | null;
322
332
  if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.branch !== "string"
323
- || Object.keys(value).some((name) => !["branch", "create", "remote"].includes(name))
333
+ || Object.keys(value).some((name) => !["branch", "create", "remote", "repository"].includes(name))
334
+ || (value.repository !== undefined && typeof value.repository !== "string")
324
335
  || (value.create !== undefined && typeof value.create !== "boolean")
325
336
  || (value.remote !== undefined && value.remote !== null && typeof value.remote !== "string")) throw new GitBranchError("branch-request-invalid");
326
- if (!(await listBranches(root))) throw new GitBranchError("not-a-git-repository");
337
+ const folder = value.repository === undefined ? root : childRepositoryFolder(root, value.repository);
338
+ if (!folder || !(await listBranches(folder))) throw new GitBranchError("not-a-git-repository");
327
339
  if (await runningInProject(projectRef) > 0) throw new GitBranchError("branch-switch-blocked-running");
328
- const head = await switchBranch(root, { branch: value.branch, create: value.create === true, remote: value.remote ?? null });
340
+ const head = await switchBranch(folder, { branch: value.branch, create: value.create === true, remote: value.remote ?? null });
329
341
  events.publish("catalog.changed", { reasonCode: "git-branch-switched" });
330
- return { head, branches: await listBranches(root) };
342
+ return { head, branches: await listBranches(folder) };
331
343
  },
332
344
  updates: { status: () => updates!.status(), check: () => updates!.check(), apply: (request) => updates!.apply(request) },
333
345
  answerSessionQuestion: (sessionRef, questionRef, answer) => company(sessionRef)
@@ -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 {
@@ -72,7 +72,7 @@ export async function startLoopbackServer(options: {
72
72
  answerSessionQuestion?: (sessionRef: string, questionRef: string, answer: unknown) => unknown | Promise<unknown>;
73
73
  suggestPaths?: (projectRef: string, query: string) => unknown | Promise<unknown>;
74
74
  listCommands?: (projectRef: string) => unknown | Promise<unknown>;
75
- listBranches?: (projectRef: string) => unknown | Promise<unknown>;
75
+ listBranches?: (projectRef: string, repository: string | null) => unknown | Promise<unknown>;
76
76
  switchBranch?: (projectRef: string, request: unknown) => unknown | Promise<unknown>;
77
77
  updates?: UpdateRoutes;
78
78
  bootstrapTtlMs?: number;
@@ -37,7 +37,7 @@ type Options = {
37
37
  listCommands?: (projectRef: string) => unknown | Promise<unknown>;
38
38
  // The project's Git branches, and a switch to one of them (or a new one);
39
39
  // null as above.
40
- listBranches?: (projectRef: string) => unknown | Promise<unknown>;
40
+ listBranches?: (projectRef: string, repository: string | null) => unknown | Promise<unknown>;
41
41
  switchBranch?: (projectRef: string, request: unknown) => unknown | Promise<unknown>;
42
42
  updates?: UpdateRoutes;
43
43
  };
@@ -111,9 +111,12 @@ export async function routeMemberRequest(context: Context, options: Options): Pr
111
111
  const projectRef = decodeURIComponent(branches[1]);
112
112
  if (request.method === "GET") {
113
113
  if (!auth.authenticate(request)) { errorResponse(response, 401, "authentication-required"); return true; }
114
- if (!CURSOR.test(projectRef)) { errorResponse(response, 400, "invalid-project-ref"); return true; }
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
+ }
115
118
  try {
116
- const value = await options.listBranches!(projectRef);
119
+ const value = await options.listBranches!(projectRef, repository);
117
120
  if (value === null) errorResponse(response, 404, "project-not-found"); else jsonResponse(response, 200, value);
118
121
  } catch { errorResponse(response, 503, "branches-unavailable"); }
119
122
  return true;
@@ -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}`);
@@ -0,0 +1,68 @@
1
+ // How a member's message ended in a company conversation ("agent-watch-turn-end"),
2
+ // read the same way by the transcript, the session list and the company
3
+ // Terminal: every checklist step done, stopped with steps open, failed (which
4
+ // harness role), or stopped by the member.
5
+ import { failureRoleText, type CopyLocale, type FailureRole } from "./company-copy.ts";
6
+
7
+ export type TurnEndState = "done" | "midway" | "failed" | "cancelled";
8
+ export type TurnEnd = { state: TurnEndState; planSteps: number; planDone: number; planDeferred?: number; rounds: number;
9
+ reason?: "idle" | "limit" | "policy"; role?: FailureRole; code?: string };
10
+ const ROLES: FailureRole[] = ["main", "scout", "research", "verify", "review"];
11
+ // The harness gives up after this many rounds in a row without progress, and
12
+ // runs at most this many (packages/piagent-core/managed/continuation.mjs).
13
+ export const IDLE_ROUNDS = 2, MAX_ROUNDS = 30;
14
+
15
+ // The recorded details, checked; null when they do not have the expected shape.
16
+ export function turnEndOf(details: any): TurnEnd | null {
17
+ const count = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 0 && (n as number) <= 30;
18
+ if (!["done", "midway", "failed", "cancelled"].includes(details?.state) || !count(details.planSteps) || !count(details.planDone)
19
+ || !count(details.rounds) || (details.planDeferred !== undefined && !count(details.planDeferred))
20
+ || details.planDone + (details.planDeferred ?? 0) > details.planSteps) return null;
21
+ const out: TurnEnd = { state: details.state, planSteps: details.planSteps, planDone: details.planDone, rounds: details.rounds };
22
+ if (details.planDeferred) out.planDeferred = details.planDeferred;
23
+ if (details.state === "midway" && ["idle", "limit", "policy"].includes(details.reason)) out.reason = details.reason;
24
+ if (details.state === "failed") {
25
+ out.role = ROLES.includes(details.role) ? details.role : "main";
26
+ if (typeof details.code === "string" && /^[a-z0-9_]{1,64}$/.test(details.code)) out.code = details.code;
27
+ }
28
+ return out;
29
+ }
30
+
31
+ const pick = (locale: CopyLocale, vi: string, en: string) => locale === "vi" ? vi : en;
32
+ export type TurnEndCopy = { tone: "success" | "warning" | "error" | "info"; title: string; text: string; continuable: boolean };
33
+
34
+ // What the member reads under the last answer: is the task finished, and if
35
+ // not, why the agent stopped and what to do.
36
+ export function turnEndCopy(end: TurnEnd, locale: CopyLocale): TurnEndCopy {
37
+ const later = end.planDeferred ?? 0, open = end.planSteps - end.planDone - later;
38
+ const rounds = end.rounds ? pick(locale, ` Harness đã cho agent làm tiếp ${end.rounds} vòng.`, ` The harness sent the agent back ${end.rounds} time(s).`) : "";
39
+ if (end.state === "cancelled") return { tone: "info", continuable: true, title: pick(locale, "Bạn đã dừng lượt này", "You stopped this turn"),
40
+ text: pick(locale, `Task chưa xong${end.planSteps ? ` (${end.planDone}/${end.planSteps} bước)` : ""}. Bấm Tiếp tục để agent làm tiếp từ chỗ dừng.`,
41
+ `The task is not finished${end.planSteps ? ` (${end.planDone}/${end.planSteps} steps)` : ""}. Choose Continue to pick up where it stopped.`) };
42
+ if (end.state === "failed") return { tone: "error", continuable: true,
43
+ title: pick(locale, `Lượt dừng vì lỗi · ${failureRoleText(end.role ?? "main", locale)}`, `Turn stopped by a failure · ${failureRoleText(end.role ?? "main", locale)}`),
44
+ text: pick(locale, `Task chưa xong${end.planSteps ? `: còn ${open}/${end.planSteps} bước` : ""}. Xem lỗi phía trên; khi đã xử lý, bấm Tiếp tục để chạy lại từ chỗ dừng.`,
45
+ `The task is not finished${end.planSteps ? `: ${open} of ${end.planSteps} steps open` : ""}. See the failure above; once it is resolved, choose Continue.`) + (end.code ? ` [${end.code}]` : "") };
46
+ if (end.state === "midway") {
47
+ const why = end.reason === "idle" ? pick(locale, `Main agent dừng ${IDLE_ROUNDS} vòng liên tiếp mà không xong thêm bước nào: đọc câu trả lời cuối để biết vướng gì.`,
48
+ `The main agent stopped ${IDLE_ROUNDS} rounds in a row without finishing a step: its last answer says what is in the way.`)
49
+ : end.reason === "limit" ? pick(locale, `Đã tới giới hạn ${MAX_ROUNDS} vòng tự làm tiếp cho một tin nhắn.`, `The limit of ${MAX_ROUNDS} automatic rounds for one message was reached.`)
50
+ : pick(locale, "Harness của team không bắt buộc plan nên không tự làm tiếp.", "The team's Harness does not require a plan, so it does not continue on its own.");
51
+ return { tone: "warning", continuable: true, title: pick(locale, `Dừng giữa chừng · còn ${open}/${end.planSteps} bước`, `Stopped midway · ${open} of ${end.planSteps} steps open`),
52
+ text: `${why}${rounds} ${pick(locale, "Bấm Tiếp tục để làm tiếp.", "Choose Continue to go on.")}` };
53
+ }
54
+ // Steps left for later at the member's word (or no longer needed): done as asked, not all done.
55
+ if (later) return { tone: "info", continuable: true, title: pick(locale, `Dừng theo yêu cầu · ${end.planDone}/${end.planSteps} bước xong, ${later} bước để sau`, `Stopped as asked · ${end.planDone} of ${end.planSteps} steps done, ${later} left for later`),
56
+ text: pick(locale, "Các bước còn lại được để sau theo yêu cầu hoặc vì không cần nữa: câu trả lời cuối nói lý do. Bấm Tiếp tục khi muốn làm nốt.", "The other steps were left for later as asked, or are no longer needed: the last answer says why. Choose Continue to do them.") + rounds };
57
+ return { tone: "success", continuable: false, title: end.planSteps ? pick(locale, `Hoàn thành · ${end.planSteps}/${end.planSteps} bước`, `Done · ${end.planSteps}/${end.planSteps} steps`) : pick(locale, "Xong lượt", "Turn finished"),
58
+ text: (end.planSteps ? pick(locale, "Mọi bước trong checklist đã xong.", "Every checklist step is completed.") : pick(locale, "Main agent đã trả lời xong; lượt này không có checklist.", "The main agent finished its answer; this turn had no checklist.")) + rounds };
59
+ }
60
+
61
+ // The short label of the session list.
62
+ export function turnEndShort(end: Pick<TurnEnd, "state" | "planSteps" | "planDone" | "planDeferred">, locale: CopyLocale): string {
63
+ if (end.state === "done" && end.planDeferred) return pick(locale, `Xong ${end.planDone}/${end.planSteps}, để sau ${end.planDeferred}`, `Done ${end.planDone}/${end.planSteps}, ${end.planDeferred} later`);
64
+ if (end.state === "midway") return pick(locale, `Dừng giữa chừng ${end.planDone}/${end.planSteps}`, `Stopped ${end.planDone}/${end.planSteps}`);
65
+ if (end.state === "failed") return pick(locale, "Lỗi, chưa xong", "Failed");
66
+ if (end.state === "cancelled") return pick(locale, "Đã dừng", "Stopped");
67
+ return end.planSteps ? pick(locale, `Xong ${end.planSteps}/${end.planSteps}`, `Done ${end.planSteps}/${end.planSteps}`) : pick(locale, "Xong", "Done");
68
+ }
@@ -95,13 +95,23 @@
95
95
  "managedThinkingLevels": {"type": "array", "uniqueItems": true, "maxItems": 7, "items": {"enum": ["off","minimal","low","medium","high","xhigh","max"]}}, "managedPermission": {"enum": ["workspace-write","trusted-full-access"]},
96
96
  "managedPlan": { "type": "object", "additionalProperties": false, "required": ["steps"], "properties": {
97
97
  "steps": { "type": "array", "minItems": 1, "maxItems": 30, "items": { "type": "object", "additionalProperties": false, "required": ["step", "status"], "properties": {
98
- "step": { "type": "string", "minLength": 1, "maxLength": 200 }, "status": { "enum": ["pending", "in_progress", "completed"] } } } } } },
98
+ "step": { "type": "string", "minLength": 1, "maxLength": 200 }, "status": { "enum": ["pending", "in_progress", "completed", "deferred"] } } } } } },
99
99
  "managedProcess": { "type": "object", "additionalProperties": false, "required": ["outcome", "verified", "reviewed", "blockingOpen"], "properties": {
100
100
  "outcome": { "enum": ["no_change", "interrupted", "disputed", "blocking_open", "unverified", "review_unavailable", "unreviewed", "clean"] },
101
101
  "verified": { "type": "boolean" }, "reviewed": { "type": "boolean" }, "blockingOpen": { "type": "integer", "minimum": 0, "maximum": 1000 }, "planSkipped": { "const": true } } },
102
+ "managedTurnEnd": { "type": "object", "additionalProperties": false, "required": ["state", "planSteps", "planDone"], "properties": {
103
+ "state": { "enum": ["done", "midway", "failed", "cancelled"] },
104
+ "planSteps": { "type": "integer", "minimum": 0, "maximum": 30 }, "planDone": { "type": "integer", "minimum": 0, "maximum": 30 },
105
+ "planDeferred": { "type": "integer", "minimum": 1, "maximum": 30 },
106
+ "reason": { "enum": ["idle", "limit", "policy"] } } },
102
107
  "gitBranch": { "type": "object", "additionalProperties": false, "required": ["name", "detached"], "properties": {
103
108
  "name": { "type": "string", "minLength": 1, "maxLength": 200, "pattern": "^[^\\u0000-\\u001F\\u007F]*$" },
104
109
  "detached": { "type": "boolean" } } },
110
+ "gitRepositories": { "type": "array", "maxItems": 30, "items": { "type": "object", "additionalProperties": false, "required": ["name", "branch"], "properties": {
111
+ "name": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[^\\u0000-\\u001F\\u007F/\\\\]*$" },
112
+ "branch": { "anyOf": [{ "type": "null" }, { "type": "object", "additionalProperties": false, "required": ["name", "detached"], "properties": {
113
+ "name": { "type": "string", "minLength": 1, "maxLength": 200, "pattern": "^[^\\u0000-\\u001F\\u007F]*$" },
114
+ "detached": { "type": "boolean" } } }] } } } },
105
115
  "modelLabel": { "$ref": "common-v1.schema.json#/$defs/nullableDisplayText" },
106
116
  "thinkingLevel": { "enum": ["off", "minimal", "low", "medium", "high", "xhigh", "max", "unknown"] },
107
117
  "contextUsage": { "$ref": "#/$defs/contextUsage" },
@@ -157,7 +157,7 @@
157
157
  "additionalProperties": false,
158
158
  "required": ["phase"],
159
159
  "properties": {
160
- "phase": { "enum": ["plan", "verify", "review", "final", "objection", "answer", "claims", "rejudge", "dispute"] },
160
+ "phase": { "enum": ["plan", "verify", "review", "final", "objection", "answer", "claims", "rejudge", "dispute", "continue"] },
161
161
  "outcome": { "enum": ["no_change", "interrupted", "disputed", "blocking_open", "unverified", "review_unavailable", "unreviewed", "clean"] },
162
162
  "role": { "enum": ["scout", "research", "verify", "review"] },
163
163
  "by": { "enum": ["main", "harness"] },
@@ -192,6 +192,11 @@
192
192
  "reviewed": { "type": "boolean" },
193
193
  "blockingOpen": { "type": "integer", "minimum": 0, "maximum": 1000 },
194
194
  "planOpen": { "type": "integer", "minimum": 1, "maximum": 30 },
195
+ "planDone": { "type": "integer", "minimum": 0, "maximum": 30 },
196
+ "planSteps": { "type": "integer", "minimum": 1, "maximum": 30 },
197
+ "round": { "type": "integer", "minimum": 1, "maximum": 30 },
198
+ "maxRounds": { "type": "integer", "minimum": 1, "maximum": 30 },
199
+ "idle": { "type": "integer", "minimum": 1, "maximum": 2 },
195
200
  "planSkipped": { "const": true },
196
201
  "verifyPolicy": { "enum": ["off", "suggest", "require"] },
197
202
  "reviewPolicy": { "enum": ["off", "suggest", "require"] },
@@ -213,6 +218,21 @@
213
218
  }
214
219
  }
215
220
  },
221
+ "turnEnd": {
222
+ "type": "object",
223
+ "additionalProperties": false,
224
+ "required": ["state", "planSteps", "planDone", "rounds"],
225
+ "properties": {
226
+ "state": { "enum": ["done", "midway", "failed", "cancelled"] },
227
+ "planSteps": { "type": "integer", "minimum": 0, "maximum": 30 },
228
+ "planDone": { "type": "integer", "minimum": 0, "maximum": 30 },
229
+ "planDeferred": { "type": "integer", "minimum": 1, "maximum": 30 },
230
+ "rounds": { "type": "integer", "minimum": 0, "maximum": 30 },
231
+ "reason": { "enum": ["idle", "limit", "policy"] },
232
+ "role": { "enum": ["main", "scout", "research", "verify", "review"] },
233
+ "code": { "type": "string", "pattern": "^[a-z0-9_]{1,64}$" }
234
+ }
235
+ },
216
236
  "companyFailure": {
217
237
  "type": "object",
218
238
  "additionalProperties": false,
@@ -243,7 +263,7 @@
243
263
  "additionalProperties": false,
244
264
  "required": ["kind", "target", "detail"],
245
265
  "properties": {
246
- "kind": { "enum": ["read", "write", "edit", "command", "search", "list", "web-search", "web-fetch", "subagent", "network", "git", "plan", "other"] },
266
+ "kind": { "enum": ["read", "write", "edit", "command", "search", "list", "web-search", "web-fetch", "subagent", "network", "docker", "git", "plan", "other"] },
247
267
  "target": { "anyOf": [{ "type": "string", "maxLength": 400 }, { "type": "null" }] },
248
268
  "detail": { "anyOf": [{ "type": "string", "maxLength": 400 }, { "type": "null" }] }
249
269
  }
@@ -315,6 +335,7 @@
315
335
  "model": { "type": "string", "minLength": 1, "maxLength": 120, "pattern": "^[A-Za-z0-9._:@/-]{1,120}$" },
316
336
  "failure": { "$ref": "#/$defs/companyFailure" },
317
337
  "process": { "$ref": "#/$defs/harnessProcess" },
338
+ "turnEnd": { "$ref": "#/$defs/turnEnd" },
318
339
  "toolCalls": {
319
340
  "type": "array",
320
341
  "maxItems": 64,