@piagent/platform 1.2.0 → 1.2.2

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 (64) hide show
  1. package/CHANGELOG.md +149 -1
  2. package/README.md +1 -1
  3. package/catalog/capabilities.json +4 -4
  4. package/docs/command-reference-vietnamese.md +15 -4
  5. package/docs/context-window-policy.md +1 -0
  6. package/docs/distribution-standard.md +7 -7
  7. package/docs/mcp-and-tools.md +49 -3
  8. package/docs/operator-manual-vietnamese.md +52 -11
  9. package/docs/publishing-for-teams.md +2 -0
  10. package/docs/quickstart-vietnamese.md +4 -2
  11. package/docs/release-install-policy.md +6 -6
  12. package/docs/security-threat-model.md +4 -3
  13. package/docs/team-onboarding.md +3 -3
  14. package/docs/usage-observability.md +49 -0
  15. package/docs-site/architecture.html +1 -1
  16. package/docs-site/commands.html +1 -1
  17. package/docs-site/content/mcp-approval.html +54 -2
  18. package/docs-site/content/quickstart.html +1 -1
  19. package/docs-site/index.html +1 -1
  20. package/docs-site/mcp-approval.html +55 -3
  21. package/docs-site/mcp-auth.html +1 -1
  22. package/docs-site/mcp-servers.html +1 -1
  23. package/docs-site/mcp.html +1 -1
  24. package/docs-site/permissions.html +1 -1
  25. package/docs-site/profiles.html +1 -1
  26. package/docs-site/quickstart.html +2 -2
  27. package/docs-site/runtime.html +1 -1
  28. package/docs-site/scope.html +1 -1
  29. package/docs-site/security.html +1 -1
  30. package/docs-site/team.html +1 -1
  31. package/docs-site/workflows.html +1 -1
  32. package/package.json +1 -1
  33. package/packages/piagent-core/README.md +2 -0
  34. package/packages/piagent-core/capabilities/capability-core.js +41 -4
  35. package/packages/piagent-core/capabilities/capability-sources.js +73 -12
  36. package/packages/piagent-core/extensions/piagent-guard.ts +539 -70
  37. package/packages/piagent-core/extensions/policy-core.js +1586 -98
  38. package/packages/piagent-core/mcp/mcp-approval-store.js +56 -15
  39. package/packages/piagent-core/mcp/mcp-auth-readiness.js +31 -3
  40. package/packages/piagent-core/mcp/mcp-command-actions.js +139 -30
  41. package/packages/piagent-core/mcp/mcp-config-layers.js +339 -6
  42. package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
  43. package/packages/piagent-core/mcp/mcp-server-entry.js +51 -0
  44. package/packages/piagent-core/mcp/mcp-session-view.js +55 -4
  45. package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
  46. package/packages/piagent-core/package.json +1 -1
  47. package/packages/piagent-core/prompts/be-to-fe.md +1 -0
  48. package/packages/piagent-core/prompts/piagent-commands.md +1 -0
  49. package/packages/piagent-core/prompts/platform-improve.md +1 -0
  50. package/packages/piagent-core/prompts/review.md +1 -0
  51. package/packages/piagent-core/prompts/scout.md +1 -0
  52. package/packages/piagent-core/prompts/task.md +1 -0
  53. package/scripts/configure-model-scope.sh +19 -3
  54. package/scripts/configure-subagents.sh +17 -4
  55. package/scripts/init-project.sh +15 -8
  56. package/scripts/mcp-manage.mjs +111 -22
  57. package/scripts/pi-model-catalog.sh +11 -1
  58. package/scripts/pi-session-stats.sh +27 -1
  59. package/scripts/pi-usage-history.mjs +567 -0
  60. package/scripts/quality-benchmark.sh +37 -9
  61. package/scripts/setup.sh +21 -13
  62. package/scripts/uninstall-global.sh +10 -4
  63. package/scripts/verify-local.sh +19 -0
  64. package/scripts/verify-release-identity.mjs +29 -0
@@ -12,6 +12,7 @@ import {
12
12
  extractShellGlobCandidates,
13
13
  extractShellPathCandidates,
14
14
  findProtectedPathInCommand,
15
+ unresolvedPathExpansions,
15
16
  globMatchesPath,
16
17
  matchesProtectedPath,
17
18
  normalizePathCandidate,
@@ -42,7 +43,14 @@ import {
42
43
  } from "./redaction-core.js";
43
44
  import { detectProfileName } from "./project-shape.js";
44
45
  import { evaluateUpdateCheck, isInstalledPlatform, readUpdateCache, startUpdateProbe } from "./update-check.js";
45
- import { REPOSITORY_SCOPES, collectServers } from "../mcp/mcp-config-layers.js";
46
+ import {
47
+ REPOSITORY_SCOPES,
48
+ collectServers,
49
+ configPathForScope,
50
+ mcpDecisionInputs,
51
+ unverifiableMcpConfig
52
+ } from "../mcp/mcp-config-layers.js";
53
+ import { attributeDirectTool } from "../mcp/mcp-tool-naming.js";
46
54
  import { approvalState } from "../mcp/mcp-approval-store.js";
47
55
  import { evaluateServerReadiness, readinessNotice } from "../mcp/mcp-auth-readiness.js";
48
56
  import * as mcpActions from "../mcp/mcp-command-actions.js";
@@ -120,6 +128,13 @@ const BOILERPLATE_COLLAPSE_CHARS = 300;
120
128
  const MAX_INLINE_COLLAPSED_TASK_CHARS = 2200;
121
129
  const MAX_CHAT_IMAGE_ATTACHMENTS = 4;
122
130
  const MAX_CHAT_IMAGE_BYTES = 8 * 1024 * 1024;
131
+ const TOOL_RESULT_COMPACT_CHAR_THRESHOLD = 12_000;
132
+ const TOOL_RESULT_COMPACT_LINE_THRESHOLD = 180;
133
+ const TOOL_RESULT_PREVIEW_HEAD_LINES = 24;
134
+ const TOOL_RESULT_PREVIEW_TAIL_LINES = 24;
135
+ const TOOL_RESULT_PREVIEW_INTERESTING_LINES = 24;
136
+ const TOOL_RESULT_PREVIEW_MAX_CHARS = 6_000;
137
+ const TOOL_RESULT_CAPTURE_MAX_CHARS = 500_000;
123
138
  const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "bmp"] as const;
124
139
  const ORCHESTRATION_MODES = ["solo-first", "bounded-subagents", "parallel-readonly"] as const;
125
140
  const REVIEW_LENSES = ["correctness", "tests", "scope", "security", "docs", "release", "package"] as const;
@@ -1480,6 +1495,14 @@ function observedBashLedgerPath(cwd: string): string {
1480
1495
  return path.join(stateRoot(cwd), "observed-bash.jsonl");
1481
1496
  }
1482
1497
 
1498
+ function toolResultCaptureRoot(cwd: string): string {
1499
+ return path.join(stateRoot(cwd), "tool-results");
1500
+ }
1501
+
1502
+ function toolResultCaptureIndexPath(cwd: string): string {
1503
+ return path.join(toolResultCaptureRoot(cwd), "index.jsonl");
1504
+ }
1505
+
1483
1506
  function safeTaskId(taskId: string): string {
1484
1507
  return taskId.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "task";
1485
1508
  }
@@ -1994,16 +2017,20 @@ function prepareToolInputForPolicy(
1994
2017
  }
1995
2018
 
1996
2019
  // Which MCP server a tool call is addressed to. The adapter exposes one proxy
1997
- // tool named `mcp` that carries the server in `input.server`, and, when a server
1998
- // runs with directTools, individual tools named `mcp__<server>__<tool>`. Both
1999
- // forms are read, because a gate that only covers the proxy is bypassed by
2000
- // turning directTools on.
2001
- function mcpServerFromToolCall(toolName: string, input: Record<string, unknown>): string | undefined {
2020
+ // tool named `mcp` that carries the server in `input.server`, and, under
2021
+ // directTools, one tool per server tool named `<prefix>_<tool>` where the prefix
2022
+ // is derived from the server name. Direct names are matched against the servers
2023
+ // this gate is holding rather than against a fixed pattern, because the prefix
2024
+ // is the server's own name and there is nothing constant to anchor on.
2025
+ function mcpServerFromToolCall(
2026
+ toolName: string,
2027
+ input: Record<string, unknown>,
2028
+ candidates: Iterable<string>
2029
+ ): string | undefined {
2002
2030
  if (normalizeActionToken(toolName) === "mcp") {
2003
2031
  return typeof input.server === "string" && input.server.trim() ? input.server.trim() : undefined;
2004
2032
  }
2005
- const direct = toolName.match(/^mcp(?:__|[-_:]+)([^_:.-]+)/i);
2006
- return direct?.[1];
2033
+ return attributeDirectTool(toolName, candidates);
2007
2034
  }
2008
2035
 
2009
2036
  // Servers a repository defines are not usable until somebody on this machine has
@@ -2014,25 +2041,71 @@ function mcpServerFromToolCall(toolName: string, input: Record<string, unknown>)
2014
2041
  //
2015
2042
  // Recomputed only when one of the files behind the decision changes, because
2016
2043
  // this runs on every tool call.
2017
- const mcpApprovalCache = new Map<string, { signature: string; blocked: Map<string, string> }>();
2044
+ interface RepositoryMcpGate {
2045
+ blocked: Map<string, { state: string; origin: string }>;
2046
+ // Conditions under which no tool call can be checked at all, each with the
2047
+ // sentence to show. These are not "a server is unapproved" — they are "this
2048
+ // repository's config has put the gate in a state where it cannot tell which
2049
+ // server a call belongs to", and the only sound answer to that is to stop.
2050
+ unverifiable: string[];
2051
+ }
2052
+
2053
+ const mcpApprovalCache = new Map<string, { signature: string; gate: RepositoryMcpGate }>();
2018
2054
 
2019
- function unapprovedMcpServers(cwd: string): Map<string, string> {
2055
+ function repositoryMcpGate(cwd: string): RepositoryMcpGate {
2056
+ // Repository scopes plus anything an `imports` key drags in from a file the
2057
+ // clone carries. Collecting those here is the whole point: a server the gate
2058
+ // never enumerates is a server it silently permits.
2020
2059
  const servers = collectServers({ projectPath: cwd, scopes: [...REPOSITORY_SCOPES] });
2060
+ const repositoryFiles = [...REPOSITORY_SCOPES].map((scope) => ({
2061
+ scope,
2062
+ file: configPathForScope(scope, { projectPath: cwd })
2063
+ }));
2064
+ // Every file the decision reads, listed by the module that reads them rather
2065
+ // than restated here. Building this by hand is what went wrong: the scan grew
2066
+ // to read merged settings from all four scopes and to stat import targets
2067
+ // outside the repository, the hand-written list did not, and an already-loaded
2068
+ // guard went on permitting calls after a personal global config had put the
2069
+ // session in the state this gate exists to refuse.
2021
2070
  const signature = [
2022
- ...servers.map((server) => `${server.scope}:${server.name}:${fileSignature(server.file)}`),
2071
+ ...servers.map((server) => `${server.origin}:${server.name}:${fileSignature(server.file)}`),
2072
+ ...repositoryFiles.map((layer) => `layer:${layer.scope}:${fileSignature(layer.file)}`),
2073
+ ...mcpDecisionInputs({ projectPath: cwd }).map((file) => `input:${file}:${fileSignature(file)}`),
2023
2074
  `store:${fileSignature(path.join(os.homedir(), ".pi", "piagent-mcp-approvals.json"))}`
2024
2075
  ].join("|");
2025
2076
  const cached = mcpApprovalCache.get(cwd);
2026
- if (cached?.signature === signature) return cached.blocked;
2077
+ if (cached?.signature === signature) return cached.gate;
2027
2078
 
2028
- const blocked = new Map<string, string>();
2079
+ const blocked = new Map<string, { state: string; origin: string }>();
2029
2080
  for (const server of servers) {
2030
2081
  const state = approvalState({ projectPath: cwd, name: server.name, entry: server.entry });
2031
2082
  if (state.state === "approved") continue;
2032
- blocked.set(server.name, state.state);
2033
- }
2034
- mcpApprovalCache.set(cwd, { signature, blocked });
2035
- return blocked;
2083
+ // A name defined more than once keeps its blocking entry: the approved copy
2084
+ // does not vouch for the one nobody has looked at.
2085
+ blocked.set(server.name, { state: state.state, origin: server.origin });
2086
+ }
2087
+
2088
+ // Two things a repository-carried config can do that leave nothing to check.
2089
+ //
2090
+ // It can ask for tools with no prefix, so a direct tool arrives under a bare
2091
+ // name carrying no evidence of its server. Refusing only the proxy there was
2092
+ // not a fix: the proxy is the one form that names its server, so blocking it
2093
+ // and allowing the bare names left the hole exactly where it was.
2094
+ //
2095
+ // It can also import a kind whose config this platform cannot enumerate, and
2096
+ // then the set of servers reaching the session is simply unknown. Blocking the
2097
+ // servers that could be listed says nothing about the ones that could not.
2098
+ //
2099
+ // Both stop every tool call. A repository can make a session refuse to run;
2100
+ // it must not be able to make one run a server nobody approved.
2101
+ // Read from the shared module rather than decided here, so `piagent-mcp
2102
+ // doctor` reports exactly what this refuses. The two disagreeing is how an
2103
+ // operator ends up reading "PASS" while every tool call is being stopped.
2104
+ const unverifiable = unverifiableMcpConfig({ projectPath: cwd }).map((problem) => problem.detail);
2105
+
2106
+ const gate = { blocked, unverifiable };
2107
+ mcpApprovalCache.set(cwd, { signature, gate });
2108
+ return gate;
2036
2109
  }
2037
2110
 
2038
2111
  function fileSignature(file: string): string {
@@ -2049,15 +2122,26 @@ function evaluateMcpApproval(
2049
2122
  toolName: string,
2050
2123
  input: Record<string, unknown>
2051
2124
  ): { block: boolean; reason?: string } {
2052
- const server = mcpServerFromToolCall(toolName, input);
2125
+ const gate = repositoryMcpGate(cwd);
2126
+
2127
+ // Every tool call, not only the ones that look like MCP. Under either of these
2128
+ // conditions a call cannot be traced to a server, so there is no such thing as
2129
+ // a call this gate can clear.
2130
+ if (gate.unverifiable.length > 0) {
2131
+ return { block: true, reason: `Blocked every tool call: ${gate.unverifiable.join(" ")}` };
2132
+ }
2133
+
2134
+ const server = mcpServerFromToolCall(toolName, input, gate.blocked.keys());
2053
2135
  if (!server) return { block: false };
2054
- const state = unapprovedMcpServers(cwd).get(server);
2055
- if (!state) return { block: false };
2056
- const explanation = state === "rejected"
2136
+ const record = gate.blocked.get(server);
2137
+ if (!record) return { block: false };
2138
+ const explanation = record.state === "rejected"
2057
2139
  ? "it was rejected for this project"
2058
- : state === "changed"
2140
+ : record.state === "changed"
2059
2141
  ? "its definition changed since it was approved"
2060
- : "this repository defines it and nobody on this machine has approved it";
2142
+ : record.origin.startsWith("import:")
2143
+ ? `this repository imports it from ${record.origin.slice("import:".length)} config and nobody on this machine has approved it here`
2144
+ : "this repository defines it and nobody on this machine has approved it";
2061
2145
  return {
2062
2146
  block: true,
2063
2147
  // Named as commands, because this is read inside a session: the reader can
@@ -2069,6 +2153,18 @@ function evaluateMcpApproval(
2069
2153
  };
2070
2154
  }
2071
2155
 
2156
+ // A word whose final path segment glues literal text onto an expansion --
2157
+ // `.en$(echo v)`, `${D}.env` -- names a file this process cannot know without
2158
+ // running the substitution. Matching the literal half against the protected
2159
+ // patterns answers a different question than the one being asked, so refuse
2160
+ // rather than let an unknown filename through unchecked.
2161
+ function unresolvedExpansionReason(subject: string, words: string[]): string {
2162
+ const listed = words.map((word) => `\`${word}\``).join(", ");
2163
+ return `${subject} builds a filename this guard cannot resolve: ${listed}. `
2164
+ + "The literal text around the expansion makes it a path, but its value is only known at run time, "
2165
+ + "so it cannot be checked against the protected paths. Write the path out, or put the expansion in its own argument.";
2166
+ }
2167
+
2072
2168
  function evaluateMcpProxyShellProtectedAccess(
2073
2169
  cwd: string,
2074
2170
  prepared: PreparedToolInput,
@@ -2112,6 +2208,10 @@ function evaluateMcpProxyShellProtectedAccess(
2112
2208
  reason: `MCP command resolves to protected path: ${resolvedProtectedHit.candidate} resolves to ${resolvedProtectedHit.resolved} matching ${resolvedProtectedHit.pattern}`
2113
2209
  };
2114
2210
  }
2211
+ const unresolved = protectedPaths.length > 0 ? unresolvedPathExpansions(command) : [];
2212
+ if (unresolved.length > 0) {
2213
+ return { block: true, reason: unresolvedExpansionReason("MCP command", unresolved) };
2214
+ }
2115
2215
  return { block: false };
2116
2216
  }
2117
2217
 
@@ -3284,6 +3384,321 @@ function modelLabel(ctx: ExtensionContext): string {
3284
3384
  return model.name ?? model.id ?? "unknown";
3285
3385
  }
3286
3386
 
3387
+ function currentSessionName(ctx: ExtensionContext): string {
3388
+ try {
3389
+ return String(ctx.sessionManager.getSessionName() ?? "").trim();
3390
+ } catch {
3391
+ return "";
3392
+ }
3393
+ }
3394
+
3395
+ function hasOperatorSessionName(name: string): boolean {
3396
+ const normalized = name.trim().toLowerCase();
3397
+ return Boolean(normalized && normalized !== "session");
3398
+ }
3399
+
3400
+ function cleanSessionNameInput(input: string): string {
3401
+ let name = input.trim();
3402
+ if ((name.startsWith("\"") && name.endsWith("\"")) || (name.startsWith("'") && name.endsWith("'"))) {
3403
+ name = name.slice(1, -1).trim();
3404
+ }
3405
+ return name;
3406
+ }
3407
+
3408
+ type ToolResultCaptureSummary = {
3409
+ path?: string;
3410
+ error?: string;
3411
+ source: string;
3412
+ toolName: string;
3413
+ originalChars: number;
3414
+ originalLines: number;
3415
+ previewChars?: number;
3416
+ storedChars?: number;
3417
+ storedTruncated?: boolean;
3418
+ sha256: string;
3419
+ };
3420
+
3421
+ function normalizeToolResultText(text: string): string {
3422
+ return text.replace(/\r\n/g, "\n");
3423
+ }
3424
+
3425
+ function toolResultLineCount(text: string): number {
3426
+ if (!text) return 0;
3427
+ return normalizeToolResultText(text).split("\n").length;
3428
+ }
3429
+
3430
+ function shouldCompactToolResultText(text: string): boolean {
3431
+ return text.length > TOOL_RESULT_COMPACT_CHAR_THRESHOLD
3432
+ || toolResultLineCount(text) > TOOL_RESULT_COMPACT_LINE_THRESHOLD;
3433
+ }
3434
+
3435
+ function clipPreviewLine(line: string, maxChars = 260): string {
3436
+ if (line.length <= maxChars) return line;
3437
+ return `${line.slice(0, maxChars).trimEnd()} ... [line clipped]`;
3438
+ }
3439
+
3440
+ function boundedPreview(text: string): string {
3441
+ if (text.length <= TOOL_RESULT_PREVIEW_MAX_CHARS) return text;
3442
+ const marker = "\n[Piagent preview shortened further to stay light.]\n";
3443
+ const edge = Math.floor((TOOL_RESULT_PREVIEW_MAX_CHARS - marker.length) / 2);
3444
+ return `${text.slice(0, edge).trimEnd()}${marker}${text.slice(-edge).trimStart()}`;
3445
+ }
3446
+
3447
+ function interestingToolResultLines(lines: string[]): string[] {
3448
+ const interesting = /\b(?:error|errors|err!|failed?|failure|warning|warn|exception|traceback|assert(?:ion)?|panic|fatal|timeout|timed out|denied|not found|cannot|eacces|enoent|ts\d{3,5}|err_[a-z0-9_]+)\b/i;
3449
+ const seen = new Set<string>();
3450
+ const result: string[] = [];
3451
+ for (let index = 0; index < lines.length; index += 1) {
3452
+ const line = lines[index];
3453
+ if (!interesting.test(line)) continue;
3454
+ const rendered = `${index + 1}: ${clipPreviewLine(line)}`;
3455
+ if (seen.has(rendered)) continue;
3456
+ seen.add(rendered);
3457
+ result.push(rendered);
3458
+ if (result.length >= TOOL_RESULT_PREVIEW_INTERESTING_LINES) break;
3459
+ }
3460
+ return result;
3461
+ }
3462
+
3463
+ function storedToolResultText(text: string): { text: string; truncated: boolean } {
3464
+ if (text.length <= TOOL_RESULT_CAPTURE_MAX_CHARS) return { text, truncated: false };
3465
+ const marker = `\n\n[Piagent capture truncated: omitted ${formatCount(text.length - TOOL_RESULT_CAPTURE_MAX_CHARS)} chars from the middle.]\n\n`;
3466
+ const edge = Math.floor((TOOL_RESULT_CAPTURE_MAX_CHARS - marker.length) / 2);
3467
+ return {
3468
+ text: `${text.slice(0, edge).trimEnd()}${marker}${text.slice(-edge).trimStart()}`,
3469
+ truncated: true
3470
+ };
3471
+ }
3472
+
3473
+ function compactToolResultPreview(toolName: string, source: string, text: string, capture: ToolResultCaptureSummary): string {
3474
+ const normalized = normalizeToolResultText(text);
3475
+ const lines = normalized.split("\n");
3476
+ const head = lines.slice(0, TOOL_RESULT_PREVIEW_HEAD_LINES).map((line) => clipPreviewLine(line));
3477
+ const tailStart = Math.max(TOOL_RESULT_PREVIEW_HEAD_LINES, lines.length - TOOL_RESULT_PREVIEW_TAIL_LINES);
3478
+ const tail = lines.slice(tailStart).map((line) => clipPreviewLine(line));
3479
+ const omitted = Math.max(0, lines.length - head.length - tail.length);
3480
+ const interesting = interestingToolResultLines(lines);
3481
+ const captureLine = capture.path
3482
+ ? `capture: ${capture.path}${capture.storedTruncated ? " (stored head/tail sample; original too large)" : ""}`
3483
+ : `capture: unavailable (${capture.error ?? "write failed"})`;
3484
+ const sections = [
3485
+ `[Piagent compacted large ${toolName} output from ${source}: ${formatCount(capture.originalChars)} chars / ${formatCount(capture.originalLines)} lines.]`,
3486
+ `[${captureLine}]`,
3487
+ "[Preview keeps head, notable lines, and tail.]",
3488
+ "",
3489
+ "head:",
3490
+ ...head
3491
+ ];
3492
+ if (interesting.length > 0) {
3493
+ sections.push("", "notable:", ...interesting);
3494
+ }
3495
+ if (omitted > 0) {
3496
+ sections.push("", `... ${formatCount(omitted)} middle line${omitted === 1 ? "" : "s"} omitted ...`);
3497
+ }
3498
+ sections.push("", "tail:", ...tail);
3499
+ return boundedPreview(sections.join("\n"));
3500
+ }
3501
+
3502
+ function maybeNumber(value: unknown): number | undefined {
3503
+ if (typeof value === "number" && Number.isFinite(value)) return value;
3504
+ if (typeof value === "string" && /^-?\d+$/.test(value.trim())) return Number.parseInt(value, 10);
3505
+ return undefined;
3506
+ }
3507
+
3508
+ function currentSessionField(ctx: ExtensionContext, field: "getSessionFile" | "getSessionId" | "getSessionName"): string | undefined {
3509
+ try {
3510
+ const value = ctx.sessionManager[field]?.();
3511
+ return value === undefined || value === null ? undefined : redactText(String(value));
3512
+ } catch {
3513
+ return undefined;
3514
+ }
3515
+ }
3516
+
3517
+ function writeToolResultCapture(
3518
+ cwd: string,
3519
+ event: any,
3520
+ ctx: ExtensionContext,
3521
+ source: string,
3522
+ text: string,
3523
+ cache: Map<string, ToolResultCaptureSummary>
3524
+ ): ToolResultCaptureSummary {
3525
+ const normalized = normalizeToolResultText(text);
3526
+ const sha256 = crypto.createHash("sha256").update(normalized).digest("hex");
3527
+ const originalLines = toolResultLineCount(normalized);
3528
+ const cached = cache.get(sha256);
3529
+ if (cached?.path) return { ...cached, source };
3530
+
3531
+ const toolName = String(event?.toolName ?? "tool");
3532
+ const recordedAt = nowIso();
3533
+ const date = recordedAt.slice(0, 10);
3534
+ const filename = `${recordedAt.replace(/[:.]/g, "-")}-${slugify(toolName)}-${sha256.slice(0, 12)}.log`;
3535
+ const relativePath = [".pi", "piagent-state", "tool-results", date, filename].join("/");
3536
+ const absolutePath = path.join(cwd, ".pi", "piagent-state", "tool-results", date, filename);
3537
+ const capture = storedToolResultText(normalized);
3538
+ const details = event?.details && typeof event.details === "object" ? event.details : {};
3539
+ const exitCode = maybeNumber(details.exitCode ?? details.status ?? event?.exitCode);
3540
+ const metadata = {
3541
+ schemaVersion: 1,
3542
+ recordedAt,
3543
+ cwd: redactText(cwd),
3544
+ sessionId: currentSessionField(ctx, "getSessionId"),
3545
+ sessionName: currentSessionField(ctx, "getSessionName"),
3546
+ sessionFile: currentSessionField(ctx, "getSessionFile"),
3547
+ toolName,
3548
+ source,
3549
+ isError: event?.isError === true,
3550
+ exitCode,
3551
+ input: redactForStorage(event?.input),
3552
+ path: relativePath,
3553
+ originalChars: normalized.length,
3554
+ originalLines,
3555
+ storedChars: capture.text.length,
3556
+ storedTruncated: capture.truncated,
3557
+ sha256
3558
+ };
3559
+
3560
+ try {
3561
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
3562
+ fs.writeFileSync(absolutePath, [
3563
+ "# Piagent compacted tool result capture",
3564
+ JSON.stringify(metadata),
3565
+ "---",
3566
+ capture.text
3567
+ ].join("\n"));
3568
+ fs.appendFileSync(toolResultCaptureIndexPath(cwd), `${JSON.stringify(metadata)}\n`);
3569
+ const summary = {
3570
+ path: relativePath,
3571
+ source,
3572
+ toolName,
3573
+ originalChars: normalized.length,
3574
+ originalLines,
3575
+ storedChars: capture.text.length,
3576
+ storedTruncated: capture.truncated,
3577
+ sha256
3578
+ };
3579
+ cache.set(sha256, summary);
3580
+ return summary;
3581
+ } catch (error) {
3582
+ const message = error instanceof Error ? error.message : String(error);
3583
+ const summary = {
3584
+ error: message,
3585
+ source,
3586
+ toolName,
3587
+ originalChars: normalized.length,
3588
+ originalLines,
3589
+ sha256
3590
+ };
3591
+ cache.set(sha256, summary);
3592
+ return summary;
3593
+ }
3594
+ }
3595
+
3596
+ function compactToolResultTextContent(
3597
+ cwd: string,
3598
+ event: any,
3599
+ ctx: ExtensionContext,
3600
+ content: unknown,
3601
+ cache: Map<string, ToolResultCaptureSummary>
3602
+ ): { content: unknown; captures: ToolResultCaptureSummary[] } {
3603
+ if (!Array.isArray(content)) return { content, captures: [] };
3604
+ const captures: ToolResultCaptureSummary[] = [];
3605
+ const compacted = content.map((block, index) => {
3606
+ if (!block || typeof block !== "object") return block;
3607
+ const typed = block as { type?: unknown; text?: unknown };
3608
+ if (typed.type !== "text" || typeof typed.text !== "string" || !shouldCompactToolResultText(typed.text)) return block;
3609
+ const source = `content[${index}].text`;
3610
+ const capture = writeToolResultCapture(cwd, event, ctx, source, typed.text, cache);
3611
+ captures.push(capture);
3612
+ return {
3613
+ ...block,
3614
+ text: compactToolResultPreview(String(event?.toolName ?? "tool"), source, typed.text, capture)
3615
+ };
3616
+ });
3617
+ return { content: compacted, captures };
3618
+ }
3619
+
3620
+ function compactToolResultDetails(
3621
+ cwd: string,
3622
+ event: any,
3623
+ ctx: ExtensionContext,
3624
+ value: unknown,
3625
+ cache: Map<string, ToolResultCaptureSummary>,
3626
+ captures: ToolResultCaptureSummary[],
3627
+ source = "details",
3628
+ depth = 0
3629
+ ): unknown {
3630
+ if (typeof value === "string") {
3631
+ if (!shouldCompactToolResultText(value)) return value;
3632
+ const capture = writeToolResultCapture(cwd, event, ctx, source, value, cache);
3633
+ captures.push(capture);
3634
+ return compactToolResultPreview(String(event?.toolName ?? "tool"), source, value, capture);
3635
+ }
3636
+ if (Array.isArray(value)) {
3637
+ if (depth >= 6) return value;
3638
+ return value.map((item, index) => compactToolResultDetails(cwd, event, ctx, item, cache, captures, `${source}[${index}]`, depth + 1));
3639
+ }
3640
+ if (isPlainRecord(value)) {
3641
+ if (depth >= 6) return value;
3642
+ return Object.fromEntries(
3643
+ Object.entries(value).map(([key, item]) => [
3644
+ key,
3645
+ compactToolResultDetails(cwd, event, ctx, item, cache, captures, `${source}.${key}`, depth + 1)
3646
+ ])
3647
+ );
3648
+ }
3649
+ return value;
3650
+ }
3651
+
3652
+ function attachToolResultCompactionDetails(details: unknown, captures: ToolResultCaptureSummary[]): unknown {
3653
+ if (captures.length === 0) return details;
3654
+ const compacted = captures.map((capture) => ({
3655
+ path: capture.path,
3656
+ error: capture.error,
3657
+ source: capture.source,
3658
+ toolName: capture.toolName,
3659
+ originalChars: capture.originalChars,
3660
+ originalLines: capture.originalLines,
3661
+ storedChars: capture.storedChars,
3662
+ storedTruncated: capture.storedTruncated,
3663
+ sha256: capture.sha256
3664
+ }));
3665
+ if (isPlainRecord(details)) return { ...details, piagentCompactedToolResults: compacted };
3666
+ if (details === undefined) return { piagentCompactedToolResults: compacted };
3667
+ return { value: details, piagentCompactedToolResults: compacted };
3668
+ }
3669
+
3670
+ function readRecentToolResultCaptures(cwd: string, limit = 5): Record<string, unknown>[] {
3671
+ const indexPath = toolResultCaptureIndexPath(cwd);
3672
+ if (!fs.existsSync(indexPath)) return [];
3673
+ const lines = fs.readFileSync(indexPath, "utf8").trim().split(/\n/).filter(Boolean);
3674
+ const captures: Record<string, unknown>[] = [];
3675
+ for (const line of lines.slice(-Math.max(limit * 3, limit))) {
3676
+ try {
3677
+ const parsed = JSON.parse(line);
3678
+ if (isPlainRecord(parsed)) captures.push(redactForStorage(parsed) as Record<string, unknown>);
3679
+ } catch {
3680
+ // A partial JSONL line should not make a status command noisy or unusable.
3681
+ }
3682
+ }
3683
+ return captures.slice(-limit);
3684
+ }
3685
+
3686
+ function formatToolResultCaptureStatus(cwd: string, captures: Record<string, unknown>[]): string {
3687
+ const root = normalizeRelative(cwd, toolResultCaptureRoot(cwd)) ?? ".pi/piagent-state/tool-results";
3688
+ const lines = [
3689
+ `logPolicy: compact above ${formatCount(TOOL_RESULT_COMPACT_CHAR_THRESHOLD)} chars or ${formatCount(TOOL_RESULT_COMPACT_LINE_THRESHOLD)} lines`,
3690
+ `captureRoot: ${root}`,
3691
+ `recent: ${captures.length || "none"}`
3692
+ ];
3693
+ for (const capture of captures) {
3694
+ const originalLines = typeof capture.originalLines === "number" ? formatCount(capture.originalLines) : "unknown";
3695
+ const originalChars = typeof capture.originalChars === "number" ? formatCount(capture.originalChars) : "unknown";
3696
+ const exit = capture.exitCode === undefined ? "" : ` exit=${capture.exitCode}`;
3697
+ lines.push(`- ${capture.recordedAt ?? "unknown"} ${capture.toolName ?? "tool"}${exit}: ${originalLines} lines / ${originalChars} chars -> ${capture.path ?? "no capture path"}`);
3698
+ }
3699
+ return lines.join("\n");
3700
+ }
3701
+
3287
3702
  function buildUsageSnapshot(ctx: ExtensionContext, thinkingLevel?: string): UsageSnapshot {
3288
3703
  const contextUsage = ctx.getContextUsage();
3289
3704
  const contextWithThinking = ctx as ExtensionContext & { getThinkingLevel?: () => string };
@@ -3310,7 +3725,8 @@ function buildUsageSnapshot(ctx: ExtensionContext, thinkingLevel?: string): Usag
3310
3725
  availableInCommand: false,
3311
3726
  howToRead: [
3312
3727
  "Inside Pi TUI: run /session for exact tokens and cost.",
3313
- "Outside Pi: run piagent-usage /path/to/project or scripts/pi-session-stats.sh /path/to/project."
3728
+ "Outside Pi: run piagent-usage /path/to/project or scripts/pi-session-stats.sh /path/to/project.",
3729
+ "Historical totals: run piagent-usage --history /path/to/project --days 7."
3314
3730
  ]
3315
3731
  }
3316
3732
  };
@@ -3321,24 +3737,13 @@ function formatUsageSnapshot(snapshot: UsageSnapshot): string {
3321
3737
  ? `${formatCount(snapshot.contextUsage.tokens)} / ${formatCount(snapshot.contextUsage.contextWindow)} tokens (${formatPercent(snapshot.contextUsage.percent)})`
3322
3738
  : "unavailable";
3323
3739
  return [
3324
- "# Piagent usage snapshot",
3325
- "",
3326
- `- Session: ${snapshot.sessionName ?? "unnamed"} (${snapshot.sessionId ?? "unknown"})`,
3327
- `- Session file: ${snapshot.sessionFile ?? "not persisted"}`,
3328
- `- CWD: ${snapshot.cwd}`,
3329
- `- Mode: ${snapshot.mode}`,
3330
- `- Model: ${snapshot.model}`,
3331
- `- Thinking: ${snapshot.thinkingLevel}`,
3332
- `- Entries: ${formatCount(snapshot.entries.branch)} on active branch / ${formatCount(snapshot.entries.total)} total`,
3333
- `- Live context: ${context}`,
3334
- "",
3335
- "Exact billed input/output/cache/cost totals are exposed by Pi via `/session` or RPC `get_session_stats`, not directly inside this command context.",
3336
- "",
3337
- "From another terminal:",
3338
- "",
3339
- "```bash",
3340
- "piagent-usage /path/to/project",
3341
- "```"
3740
+ `usage: ${context}`,
3741
+ `session: ${snapshot.sessionName ?? "unnamed"} (${snapshot.sessionId ?? "unknown"})`,
3742
+ `model: ${snapshot.model}; thinking: ${snapshot.thinkingLevel}`,
3743
+ `entries: ${formatCount(snapshot.entries.branch)} active / ${formatCount(snapshot.entries.total)} total`,
3744
+ `file: ${snapshot.sessionFile ?? "not persisted"}`,
3745
+ "exact: /session | piagent-usage /path/to/project",
3746
+ "history: piagent-usage --history /path/to/project --days 7"
3342
3747
  ].join("\n");
3343
3748
  }
3344
3749
 
@@ -3405,30 +3810,14 @@ function formatContextPreflight(preflight: ContextPreflight, snapshot: UsageSnap
3405
3810
  ? `${formatCount(preflight.projectedContext.tokens)} (${formatPercent(preflight.projectedContext.percent)})`
3406
3811
  : "unavailable";
3407
3812
  return [
3408
- "# Piagent task preflight",
3409
- "",
3410
- `- Workflow: ${preflight.workflow}`,
3411
- `- Session: ${snapshot.sessionName ?? "unnamed"} (${snapshot.sessionId ?? "unknown"})`,
3412
- `- Model: ${snapshot.model}`,
3413
- `- Thinking: ${snapshot.thinkingLevel}`,
3414
- `- Entries: ${formatCount(snapshot.entries.branch)} active / ${formatCount(snapshot.entries.total)} total`,
3415
- `- Live context: ${live}`,
3416
- `- Incoming input estimate: ${formatCount(preflight.inputTokenEstimate)} tokens from ${formatCount(preflight.inputChars)} chars`,
3417
- `- Projected context: ${projected}`,
3418
- `- Recommendation: ${preflight.recommendation}`,
3419
- `- Reason: ${preflight.reason}`,
3420
- "",
3421
- "Commands:",
3422
- "",
3423
- "```text",
3424
- ...preflight.commands,
3425
- "```",
3426
- "",
3427
- "Notes:",
3428
- "",
3429
- "- Do not paste the full mandatory flow into every task. Platform prompts and piagent tools already carry it.",
3430
- "- Use `/scout` for read-only risk mapping. Use `/fresh-scout` when the current session is already heavy.",
3431
- "- If exact billed token/cost totals are needed, run `/session` in Pi or `piagent-usage <project-path>` from another terminal."
3813
+ `preflight: ${preflight.recommendation}`,
3814
+ `workflow: ${preflight.workflow}`,
3815
+ `session: ${snapshot.sessionName ?? "unnamed"} (${snapshot.sessionId ?? "unknown"})`,
3816
+ `model: ${snapshot.model}; thinking: ${snapshot.thinkingLevel}`,
3817
+ `context: ${live}; projected: ${projected}`,
3818
+ `input: ~${formatCount(preflight.inputTokenEstimate)} tokens from ${formatCount(preflight.inputChars)} chars`,
3819
+ `reason: ${preflight.reason}`,
3820
+ `next: ${preflight.commands.join(" | ")}`
3432
3821
  ].join("\n");
3433
3822
  }
3434
3823
 
@@ -3496,7 +3885,7 @@ function isPiagentWorkflowInput(text: string): boolean {
3496
3885
  }
3497
3886
 
3498
3887
  function isFreshOrUtilityInput(text: string): boolean {
3499
- return /^\/(?:fresh-task|fresh-scout|fresh-be-to-fe|task-preflight|piagent-usage|session|compact)\b/i.test(text.trim());
3888
+ return /^\/(?:fresh-task|fresh-scout|fresh-be-to-fe|task-preflight|piagent-usage|piagent-logs|setname|session|compact)\b/i.test(text.trim());
3500
3889
  }
3501
3890
 
3502
3891
  function taskInboxDir(cwd: string): string {
@@ -3823,7 +4212,10 @@ export default function piagentGuard(pi: ExtensionAPI) {
3823
4212
  const explicitProfile = Boolean(process.env.PIAGENT_PROFILE?.trim());
3824
4213
  const profile = loadProfile(ctx.cwd, projectTrusted);
3825
4214
  const name = profile.displayName || profile.projectId || path.basename(ctx.cwd);
3826
- pi.setSessionName(`pi:${name}`);
4215
+ const operatorSessionName = currentSessionName(ctx);
4216
+ if (!hasOperatorSessionName(operatorSessionName)) {
4217
+ pi.setSessionName(`pi:${name}`);
4218
+ }
3827
4219
  const profileHint = explicitProfile || (projectTrusted && fs.existsSync(projectProfilePath(ctx.cwd)))
3828
4220
  ? ""
3829
4221
  : " (run /onboard-project to select a profile)";
@@ -3965,6 +4357,24 @@ export default function piagentGuard(pi: ExtensionAPI) {
3965
4357
  resultChanged = true;
3966
4358
  }
3967
4359
 
4360
+ const captureCache = new Map<string, ToolResultCaptureSummary>();
4361
+ const compactedContent = compactToolResultTextContent(ctx.cwd, event, ctx, resultContent, captureCache);
4362
+ const compactionCaptures = [...compactedContent.captures];
4363
+ if (compactedContent.captures.length > 0) {
4364
+ resultContent = compactedContent.content;
4365
+ resultChanged = true;
4366
+ }
4367
+ if (resultDetails !== undefined) {
4368
+ const compactedDetails = compactToolResultDetails(ctx.cwd, event, ctx, resultDetails, captureCache, compactionCaptures);
4369
+ if (compactionCaptures.length > compactedContent.captures.length) {
4370
+ resultDetails = compactedDetails;
4371
+ resultChanged = true;
4372
+ }
4373
+ }
4374
+ if (compactionCaptures.length > 0) {
4375
+ resultDetails = attachToolResultCompactionDetails(resultDetails, compactionCaptures);
4376
+ }
4377
+
3968
4378
  if (resultChanged) {
3969
4379
  return resultDetails === undefined
3970
4380
  ? { content: resultContent }
@@ -4047,6 +4457,12 @@ export default function piagentGuard(pi: ExtensionAPI) {
4047
4457
  reason: `Command resolves to protected path: ${resolvedProtectedHit.candidate} resolves to ${resolvedProtectedHit.resolved} matching ${resolvedProtectedHit.pattern}`
4048
4458
  };
4049
4459
  }
4460
+ const unresolvedExpansions = pathPolicy.shellProtectedPaths.length > 0
4461
+ ? unresolvedPathExpansions(command)
4462
+ : [];
4463
+ if (unresolvedExpansions.length > 0) {
4464
+ return { block: true, reason: unresolvedExpansionReason("Command", unresolvedExpansions) };
4465
+ }
4050
4466
 
4051
4467
  const confirmationReasons = execDecision.mode !== "off" && execDecision.decision === "prompt"
4052
4468
  ? [...execDecision.reasons]
@@ -4263,8 +4679,8 @@ export default function piagentGuard(pi: ExtensionAPI) {
4263
4679
  }
4264
4680
  });
4265
4681
 
4266
- pi.registerTool({
4267
- name: "piagent_exec_policy_check",
4682
+ pi.registerTool({
4683
+ name: "piagent_exec_policy_check",
4268
4684
  label: "Piagent Exec Policy Check",
4269
4685
  description: "Evaluate a shell command against piagent exec policy before running it.",
4270
4686
  promptSnippet: "Use this before high-impact, complex, generated, or unfamiliar shell commands.",
@@ -6147,6 +6563,59 @@ export default function piagentGuard(pi: ExtensionAPI) {
6147
6563
  }
6148
6564
  });
6149
6565
 
6566
+ pi.registerCommand("piagent-logs", {
6567
+ description: "Show compact log policy and recent captured large tool outputs",
6568
+ handler: async (_args, ctx) => {
6569
+ const captures = readRecentToolResultCaptures(ctx.cwd, 5);
6570
+ ctx.ui.notify(`Piagent logs: ${captures.length ? `${captures.length} recent capture(s)` : "no compacted captures yet"}`, "info");
6571
+ pi.sendMessage(
6572
+ {
6573
+ customType: "piagent-log-captures",
6574
+ content: formatToolResultCaptureStatus(ctx.cwd, captures),
6575
+ display: true,
6576
+ details: {
6577
+ policy: {
6578
+ compactAboveChars: TOOL_RESULT_COMPACT_CHAR_THRESHOLD,
6579
+ compactAboveLines: TOOL_RESULT_COMPACT_LINE_THRESHOLD,
6580
+ previewMaxChars: TOOL_RESULT_PREVIEW_MAX_CHARS,
6581
+ captureMaxChars: TOOL_RESULT_CAPTURE_MAX_CHARS
6582
+ },
6583
+ captures
6584
+ }
6585
+ },
6586
+ { triggerTurn: false }
6587
+ );
6588
+ }
6589
+ });
6590
+
6591
+ pi.registerCommand("setname", {
6592
+ description: "Set the current Pi session name for reports and resume lists",
6593
+ handler: async (args, ctx) => {
6594
+ const name = cleanSessionNameInput(String(args ?? ""));
6595
+ if (!name) {
6596
+ ctx.ui.notify("Usage: /setname <task/session name>", "warning");
6597
+ return;
6598
+ }
6599
+ const previousName = currentSessionName(ctx);
6600
+ pi.setSessionName(name);
6601
+ appendSessionTrace(pi, {
6602
+ event: "session_name_set",
6603
+ previousName: previousName || undefined,
6604
+ sessionName: name
6605
+ });
6606
+ ctx.ui.notify(`Session name set: ${name}`, "info");
6607
+ pi.sendMessage(
6608
+ {
6609
+ customType: "piagent-session-name-set",
6610
+ content: `sessionName: ${name}`,
6611
+ display: true,
6612
+ details: { sessionName: name, previousName: previousName || undefined }
6613
+ },
6614
+ { triggerTurn: false }
6615
+ );
6616
+ }
6617
+ });
6618
+
6150
6619
  pi.registerCommand("task-preflight", {
6151
6620
  description: "Check context health before a large task; optionally compact",
6152
6621
  handler: async (args, ctx) => {