@piagent/platform 1.11.1 → 1.12.1

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 (78) hide show
  1. package/CHANGELOG.md +31 -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 +18 -0
  16. package/docs-site/content/index.html +4 -4
  17. package/docs-site/content/whats-new.html +18 -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 +19 -1
  38. package/docs-site/en/workflows.html +1 -1
  39. package/docs-site/index.html +5 -5
  40. package/docs-site/mcp-approval.html +1 -1
  41. package/docs-site/mcp-auth.html +1 -1
  42. package/docs-site/mcp-servers.html +1 -1
  43. package/docs-site/mcp.html +1 -1
  44. package/docs-site/permissions.html +1 -1
  45. package/docs-site/profiles.html +1 -1
  46. package/docs-site/quickstart.html +1 -1
  47. package/docs-site/runtime.html +1 -1
  48. package/docs-site/scope.html +1 -1
  49. package/docs-site/security.html +1 -1
  50. package/docs-site/team.html +1 -1
  51. package/docs-site/whats-new.html +19 -1
  52. package/docs-site/workflows.html +1 -1
  53. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  54. package/package.json +1 -1
  55. package/packages/piagent-core/extensions/piagent-guard.ts +11 -5
  56. package/packages/piagent-core/extensions/source-checkout-paths.ts +31 -0
  57. package/packages/piagent-core/managed/agent-skills.mjs +44 -0
  58. package/packages/piagent-core/managed/doctor.mjs +23 -2
  59. package/packages/piagent-core/managed/language-environment.mjs +118 -0
  60. package/packages/piagent-core/managed/resource-loader.mjs +5 -4
  61. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +4 -1
  62. package/packages/piagent-core/managed/session.mjs +15 -3
  63. package/packages/piagent-core/managed/tool-boundary.mjs +110 -28
  64. package/packages/piagent-core/managed/tool-worker.mjs +3 -3
  65. package/packages/piagent-core/managed/toolchain.mjs +10 -8
  66. package/packages/piagent-core/package.json +1 -1
  67. package/packages/piagent-core/runtime/registration/agent-resources.ts +12 -0
  68. package/packages/piagent-core/runtime/resources/agent-resources.d.mts +6 -0
  69. package/packages/piagent-core/runtime/resources/agent-resources.mjs +140 -0
  70. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-D70xkf0d.js → MarkdownMessage-FWfOWZjV.js} +1 -1
  71. package/packages/piagent-webui/dist/client/assets/{index-BuIJR5rd.js → index-DFKHLVYk.js} +14 -14
  72. package/packages/piagent-webui/dist/client/index.html +1 -1
  73. package/packages/piagent-webui/gateway/gateway-service.ts +16 -0
  74. package/packages/piagent-webui/gateway/path-suggestions.ts +118 -0
  75. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +3 -1
  76. package/packages/piagent-webui/server/loopback-server.ts +2 -0
  77. package/packages/piagent-webui/server/member-routes.ts +22 -3
  78. package/packages/piagent-webui/server/transcript-projection.ts +7 -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-BuIJR5rd.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-DFKHLVYk.js"></script>
11
11
  <link rel="stylesheet" crossorigin href="/assets/index-Z91z6SlN.css">
12
12
  </head>
13
13
  <body>
@@ -19,6 +19,8 @@ import {
19
19
  type GatewayDescriptor
20
20
  } from "./profile-state.ts";
21
21
  import { buildSessionCatalog, projectRefForCwd } from "./session-catalog.ts";
22
+ import { suggestPaths } from "./path-suggestions.ts";
23
+ import { listAgentCommands } from "../../piagent-core/runtime/resources/agent-resources.mjs";
22
24
  import { sessionRefForPath } from "../ownership/session-refs.ts";
23
25
  import { SessionMetadataStore } from "./session-metadata-store.ts";
24
26
  import { GatewayProtocolService } from "./gateway-protocol-service.ts";
@@ -222,6 +224,9 @@ export async function startPiagentGateway(options: {
222
224
  // Every revision the browser sees and sends back is the merged one.
223
225
  const hubCatalog = relay ? async () => relay!.merge(await readCatalog()) : readCatalog;
224
226
  const company = (sessionRef: unknown) => !!relay?.owns(sessionRef);
227
+ const projectFolder = async (projectRef: string) => projects.resolve(projectRef)
228
+ ?? (await runtimes!.listSessions()).find((info) => typeof info.cwd === "string" && projectRefForCwd(key, info.cwd) === projectRef)?.cwd
229
+ ?? (relay ? (await relay.folders()).find((folder) => projectRefForCwd(key, folder) === projectRef) : undefined);
225
230
  const relayPost = (path: string, body: unknown) => relay!.json("POST", path, body);
226
231
  const commands = new SessionCommandController({ catalog: hubCatalog, runtimes, metadata,
227
232
  store: new SessionCommandStore(state.root, key), events,
@@ -286,6 +291,17 @@ export async function startPiagentGateway(options: {
286
291
  // conversation runs (the company Gateway for a company conversation).
287
292
  readSessionQuestions: (sessionRef) => company(sessionRef)
288
293
  ? relay!.json("GET", `/api/v1/sessions/${encodeURIComponent(sessionRef)}/questions`) : runtimes!.questions(sessionRef),
294
+ // What an @ or a / in the composer offers, under a folder this dashboard
295
+ // knows: an imported project or the folder of a conversation (company
296
+ // ones too).
297
+ suggestPaths: async (projectRef, query) => {
298
+ const root = await projectFolder(projectRef);
299
+ return root ? await suggestPaths({ root, query }) : null;
300
+ },
301
+ listCommands: async (projectRef) => {
302
+ const root = await projectFolder(projectRef);
303
+ return root ? { commands: listAgentCommands({ cwd: root }) } : null;
304
+ },
289
305
  updates: { status: () => updates!.status(), check: () => updates!.check(), apply: (request) => updates!.apply(request) },
290
306
  answerSessionQuestion: (sessionRef, questionRef, answer) => company(sessionRef)
291
307
  ? relayPost(`/api/v1/sessions/${encodeURIComponent(sessionRef)}/questions/${encodeURIComponent(questionRef)}/answer`, answer)
@@ -0,0 +1,118 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ // What an @ in the composer offers, as Pi's terminal does: files and folders
7
+ // of the project matched loosely by name (fd, which honours .gitignore), and,
8
+ // after a folder and a slash (src/, ~/Documents/, /Volumes/), the names under
9
+ // that folder anywhere on this Mac. Only names reach the browser; no file is
10
+ // opened.
11
+ export type PathSuggestion = { value: string; label: string; detail: string; kind: "file" | "directory" };
12
+ type Entry = { path: string; directory: boolean };
13
+
14
+ const LIMIT = 20;
15
+ const SEARCH_RESULTS = 100;
16
+ const LISTED_ENTRIES = 5_000;
17
+ // Outside the project a search stays shallow and skips app data and
18
+ // dependency folders: a home folder is far larger than a repository.
19
+ const OUTSIDE_DEPTH = 4;
20
+ const fold = (value: string) => value.normalize("NFC").toLowerCase();
21
+ const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22
+ // A name typed composed (Dự án) may be stored decomposed on disk: either form matches.
23
+ const either = (value: string) => value.normalize("NFC") === value.normalize("NFD") ? escape(value)
24
+ : `(?:${escape(value.normalize("NFC"))}|${escape(value.normalize("NFD"))})`;
25
+ const inside = (file: string, root: string) => file === root || file.startsWith(root + path.sep);
26
+
27
+ export function findFd(home = os.homedir()): string | null {
28
+ for (const candidate of ["/opt/homebrew/bin/fd", "/usr/local/bin/fd", path.join(home, ".pi/agent/bin/fd")]) {
29
+ try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch { /* next */ }
30
+ }
31
+ return null;
32
+ }
33
+
34
+ // Higher is better: the name itself, then its start, then anywhere in it,
35
+ // then anywhere in the path; a folder before a file with the same score.
36
+ function score(entry: string, query: string, directory: boolean): number {
37
+ const name = fold(path.basename(entry)), wanted = fold(query);
38
+ const value = name === wanted ? 100 : name.startsWith(wanted) ? 80 : name.includes(wanted) ? 50 : fold(entry).includes(wanted) ? 30 : 0;
39
+ return value && directory ? value + 10 : value;
40
+ }
41
+
42
+ function run(file: string, args: string[], signal?: AbortSignal, input?: string): Promise<string> {
43
+ return new Promise((resolve) => {
44
+ const child = execFile(file, args, { encoding: "utf8", timeout: 4_000, maxBuffer: 8 << 20, signal }, (error, stdout) => resolve(error ? "" : stdout));
45
+ child.stdin?.on("error", () => {}); child.stdin?.end(input ?? "");
46
+ });
47
+ }
48
+
49
+ // The folder's own names, without what its repository ignores (dist/,
50
+ // node_modules/), as fd lists them for Pi.
51
+ async function listed(folder: string, signal?: AbortSignal): Promise<Entry[]> {
52
+ const entries: Entry[] = [];
53
+ try {
54
+ for await (const entry of await fs.promises.opendir(folder)) {
55
+ let directory = entry.isDirectory();
56
+ if (entry.isSymbolicLink()) try { directory = fs.statSync(path.join(folder, entry.name)).isDirectory(); } catch { /* broken link */ }
57
+ entries.push({ path: entry.name, directory });
58
+ if (entries.length >= LISTED_ENTRIES) break;
59
+ }
60
+ } catch { /* unreadable */ }
61
+ if (!entries.length) return entries;
62
+ const ignored = new Set((await run("/usr/bin/git", ["-C", folder, "check-ignore", "-z", "--stdin"], signal, entries.map((entry) => `${entry.path}\0`).join("")))
63
+ .split("\0").filter(Boolean));
64
+ return ignored.size ? entries.filter((entry) => !ignored.has(entry.path)) : entries;
65
+ }
66
+
67
+ // Names deeper down that match: fd as Pi runs it in the project; shallow and
68
+ // without hidden folders elsewhere; without fd, the files git knows about.
69
+ async function searched(folder: string, query: string, options: { fd: string | null; project: boolean; atRoot: boolean; signal?: AbortSignal }): Promise<Entry[]> {
70
+ if (options.fd) {
71
+ const args = ["--base-directory", folder, "--type", "f", "--type", "d", "--max-results", String(SEARCH_RESULTS), "--ignore-case", "--exclude", ".git",
72
+ ...(options.project ? ["--follow", "--hidden"] : ["--max-depth", String(OUTSIDE_DEPTH), "--exclude", "Library", "--exclude", "node_modules", ...(query.startsWith(".") ? ["--hidden"] : [])]),
73
+ ...(query.includes("/") ? ["--full-path", query.split("/").filter(Boolean).map(either).join("/")] : [either(query)])];
74
+ return (await run(options.fd, args, options.signal)).split("\n").filter(Boolean)
75
+ .map((line) => ({ path: line.replace(/^\.\//, "").replace(/\/$/, ""), directory: line.endsWith("/") }));
76
+ }
77
+ if (!options.atRoot) return [];
78
+ const files = (await run("/usr/bin/git", ["-C", folder, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], options.signal))
79
+ .split("\0").filter(Boolean).slice(0, 50_000);
80
+ const folders = new Set<string>();
81
+ for (const file of files) for (let dir = path.posix.dirname(file); dir !== "."; dir = path.posix.dirname(dir)) folders.add(dir);
82
+ const wanted = fold(query);
83
+ return [...[...folders].map((dir) => ({ path: dir, directory: true })), ...files.map((file) => ({ path: file, directory: false }))]
84
+ .filter((entry) => fold(entry.path).includes(wanted));
85
+ }
86
+
87
+ export async function suggestPaths(options: { root: string; query: string; home?: string; fd?: string | null; signal?: AbortSignal }): Promise<{ suggestions: PathSuggestion[] }> {
88
+ const home = options.home ?? os.homedir(), fd = options.fd === undefined ? findFd(home) : options.fd;
89
+ const root = fs.realpathSync(options.root);
90
+ const typed = options.query.trim() === "~" ? "~/" : options.query.trim();
91
+ // Text up to the last slash names a folder: search under it (Pi's scoped
92
+ // search). When it is not a folder, the whole text is matched in the project.
93
+ const cut = typed.lastIndexOf("/") + 1;
94
+ let base = typed.slice(0, cut), name = typed.slice(cut), folder = root;
95
+ if (base) {
96
+ try {
97
+ const candidate = fs.realpathSync(base.startsWith("~/") ? path.join(home, base.slice(2)) : path.resolve(root, base));
98
+ if (fs.statSync(candidate).isDirectory()) folder = candidate; else { base = ""; name = typed; }
99
+ } catch { base = ""; name = typed; }
100
+ }
101
+ const project = inside(folder, root);
102
+ const near = await listed(folder, options.signal);
103
+ const deep = name ? await searched(folder, name, { fd, project, atRoot: folder === root, signal: options.signal }) : [];
104
+ const seen = new Set<string>(), entries: Array<Entry & { score: number }> = [];
105
+ for (const entry of [...near, ...deep]) {
106
+ if (seen.has(entry.path)) continue; seen.add(entry.path);
107
+ // Hidden entries (.github, .ssh, .zshrc) for a name typed with a dot, and
108
+ // in the project for any name typed, as Pi's search finds them there.
109
+ if (entry.path.split("/").some((part) => part.startsWith(".")) && !name.startsWith(".") && !(project && name)) continue;
110
+ const value = name ? score(entry.path, name.split("/").filter(Boolean).at(-1) ?? name, entry.directory) : 1;
111
+ if (value > 0) entries.push({ ...entry, score: value });
112
+ }
113
+ const depth = (entry: Entry) => entry.path.split("/").length;
114
+ entries.sort((a, b) => b.score - a.score || depth(a) - depth(b)
115
+ || (name ? a.path.length - b.path.length : Number(b.directory) - Number(a.directory)) || a.path.localeCompare(b.path));
116
+ return { suggestions: entries.slice(0, LIMIT).map((entry) => ({ value: `${base}${entry.path}${entry.directory ? "/" : ""}`,
117
+ label: path.basename(entry.path) + (entry.directory ? "/" : ""), detail: `${base}${entry.path}`, kind: entry.directory ? "directory" : "file" })) };
118
+ }
@@ -426,7 +426,9 @@ export class SessionRuntimeSupervisor {
426
426
  if (!file) throw new Error("session-fork-unavailable");
427
427
  forkManager = this.#host.SessionManager.open(file);
428
428
  } else {
429
- forkManager = this.#host.SessionManager.forkFrom(source.path, source.cwd);
429
+ // Managed members keep sessions flat in one folder; Pi would otherwise write
430
+ // the fork into its per-cwd folder, where the gateway never finds it again.
431
+ forkManager = this.#host.SessionManager.forkFrom(source.path, source.cwd, this.#sessionDirectory);
430
432
  }
431
433
  if (title) forkManager.appendSessionInfo(title);
432
434
  const file = forkManager.getSessionFile(), id = forkManager.getSessionId?.();
@@ -70,6 +70,8 @@ export async function startLoopbackServer(options: {
70
70
  executeApproval?: (approvalRef: string, decision: unknown) => unknown | Promise<unknown>;
71
71
  readSessionQuestions?: (sessionRef: string) => unknown | Promise<unknown>;
72
72
  answerSessionQuestion?: (sessionRef: string, questionRef: string, answer: unknown) => unknown | Promise<unknown>;
73
+ suggestPaths?: (projectRef: string, query: string) => unknown | Promise<unknown>;
74
+ listCommands?: (projectRef: string) => unknown | Promise<unknown>;
73
75
  updates?: UpdateRoutes;
74
76
  bootstrapTtlMs?: number;
75
77
  sessionTtlMs?: number;
@@ -4,12 +4,14 @@ import { errorResponse, jsonResponse, requestBody } from "./http-security.ts";
4
4
  import type { SessionAuthority } from "./session-auth.ts";
5
5
 
6
6
  // Requests a member makes beside a conversation: answering the main agent's
7
- // questions, and keeping Piagent itself up to date. Reads need the browser
8
- // session; every change also needs this origin, the session's CSRF token and
9
- // a share of the control rate.
7
+ // questions, what an @ or a / in the composer offers, and keeping
8
+ // Piagent itself up to date. Reads need the browser session; every change
9
+ // also needs this origin, the session's CSRF token and a share of the control
10
+ // rate.
10
11
  const MAX_CONTROL_BODY_BYTES = 70_000;
11
12
  const CURSOR = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
12
13
  const QUESTION_PATH = /^\/api\/v1\/sessions\/([^/]+)\/questions(?:\/([^/]+)\/answer)?$/;
14
+ const PROJECT_PATH = /^\/api\/v1\/projects\/([^/]+)\/(paths|commands)$/;
13
15
 
14
16
  export type UpdateRoutes = {
15
17
  // What is installed, what is available, and the last update run.
@@ -27,6 +29,11 @@ type Context = {
27
29
  type Options = {
28
30
  readSessionQuestions?: (sessionRef: string) => unknown | Promise<unknown>;
29
31
  answerSessionQuestion?: (sessionRef: string, questionRef: string, answer: unknown) => unknown | Promise<unknown>;
32
+ // Names under the project (or the folder typed after ~/ or /); null when the
33
+ // project is not one this dashboard knows.
34
+ suggestPaths?: (projectRef: string, query: string) => unknown | Promise<unknown>;
35
+ // The project's and the member's commands and skills; null as above.
36
+ listCommands?: (projectRef: string) => unknown | Promise<unknown>;
30
37
  updates?: UpdateRoutes;
31
38
  };
32
39
 
@@ -76,6 +83,18 @@ export async function routeMemberRequest(context: Context, options: Options): Pr
76
83
  }
77
84
  return true;
78
85
  }
86
+ const project = request.method === "GET" ? PROJECT_PATH.exec(url.pathname) : null;
87
+ const read = project?.[2] === "paths" ? options.suggestPaths : project ? options.listCommands : undefined;
88
+ if (project && read) {
89
+ if (!auth.authenticate(request)) { errorResponse(response, 401, "authentication-required"); return true; }
90
+ const projectRef = decodeURIComponent(project[1]), query = url.searchParams.get("query") ?? "";
91
+ if (!CURSOR.test(projectRef) || query.length > 512 || /[\x00-\x1f]/.test(query)) { errorResponse(response, 400, `invalid-${project[2] === "paths" ? "path" : "command"}-query`); return true; }
92
+ try {
93
+ const value = await read(projectRef, query);
94
+ if (value === null) errorResponse(response, 404, "project-not-found"); else jsonResponse(response, 200, value);
95
+ } catch { errorResponse(response, 503, `${project[2] === "paths" ? "path-suggestions" : "commands"}-unavailable`); }
96
+ return true;
97
+ }
79
98
 
80
99
  if (!options.updates || !url.pathname.startsWith("/api/v1/updates")) return false;
81
100
  const updates = options.updates;
@@ -98,7 +98,13 @@ function userMessageProjection(message: any): { text: string; attachments: Trans
98
98
  // hashing them here would turn the chat bubble into a document dump and make
99
99
  // a bounded UI digest an oracle over user files. The first block is the exact
100
100
  // text the operator typed; the remaining recognized blocks become cards.
101
- return { text: attachments.length ? String(textParts[0]?.text ?? "") : messageText(message), attachments: attachments.slice(0, 4) };
101
+ return { text: typedSkillCall(attachments.length ? String(textParts[0]?.text ?? "") : messageText(message)), attachments: attachments.slice(0, 4) };
102
+ }
103
+ // A skill the member called (/skill:name): the message holds the skill's
104
+ // instructions for the model; the chat shows what was typed.
105
+ function typedSkillCall(text: string): string {
106
+ const skill = /^<skill name="([^"\n]+)" location="[^"\n]+">\n[\s\S]*?\n<\/skill>(?:\n\n([\s\S]+))?$/.exec(text);
107
+ return skill ? `/skill:${skill[1]}${skill[2] ? ` ${skill[2]}` : ""}` : text;
102
108
  }
103
109
  // A company request fails with Studio's (or Agent Watch's) own code: who
104
110
  // failed, which kind of failure, and Studio's request id. Text is never shown.