@piagent/platform 1.4.0 → 1.4.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 (100) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +8 -2
  3. package/README.vi.md +27 -0
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +7 -7
  6. package/docs/en/architecture.md +13 -0
  7. package/docs/languages.json +20 -4
  8. package/docs/mcp-and-tools.md +112 -1
  9. package/docs/operator-manual-vietnamese.md +7 -7
  10. package/docs/publishing-for-teams.md +36 -0
  11. package/docs/release-install-policy.md +21 -8
  12. package/docs/runtime-harness-standard.md +1 -1
  13. package/docs/security-threat-model.md +1 -1
  14. package/docs/task-implementation-contract.md +11 -4
  15. package/docs/team-onboarding.md +2 -2
  16. package/docs/usage-observability.md +70 -0
  17. package/docs/vi/architecture.md +13 -0
  18. package/docs-site/architecture.html +5 -1
  19. package/docs-site/benchmark.html +5 -1
  20. package/docs-site/commands.html +5 -1
  21. package/docs-site/content/en/index.html +49 -0
  22. package/docs-site/content/en/runtime.html +13 -0
  23. package/docs-site/content/index.html +49 -0
  24. package/docs-site/content/runtime.html +28 -0
  25. package/docs-site/context-engine.html +5 -1
  26. package/docs-site/en/architecture.html +5 -1
  27. package/docs-site/en/benchmark.html +5 -1
  28. package/docs-site/en/commands.html +5 -1
  29. package/docs-site/en/context-engine.html +5 -1
  30. package/docs-site/en/index.html +54 -1
  31. package/docs-site/en/mcp-approval.html +5 -1
  32. package/docs-site/en/mcp-auth.html +5 -1
  33. package/docs-site/en/mcp-servers.html +5 -1
  34. package/docs-site/en/mcp.html +5 -1
  35. package/docs-site/en/permissions.html +5 -1
  36. package/docs-site/en/profiles.html +5 -1
  37. package/docs-site/en/quickstart.html +5 -1
  38. package/docs-site/en/runtime.html +18 -1
  39. package/docs-site/en/scope.html +5 -1
  40. package/docs-site/en/security.html +5 -1
  41. package/docs-site/en/team.html +5 -1
  42. package/docs-site/en/whats-new.html +5 -1
  43. package/docs-site/en/workflows.html +5 -1
  44. package/docs-site/index.html +54 -1
  45. package/docs-site/mcp-approval.html +5 -1
  46. package/docs-site/mcp-auth.html +5 -1
  47. package/docs-site/mcp-servers.html +5 -1
  48. package/docs-site/mcp.html +5 -1
  49. package/docs-site/permissions.html +5 -1
  50. package/docs-site/profiles.html +5 -1
  51. package/docs-site/quickstart.html +5 -1
  52. package/docs-site/runtime.html +33 -1
  53. package/docs-site/scope.html +5 -1
  54. package/docs-site/security.html +5 -1
  55. package/docs-site/team.html +5 -1
  56. package/docs-site/whats-new.html +5 -1
  57. package/docs-site/workflows.html +5 -1
  58. package/package.json +14 -2
  59. package/packages/piagent-core/README.md +3 -2
  60. package/packages/piagent-core/capabilities/capability-verification-cache.js +17 -7
  61. package/packages/piagent-core/capabilities/runtime-integrity.js +51 -4
  62. package/packages/piagent-core/extensions/piagent-guard.ts +18 -235
  63. package/packages/piagent-core/extensions/runtime-evidence.js +12 -1
  64. package/packages/piagent-core/extensions/shell-reach.ts +250 -0
  65. package/packages/piagent-core/mcp/mcp-command-actions.js +1 -1
  66. package/packages/piagent-core/mcp/mcp-server-catalog.js +135 -5
  67. package/packages/piagent-core/package.json +1 -1
  68. package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +9 -2
  69. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +25 -2
  70. package/packages/piagent-core/runtime/policy/block-remedy.ts +139 -0
  71. package/packages/piagent-core/runtime/registration/task-start-tool.ts +9 -13
  72. package/packages/piagent-core/runtime/session/token-economics.js +34 -0
  73. package/packages/piagent-core/runtime/tools/phase-tool-runtime.ts +2 -1
  74. package/packages/piagent-core/runtime/workflows/task-intake.ts +5 -0
  75. package/packages/piagent-core/security/sensitive-data.js +7 -0
  76. package/packages/piagent-webui/dist/client/assets/index-bu7XpD9Q.js +172 -0
  77. package/packages/piagent-webui/dist/client/index.html +1 -1
  78. package/packages/piagent-webui/extension/lifecycle-controller.ts +15 -0
  79. package/packages/piagent-webui/extension/piagent-webui.ts +4 -4
  80. package/packages/piagent-webui/extension/same-session-bridge.ts +3 -3
  81. package/packages/piagent-webui/gateway/extension-authority.ts +47 -0
  82. package/packages/piagent-webui/gateway/session-inspection-registry.ts +37 -2
  83. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +5 -1
  84. package/packages/piagent-webui/server/core-inspection-provider.ts +5 -5
  85. package/packages/piagent-webui/server/transcript-projection.ts +15 -0
  86. package/scripts/build-docs-site.mjs +21 -1
  87. package/scripts/explain-command.mjs +240 -0
  88. package/scripts/install-global.sh +104 -3
  89. package/scripts/mcp-manage.mjs +17 -4
  90. package/scripts/pi-session-stats.sh +10 -2
  91. package/scripts/piagent-cli.mjs +14 -1
  92. package/scripts/piagent-dashboard.mjs +55 -2
  93. package/scripts/setup.sh +69 -33
  94. package/scripts/traffic-snapshot.mjs +214 -0
  95. package/scripts/typescript-loader.mjs +20 -4
  96. package/scripts/uninstall-global.sh +53 -2
  97. package/scripts/update-global.mjs +33 -1
  98. package/scripts/verify-local.sh +4 -0
  99. package/scripts/verify-translation-pairing.mjs +121 -0
  100. package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +0 -172
@@ -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-DjMntaK5.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-bu7XpD9Q.js"></script>
11
11
  <link rel="stylesheet" crossorigin href="/assets/index-DwKCWbwd.css">
12
12
  </head>
13
13
  <body>
@@ -131,6 +131,21 @@ export class LifecycleController {
131
131
  return !inspectTaskControlState(current.cwd, task).dispatchBlocked && !this.#pendingStop;
132
132
  }
133
133
 
134
+ inputAllowed(ctx?: ExtensionContext): boolean {
135
+ const current = ctx ?? this.#ctx; if (!current || !this.#sameSession(current) || this.#pendingStop) return false;
136
+ const task = this.#task(current); if (!task) return true;
137
+ const state = inspectTaskControlState(current.cwd, task).state;
138
+ return state === "active" || state === "terminal";
139
+ }
140
+
141
+ toolAllowed(toolName: string, ctx?: ExtensionContext): boolean {
142
+ if (this.dispatchAllowed(ctx)) return true;
143
+ const current = ctx ?? this.#ctx; if (!current || !this.#sameSession(current) || this.#pendingStop) return false;
144
+ const task = this.#task(current);
145
+ return toolName === "piagent_task_start" && Boolean(task)
146
+ && inspectTaskControlState(current.cwd, task).state === "terminal";
147
+ }
148
+
134
149
  observeAgentStart(ctx: ExtensionContext): void { if (!this.#sameSession(ctx)) return; this.#phase = "model"; }
135
150
  observeToolStart(ctx: ExtensionContext): void { if (!this.#sameSession(ctx)) return; this.#toolDepth += 1; this.#phase = "tool"; }
136
151
  observeToolEnd(ctx: ExtensionContext): void { if (!this.#sameSession(ctx)) return; this.#toolDepth = Math.max(0, this.#toolDepth - 1);
@@ -398,10 +398,10 @@ export default function piagentWebUiExtension(pi: ExtensionAPI): void {
398
398
  }
399
399
  });
400
400
 
401
- pi.on("tool_call", (_event, ctx) => { publishPersisted(ctx); if (!isGatewayRuntimeContext(ctx) && !terminalAdapter?.dispatchAllowed(ctx)) return { block: true,
401
+ pi.on("tool_call", (event, ctx) => { publishPersisted(ctx); if (!isGatewayRuntimeContext(ctx) && !terminalAdapter?.dispatchAllowed(ctx)) return { block: true,
402
402
  reason: `Piagent cannot prove this terminal owns the session (${terminalAdapter?.reasonCode() ?? "terminal-session-adapter-unavailable"}).` };
403
- if (!lifecycle.dispatchAllowed(ctx)) return { block: true,
404
- reason: "Piagent lifecycle control blocks new tool work while the task is paused or stopping." }; });
403
+ if (!lifecycle.toolAllowed(String(event.toolName ?? ""), ctx)) return { block: true,
404
+ reason: "Piagent lifecycle control blocks tool work while the task is paused, stopping, or terminal. A terminal task may only start its successor." }; });
405
405
  pi.on("tool_result", (_event, ctx) => publishPersisted(ctx));
406
406
  pi.on("input", (event, ctx) => {
407
407
  if (!isGatewayRuntimeContext(ctx) && !terminalAdapter?.dispatchAllowed(ctx)) {
@@ -409,7 +409,7 @@ export default function piagentWebUiExtension(pi: ExtensionAPI): void {
409
409
  catch { /* optional UI */ }
410
410
  return { action: "handled" } as const;
411
411
  }
412
- if (!lifecycle.dispatchAllowed(ctx)) {
412
+ if (!lifecycle.inputAllowed(ctx)) {
413
413
  try { ctx.ui.notify("Piagent task is paused or stopping. Resume it before sending new work.", "warning"); } catch { /* optional UI */ }
414
414
  return { action: "handled" } as const;
415
415
  }
@@ -371,11 +371,11 @@ export class SameSessionPiBridge {
371
371
  return this.#rejection(command, "stale-revision", "stale-revision", "The current Pi session changed before dispatch.");
372
372
  if (this.#sessionOptionPermit)
373
373
  return this.#rejection(command, "capability-unavailable", "session-option-change-in-progress", "A model or thinking change is being settled.");
374
+ const delivery = command.payload.delivery;
374
375
  const controlState = this.#taskFacts(binding.ctx)?.controlState;
375
- if (controlState && controlState !== "active")
376
+ if (controlState && controlState !== "active" && !(controlState === "terminal" && delivery === "new-operation"))
376
377
  return this.#rejection(command, "capability-unavailable", controlState === "terminal" ? "task-terminal" : "task-control-gate-closed",
377
- controlState === "terminal" ? "A completed task cannot accept another dispatch." : "Task lifecycle control currently blocks dispatch.");
378
- const delivery = command.payload.delivery;
378
+ controlState === "terminal" ? "A terminal task accepts only a new operation that can establish its successor." : "Task lifecycle control currently blocks dispatch.");
379
379
  if (delivery === "new-operation" && (!binding.ctx.isIdle() || binding.identity.agentOperationId))
380
380
  return this.#rejection(command, "dispatch-rejected", "agent-not-idle", "A new operation can start only while the Pi session is idle.");
381
381
  if (delivery !== "new-operation" && (binding.ctx.isIdle() || !binding.identity.agentOperationId))
@@ -0,0 +1,47 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ type ExtensionLoadResult = {
5
+ extensions: Array<{ path: string; resolvedPath: string }>;
6
+ errors: Array<{ path: string; error: string }>;
7
+ runtime: unknown;
8
+ };
9
+
10
+ const PIAGENT_GUARD_SUFFIX = path.join("packages", "piagent-core", "extensions", "piagent-guard.ts");
11
+
12
+ function canonicalExtensionPath(value: string): string {
13
+ try { return fs.realpathSync.native(value); }
14
+ catch { return path.resolve(value); }
15
+ }
16
+
17
+ function isPiagentGuardPath(value: string): boolean {
18
+ const normalized = path.normalize(value);
19
+ return normalized === PIAGENT_GUARD_SUFFIX || normalized.endsWith(`${path.sep}${PIAGENT_GUARD_SUFFIX}`);
20
+ }
21
+
22
+ export function preferAuthoritativePiagentGuard(
23
+ guardPath: string
24
+ ): (base: ExtensionLoadResult) => ExtensionLoadResult {
25
+ const authoritative = canonicalExtensionPath(guardPath);
26
+ const authorityAliases = [guardPath, path.resolve(guardPath), authoritative].map((value) => path.normalize(value));
27
+ return (base) => {
28
+ const loadedAuthoritative = base.extensions.some((extension) =>
29
+ canonicalExtensionPath(extension.resolvedPath || extension.path) === authoritative);
30
+ if (!loadedAuthoritative) return base;
31
+ const removed = new Set<string>();
32
+ const extensions = base.extensions.filter((extension) => {
33
+ const resolved = canonicalExtensionPath(extension.resolvedPath || extension.path);
34
+ const duplicate = resolved !== authoritative && isPiagentGuardPath(resolved);
35
+ if (duplicate) removed.add(resolved);
36
+ return !duplicate;
37
+ });
38
+ if (removed.size === 0) return base;
39
+ const errors = base.errors.filter((diagnostic) => {
40
+ const diagnosticPath = canonicalExtensionPath(diagnostic.path);
41
+ return !(removed.has(diagnosticPath)
42
+ && diagnostic.error.includes("conflicts with")
43
+ && authorityAliases.some((value) => diagnostic.error.includes(value)));
44
+ });
45
+ return { ...base, extensions, errors };
46
+ };
47
+ }
@@ -19,9 +19,12 @@ type PiHost = {
19
19
  listAll(): Promise<PiSessionInfo[]>;
20
20
  open(file: string): {
21
21
  getBranch(): unknown[];
22
- buildSessionContext(): { model: { provider: string; modelId: string } | null; thinkingLevel: string };
22
+ buildSessionContext(): { model: { provider: string; modelId: string } | null; thinkingLevel: string; messages: unknown[] };
23
23
  };
24
24
  };
25
+ calculateContextTokens(usage: unknown): number;
26
+ estimateTokens(message: unknown): number;
27
+ getLatestCompactionEntry(entries: unknown[]): unknown | null;
25
28
  };
26
29
 
27
30
  type ModelRuntime = {
@@ -59,6 +62,36 @@ function safeModelId(value: unknown): string | null {
59
62
  return redactSensitiveText(value).redacted ? null : value;
60
63
  }
61
64
 
65
+ function persistedContextUsage(host: PiHost, entries: unknown[], messages: unknown[], model: Record<string, unknown> | undefined) {
66
+ const contextWindow = Number(model?.contextWindow);
67
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) return undefined;
68
+ const latestCompaction = host.getLatestCompactionEntry(entries);
69
+ if (latestCompaction) {
70
+ const boundary = entries.lastIndexOf(latestCompaction);
71
+ let hasPostCompactionUsage = false;
72
+ for (let index = entries.length - 1; index > boundary; index -= 1) {
73
+ const entry = entries[index] as { type?: unknown; message?: { role?: unknown; stopReason?: unknown; usage?: unknown } } | undefined;
74
+ const message = entry?.type === "message" ? entry.message : undefined;
75
+ if (message?.role === "assistant" && message.usage && message.stopReason !== "aborted" && message.stopReason !== "error"
76
+ && host.calculateContextTokens(message.usage) > 0) {
77
+ hasPostCompactionUsage = true;
78
+ break;
79
+ }
80
+ }
81
+ if (!hasPostCompactionUsage) return { tokens: null, contextWindow, percent: null };
82
+ }
83
+ let lastUsageIndex = -1, tokens = 0;
84
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
85
+ const message = messages[index] as { role?: unknown; stopReason?: unknown; usage?: unknown } | undefined;
86
+ if (message?.role !== "assistant" || !message.usage || message.stopReason === "aborted" || message.stopReason === "error") continue;
87
+ const measured = host.calculateContextTokens(message.usage);
88
+ if (measured > 0) { tokens = measured; lastUsageIndex = index; break; }
89
+ }
90
+ for (let index = lastUsageIndex + 1; index < messages.length; index += 1) tokens += host.estimateTokens(messages[index]);
91
+ if (!Number.isFinite(tokens) || tokens < 0) return undefined;
92
+ return { tokens, contextWindow, percent: tokens / contextWindow * 100 };
93
+ }
94
+
62
95
  export class SessionInspectionRegistry {
63
96
  readonly #gatewayInstanceRef: string;
64
97
  readonly #host: PiHost;
@@ -173,9 +206,10 @@ export class SessionInspectionRegistry {
173
206
  let manager: ReturnType<PiHost["SessionManager"]["open"]>;
174
207
  try { manager = this.#host.SessionManager.open(info.path); }
175
208
  catch { throw new ReadModelNotFound(); }
176
- const context = safeRead(() => manager.buildSessionContext(), { model: null, thinkingLevel: "unknown" });
209
+ const context = safeRead(() => manager.buildSessionContext(), { model: null, thinkingLevel: "unknown", messages: [] });
177
210
  const model = context.model ? this.#models?.getModel(context.model.provider, context.model.modelId) : undefined;
178
211
  const entries = safeRead(() => manager.getBranch(), []);
212
+ const contextUsage = safeRead(() => persistedContextUsage(this.#host, entries, context.messages, model), undefined);
179
213
  const eventStore = {
180
214
  retention: () => ({ eventRetentionCount: 0, eventRetentionSeconds: 0 }),
181
215
  currentCursor: () => null,
@@ -191,6 +225,7 @@ export class SessionInspectionRegistry {
191
225
  activityEvents: () => safeRead(() => readContextTelemetry(info.cwd, { limit: 5_000 }), []),
192
226
  sessionEntries: () => entries,
193
227
  protectedPaths: () => runtimeProtectedPaths(this.#packageRoot, info.cwd),
228
+ contextUsage: () => contextUsage,
194
229
  model: () => model,
195
230
  thinkingLevel: () => context.thinkingLevel,
196
231
  approvalProjection: () => piApprovalBroker.projection(info.cwd, info.id),
@@ -12,6 +12,7 @@ import { SessionLeaseStore, type SessionLeaseSnapshot } from "./session-lease-st
12
12
  import { GatewayEventStore } from "./gateway-events.ts";
13
13
  import { GatewaySessionStream } from "./gateway-session-stream.ts";
14
14
  import { GATEWAY_RUNTIME_UI_MARKER } from "../ownership/gateway-runtime-context.ts";
15
+ import { preferAuthoritativePiagentGuard } from "./extension-authority.ts";
15
16
 
16
17
  const MAX_WARM_RUNTIMES = 10;
17
18
 
@@ -55,7 +56,10 @@ function productionRuntimeFactory(options: { host: any; agentDir: string; packag
55
56
  const createRuntime = async ({ cwd, agentDir, sessionManager, sessionStartEvent }: any) => {
56
57
  const services = await options.host.createAgentSessionServices({
57
58
  cwd, agentDir, modelRuntime: options.modelRuntime,
58
- resourceLoaderOptions: { additionalExtensionPaths: [guard] }
59
+ resourceLoaderOptions: {
60
+ additionalExtensionPaths: [guard],
61
+ extensionsOverride: preferAuthoritativePiagentGuard(guard)
62
+ }
59
63
  });
60
64
  const extensionErrors = services.resourceLoader.getExtensions().errors;
61
65
  if (extensionErrors.length) throw new Error("session-runtime-extension-load-failed");
@@ -101,7 +101,7 @@ export class CoreInspectionProvider implements WebUiReadModelProvider {
101
101
  if (control?.state === "ready" && control.identity && control.revisions && !this.#input.eventStore.resyncRequired()) {
102
102
  const available = { available: true, reasonCode: null }, disabled = (reasonCode: string) => ({ available: false, reasonCode });
103
103
  const lifecycle = this.#input.lifecycleControl?.(), terminal = control.taskState === "terminal";
104
- const dispatchBlocked = terminal || lifecycle?.dispatchBlocked === true;
104
+ const dispatchBlocked = lifecycle?.dispatchBlocked === true && lifecycle.state !== "terminal";
105
105
  value.snapshot.identity = structuredClone(control.identity);
106
106
  value.snapshot.capabilities.identity = structuredClone(control.identity);
107
107
  for (const name of ["runtimeRevision", "taskRevision", "controlRevision", "sessionOptionRevision", "queueRevision"])
@@ -109,11 +109,11 @@ export class CoreInspectionProvider implements WebUiReadModelProvider {
109
109
  value.snapshot.capabilities.mode = "control-enabled";
110
110
  value.snapshot.capabilities.capabilities["control.chat"] = {
111
111
  status: "available", version: 1, reason: null, queuePersistence: "runtime-lifetime",
112
- actions: { send: dispatchBlocked ? disabled(terminal ? "task-terminal" : "task-control-gate-closed") : available, hold: available, editHeld: available,
113
- deleteHeld: available, dispatchHeld: dispatchBlocked ? disabled(terminal ? "task-terminal" : "task-control-gate-closed") : available,
114
- interruptAndSend: dispatchBlocked ? disabled(terminal ? "task-terminal" : "task-control-gate-closed") : control.liveness === "running" ? available : disabled("agent-not-running") }
112
+ actions: { send: dispatchBlocked ? disabled("task-control-gate-closed") : available, hold: available, editHeld: available,
113
+ deleteHeld: available, dispatchHeld: dispatchBlocked ? disabled("task-control-gate-closed") : available,
114
+ interruptAndSend: dispatchBlocked ? disabled("task-control-gate-closed") : control.liveness === "running" ? available : disabled("agent-not-running") }
115
115
  };
116
- if (!terminal && this.#input.attachmentCapability) {
116
+ if (this.#input.attachmentCapability) {
117
117
  const attachment = this.#input.attachmentCapability();
118
118
  value.snapshot.capabilities.capabilities.attachments = { status: "available", version: 1, reason: null,
119
119
  kinds: attachment.kinds, mimeTypes: attachment.mimeTypes };
@@ -41,6 +41,19 @@ function messageText(message: any): string {
41
41
  if (!Array.isArray(message?.content)) return "";
42
42
  return message.content.filter((item: any) => item?.type === "text" && typeof item.text === "string").map((item: any) => item.text).join("\n");
43
43
  }
44
+ function assistantFailureReason(message: any): string | null {
45
+ if (message?.role !== "assistant" || message?.stopReason !== "error") return null;
46
+ const detail = String(message?.errorMessage ?? "").toLowerCase();
47
+ if (/(?:authentication|auth|token|credential).{0,48}expired|expired.{0,48}(?:authentication|auth|token|credential)/.test(detail)) {
48
+ return "provider-auth-expired";
49
+ }
50
+ if (/(?:authentication|unauthorized|forbidden|credential|api[ _-]?key|log[ -]?in|sign[ -]?in)/.test(detail)) {
51
+ return "provider-auth-required";
52
+ }
53
+ if (/(?:rate[ _-]?limit|too many requests|quota)/.test(detail)) return "provider-rate-limited";
54
+ if (/(?:network|fetch failed|econn|timed? ?out|unavailable)/.test(detail)) return "provider-unavailable";
55
+ return "provider-response-failed";
56
+ }
44
57
  function safeText(value: string): { full: string; preview: string; redacted: boolean; truncated: boolean } {
45
58
  const clean = value.replace(ANSI, "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ");
46
59
  const redaction = redactSensitiveText(clean);
@@ -78,6 +91,8 @@ function item(entry: any, identity: TranscriptIdentity): TranscriptItem | null {
78
91
  if (!itemRole || !recordedAt || typeof entry.id !== "string" || entry.id.length === 0) return null;
79
92
  const content = itemRole === "tool-result" ? unavailableContent("tool-output-in-activity-preview") : (() => {
80
93
  const projected = safeText(messageText(entry.message));
94
+ const failureReason = projected.full.length === 0 ? assistantFailureReason(entry.message) : null;
95
+ if (failureReason) return unavailableContent(failureReason);
81
96
  return { state: projected.redacted ? "redacted" as const : "available" as const, text: projected.preview,
82
97
  textChars: Math.min(1_000_000_000, projected.full.length), digest: `sha256:${hash(projected.full)}`, truncated: projected.truncated,
83
98
  redacted: projected.redacted, imageCount: imageCount(entry.message), reasonCode: projected.redacted ? "sensitive-values-redacted" as const : null };
@@ -24,6 +24,26 @@ const SITE = {
24
24
  facebook: "https://www.facebook.com/vinhtam0544/"
25
25
  };
26
26
 
27
+ // Vercel Web Analytics, added here rather than to a page so all locales and
28
+ // every generated page carry the same one. It is cookieless and stores no
29
+ // identifier in the browser, so the site needs no consent banner -- which is
30
+ // the reason to prefer it over an analytics product that would.
31
+ //
32
+ // The route only exists once Web Analytics is enabled for the Vercel project
33
+ // (dashboard -> Analytics -> Enable) and the site is redeployed. Until then the
34
+ // request 404s and the page is otherwise unaffected: the inline shim below
35
+ // queues calls instead of throwing on an undefined `window.va`.
36
+ //
37
+ // Enabling also provisions a second, project-unique route that ad blockers do
38
+ // not recognise. Swapping this src for that path collects more of the traffic;
39
+ // this stable one is used because it does not embed a per-project secret in a
40
+ // public repository.
41
+ const ANALYTICS_SNIPPET = ` <script>
42
+ window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); };
43
+ </script>
44
+ <script defer src="/_vercel/insights/script.js"></script>
45
+ `;
46
+
27
47
  // Order here is the order in the sidebar, and it is also the order of the
28
48
  // previous/next links at the foot of each page.
29
49
  const NAV = [
@@ -527,7 +547,7 @@ ${renderPager(index, pages, locale)}
527
547
  <nav data-toc></nav>
528
548
  </aside>
529
549
  </div>
530
- </body>
550
+ ${ANALYTICS_SNIPPET} </body>
531
551
  </html>
532
552
  `;
533
553
  }
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env node
2
+ // Answers the one question the guard never answers: why.
3
+ //
4
+ // A blocked command is the only interface most operators ever have with this
5
+ // policy, and a denial that says "no" without saying "what now" leaves them
6
+ // guessing. This runs the guard's static shell preflight, in the same order,
7
+ // over the same resolved protected paths, and prints what each step saw.
8
+ //
9
+ // It reuses `shell-reach.js` rather than re-deriving anything. A second opinion
10
+ // about what a command reaches would be worse than no opinion: the operator
11
+ // would tune a command until this said allow and still be blocked at run time.
12
+ // The final decision also depends on live session/task facts this standalone
13
+ // process does not own, so passing static preflight is deliberately reported as
14
+ // indeterminate rather than as permission to execute.
15
+
16
+ import path from "node:path";
17
+
18
+ import { evaluateExecPolicyCore, findProtectedPathInCommand, unresolvedPathExpansions } from "../packages/piagent-core/extensions/policy-core.js";
19
+ import {
20
+ findResolvedProtectedPathInCommand,
21
+ shellGlobTargetsProtectedPath,
22
+ unresolvedExpansionReason
23
+ // `.ts` rather than the `.js` specifier the TypeScript sources use: this file is
24
+ // plain ESM, so the loader resolves what is actually on disk.
25
+ } from "../packages/piagent-core/extensions/shell-reach.ts";
26
+ import { effectiveProtectedPaths, loadProjectContextIndexPolicy } from "../packages/piagent-core/extensions/context-index-policy.js";
27
+
28
+ const platformRoot = path.resolve(import.meta.dirname, "..");
29
+
30
+ function usage() {
31
+ return [
32
+ "Usage: piagent explain <command> [--project <path>] [--json]",
33
+ " piagent explain --scope [--project <path>] [--json]",
34
+ "",
35
+ "Prints a fail-closed static preflight for a shell command and what to do",
36
+ "next. A static pass is indeterminate until the live Pi runtime evaluates",
37
+ "Task Contract, scope, permission, lifecycle, approval, and budget state.",
38
+ "Nothing is executed.",
39
+ "",
40
+ " --scope List the boundary in effect instead of judging a command",
41
+ " --project <path> Evaluate against this project's profile (default: cwd)",
42
+ " --json Machine-readable output",
43
+ " -h, --help Show this help"
44
+ ].join("\n");
45
+ }
46
+
47
+ function parseArgs(argv) {
48
+ let project = process.cwd(), json = false, scope = false;
49
+ const words = [];
50
+ for (let index = 0; index < argv.length; index += 1) {
51
+ const value = argv[index];
52
+ if (value === "-h" || value === "--help") return { help: true };
53
+ if (value === "--json") { json = true; continue; }
54
+ if (value === "--scope") { scope = true; continue; }
55
+ if (value === "--project") {
56
+ const next = argv[index += 1];
57
+ if (!next || next.startsWith("--")) throw new Error("--project needs a path");
58
+ project = path.resolve(next);
59
+ continue;
60
+ }
61
+ if (value.startsWith("--")) throw new Error(`Unknown option: ${value}`);
62
+ words.push(value);
63
+ }
64
+ return { help: false, project, json, scope, command: words.join(" ").trim() };
65
+ }
66
+
67
+ // What the operator should do next. The guard states 74 refusals and only three
68
+ // of them say this, which is why a denial so often ends the conversation.
69
+ const REMEDY = {
70
+ "protected-path": "Use an approved context projection if one is available. Direct access requires the operator to change the protected-path policy; task scope alone does not override it.",
71
+ "protected-glob": "Name the file you mean instead of a pattern; a pattern is refused when any name it can match is protected.",
72
+ "resolved-protected": "The path resolves to a protected file through a link or a parent directory. Use the real path you intend.",
73
+ "unresolved": "Write the path out, or put the expansion in its own argument, so the guard can see what is being opened.",
74
+ "exec-policy": "This is refused by an exec-policy rule, not by a path. Change the command, or ask the operator to run it.",
75
+ "runtime-required": "Run the command through the current Pi session. Only that runtime owns the Task Contract, scope, permission, lifecycle, approval, and budget facts needed for a final decision."
76
+ };
77
+
78
+ const RUNTIME_GATES = [
79
+ "task-contract",
80
+ "task-scope",
81
+ "permission-profile",
82
+ "lifecycle-control",
83
+ "approval-state",
84
+ "context-budget"
85
+ ];
86
+
87
+ function explain(command, protectedPaths, policy, cwd) {
88
+ const steps = [];
89
+ const record = (name, hit, detail) => { steps.push({ step: name, matched: Boolean(hit), detail }); return hit; };
90
+
91
+ const exec = evaluateExecPolicyCore(command, { policy, mode: "enforce" });
92
+ record("exec-policy", exec.decision === "forbid", exec.reasons.length ? exec.reasons.join("; ") : "no rule matched");
93
+ if (exec.decision === "forbid") {
94
+ return { decision: "deny", kind: "exec-policy", confidence: "exact", reason: exec.reasons.join("; "), steps, exec };
95
+ }
96
+
97
+ const literal = findProtectedPathInCommand(command, protectedPaths);
98
+ record("literal-path", literal, literal ? `${literal.candidate} matches ${literal.pattern}` : "no word names a protected path");
99
+ if (literal) {
100
+ return {
101
+ decision: "deny", kind: "protected-path", confidence: "exact",
102
+ reason: `Command touches protected path: ${literal.candidate} matches ${literal.pattern}`, steps, exec
103
+ };
104
+ }
105
+
106
+ const glob = shellGlobTargetsProtectedPath(command, protectedPaths);
107
+ record("glob", glob, glob ? `${glob.glob} can match ${glob.example} via ${glob.pattern}` : "no pattern can reach a protected name");
108
+ if (glob) {
109
+ return {
110
+ decision: "deny", kind: "protected-glob",
111
+ // A pattern is matched against generated examples of each protected
112
+ // pattern, not against the filesystem, so this answers for names that may
113
+ // not exist. That is deliberate, and worth saying out loud.
114
+ confidence: "over-approximate",
115
+ reason: `Command glob can target protected path: ${glob.glob} can match ${glob.example} via ${glob.pattern}`, steps, exec
116
+ };
117
+ }
118
+
119
+ const resolved = findResolvedProtectedPathInCommand(cwd, command, protectedPaths);
120
+ record("resolved-path", resolved, resolved ? `${resolved.candidate} resolves to ${resolved.resolved}` : "no candidate resolves onto a protected path");
121
+ if (resolved) {
122
+ return {
123
+ decision: "deny", kind: "resolved-protected", confidence: "exact",
124
+ reason: `Command resolves to protected path: ${resolved.candidate} resolves to ${resolved.resolved} matching ${resolved.pattern}`, steps, exec
125
+ };
126
+ }
127
+
128
+ const unresolved = protectedPaths.length > 0 ? unresolvedPathExpansions(command) : [];
129
+ record("unresolved-expansion", unresolved.length > 0, unresolved.length ? unresolved.join(", ") : "every path is resolvable");
130
+ if (unresolved.length > 0) {
131
+ return {
132
+ decision: "deny", kind: "unresolved",
133
+ // Nothing was proven to be protected here. The command is refused because
134
+ // its target is unknowable before it runs, which is a different statement
135
+ // and leads the operator to a different fix.
136
+ confidence: "unknown-target",
137
+ reason: unresolvedExpansionReason("Command", unresolved), steps, exec
138
+ };
139
+ }
140
+
141
+ const staticDecision = exec.decision === "prompt" ? "confirm" : "allow";
142
+ return {
143
+ decision: "indeterminate", kind: "runtime-required", confidence: "runtime-required",
144
+ staticDecision,
145
+ reason: staticDecision === "confirm"
146
+ ? `Static preflight requires confirmation (${exec.reasons.join("; ")}), but the live runtime may still block it.`
147
+ : "Static preflight found no refusal. A final decision requires live session and task state.",
148
+ remainingGates: RUNTIME_GATES,
149
+ steps, exec
150
+ };
151
+ }
152
+
153
+ function render(result, context) {
154
+ const lines = [`decision: ${result.decision}`, ""];
155
+ lines.push(` reason ${result.reason}`);
156
+ lines.push(` confidence ${result.confidence}`);
157
+ if (result.staticDecision) lines.push(` static ${result.staticDecision}`);
158
+ lines.push(` remedy ${REMEDY[result.kind] ?? REMEDY["runtime-required"]}`);
159
+ if (result.remainingGates?.length) lines.push(` still needs ${result.remainingGates.join(", ")}`);
160
+ lines.push("", " static chain (the order the guard asks these):");
161
+ for (const step of result.steps) {
162
+ lines.push(` ${step.matched ? "HIT " : " "} ${step.step.padEnd(20)} ${step.detail}`);
163
+ }
164
+ lines.push("", ` profile ${context.profileSource} (${context.profilePath})`);
165
+ lines.push(` protected ${context.protectedPaths.length} patterns in effect`);
166
+ return lines.join("\n");
167
+ }
168
+
169
+ function renderScope(loaded, effective) {
170
+ const lines = ["boundary in effect", ""];
171
+ lines.push(` profile ${loaded.profileSource} (${loaded.profilePath})`);
172
+ lines.push(` mode ${loaded.profile.mode ?? "unset"}`);
173
+ if (typeof loaded.profile.displayName === "string") lines.push(` project ${loaded.profile.displayName}`);
174
+ // Packs are `{name, version}` records, not names, so joining them raw printed
175
+ // `[object Object]` where the operator expected to read what is loaded.
176
+ const packs = (Array.isArray(loaded.profile.capabilityPacks) ? loaded.profile.capabilityPacks : [])
177
+ .map((pack) => (pack && typeof pack === "object" ? `${pack.name}@${pack.version}` : String(pack)));
178
+ lines.push(` packs ${packs.length > 0 ? packs.join(", ") : "none"}`);
179
+ for (const [label, key] of [
180
+ ["shell", "shellProtectedPaths"],
181
+ ["read", "readProtectedPaths"],
182
+ ["write", "writeProtectedPaths"],
183
+ ["read-only", "readOnlyPaths"]
184
+ ]) {
185
+ const values = effective[key];
186
+ lines.push("", ` ${label} (${values.length})`);
187
+ for (const value of values) lines.push(` ${value}`);
188
+ if (values.length === 0) lines.push(" (none)");
189
+ }
190
+ return lines.join("\n");
191
+ }
192
+
193
+ let parsed;
194
+ try {
195
+ parsed = parseArgs(process.argv.slice(2));
196
+ } catch (error) {
197
+ console.error(`FAIL: ${error.message}\n\n${usage()}`);
198
+ process.exit(2);
199
+ }
200
+ if (parsed.help) { process.stdout.write(`${usage()}\n`); process.exit(0); }
201
+ if (!parsed.command && !parsed.scope) { console.error(`FAIL: nothing to explain\n\n${usage()}`); process.exit(2); }
202
+
203
+ try {
204
+ const loaded = loadProjectContextIndexPolicy(platformRoot, parsed.project, { profilePath: process.env.PIAGENT_PROFILE });
205
+ const effective = effectiveProtectedPaths(loaded.policy, loaded.profile);
206
+ const protectedPaths = effective.shellProtectedPaths;
207
+ if (parsed.scope) {
208
+ // The boundary is otherwise only visible by running into it.
209
+ if (parsed.json) {
210
+ process.stdout.write(`${JSON.stringify({
211
+ profileSource: loaded.profileSource, profilePath: loaded.profilePath,
212
+ mode: loaded.profile.mode ?? null, capabilityPacks: loaded.profile.capabilityPacks ?? [], ...effective
213
+ }, null, 2)}\n`);
214
+ } else {
215
+ process.stdout.write(`${renderScope(loaded, effective)}\n`);
216
+ }
217
+ process.exit(0);
218
+ }
219
+ const result = explain(parsed.command, protectedPaths, loaded.policy, parsed.project);
220
+ const context = { profileSource: loaded.profileSource, profilePath: loaded.profilePath, protectedPaths };
221
+ if (parsed.json) {
222
+ process.stdout.write(`${JSON.stringify({
223
+ command: parsed.command, decision: result.decision, kind: result.kind, confidence: result.confidence,
224
+ reason: result.reason, remedy: REMEDY[result.kind] ?? REMEDY["runtime-required"],
225
+ staticDecision: result.staticDecision ?? null,
226
+ remainingGates: result.remainingGates ?? [], steps: result.steps,
227
+ protectedPatternCount: protectedPaths.length, profileSource: loaded.profileSource
228
+ }, null, 2)}\n`);
229
+ } else {
230
+ process.stdout.write(`${render(result, context)}\n`);
231
+ }
232
+ // Exit status is a permission signal for existing shell callers: 0 is reserved
233
+ // for commands that are conclusively allowed. This standalone process can no
234
+ // longer make that claim; 1 is a conclusive static denial and 2 means the live
235
+ // runtime still has to decide.
236
+ process.exit(result.decision === "deny" ? 1 : 2);
237
+ } catch (error) {
238
+ console.error(`FAIL: ${error instanceof Error ? error.message : String(error)}`);
239
+ process.exit(1);
240
+ }