@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
@@ -10,6 +10,7 @@ import {
10
10
  collectServers,
11
11
  configPathForScope,
12
12
  isRecord,
13
+ mergedServerEntry,
13
14
  readMcpConfig,
14
15
  writeMcpConfig
15
16
  } from "../packages/piagent-core/mcp/mcp-config-layers.js";
@@ -28,18 +29,18 @@ import {
28
29
  } from "../packages/piagent-core/mcp/mcp-server-entry.js";
29
30
  import {
30
31
  approvalState,
32
+ approvalStorePath,
31
33
  clearApproval,
32
34
  recordApproval
33
35
  } from "../packages/piagent-core/mcp/mcp-approval-store.js";
34
36
  import { evaluateServerReadiness } from "../packages/piagent-core/mcp/mcp-auth-readiness.js";
35
37
  import {
36
38
  McpViewError,
37
- blockedRows,
38
- collectServerRows,
39
39
  describeServer,
40
40
  formatDoctorReport,
41
41
  formatServerDetail,
42
42
  formatServerTable,
43
+ sessionMcpState,
43
44
  usesDocker
44
45
  } from "../packages/piagent-core/mcp/mcp-session-view.js";
45
46
 
@@ -214,6 +215,25 @@ function locateServer(name, flags) {
214
215
  return servers[0];
215
216
  }
216
217
 
218
+ /**
219
+ * A server this command has no file of its own to edit: it was read out of
220
+ * another tool's config through `imports`. `located.file` is that other tool's
221
+ * config, and writing it back through `writeMcpConfig` would rewrite somebody's
222
+ * Cursor or VS Code file in this platform's format -- adding a `settings` block
223
+ * it does not use and dropping every key this shape does not carry.
224
+ *
225
+ * @param {{name: string, origin?: string, scope: string, file: string}} located
226
+ * @param {string} action
227
+ */
228
+ function assertOwnedByThisTool(located, action) {
229
+ if (!located.origin?.startsWith("import:")) return;
230
+ const kind = located.origin.slice("import:".length);
231
+ throw new UsageError(
232
+ `${located.name} is not defined by any config this command owns. The ${located.scope} config imports ${kind}, ` +
233
+ `and the definition lives in ${located.file}. Edit that file to ${action} it, or drop "${kind}" from its imports list.`
234
+ );
235
+ }
236
+
217
237
  // --- commands ---------------------------------------------------------------
218
238
 
219
239
  /** @param {ReturnType<typeof parseArgs>} args */
@@ -277,12 +297,21 @@ function commandRemove(args) {
277
297
  const name = args.positionals[0];
278
298
  if (!name) throw new UsageError("remove needs a server name");
279
299
  const located = locateServer(name, args.flags);
300
+ assertOwnedByThisTool(located, "remove");
280
301
  const config = readMcpConfig(located.file);
281
302
  delete config.mcpServers[name];
282
303
  writeMcpConfig(located.file, config);
283
- clearApproval({ projectPath: projectPath(args.flags), name });
284
304
  process.stdout.write(`Removed MCP server ${name} from ${located.scope} scope\n`);
285
305
  process.stdout.write(`File modified: ${located.file}\n`);
306
+ // The definition is gone either way; the decision about it is a separate
307
+ // record, and leaving one behind for a name that no longer exists means a
308
+ // later server reusing the name would meet an approval nobody granted it.
309
+ if (!clearApproval({ projectPath: projectPath(args.flags), name })) {
310
+ process.stdout.write(
311
+ `Warning: could not update ${approvalStorePath() ?? "the approval store"}, ` +
312
+ `so the recorded decision for ${name} is still there. Run \`piagent-mcp reset ${name}\` once it is writable.\n`
313
+ );
314
+ }
286
315
  return 0;
287
316
  }
288
317
 
@@ -318,33 +347,51 @@ function commandGet(args) {
318
347
  function commandList(args) {
319
348
  const scopeFilter = value(args.flags, "--scope");
320
349
  const project = projectPath(args.flags);
321
- const rows = collectServerRows({ projectPath: project, scopes: scopeFilter ? [scopeFilter] : undefined });
350
+ // Listing nothing is the correct answer only when nothing is reaching the
351
+ // session. A config the gate cannot verify reaches it with an unknown set of
352
+ // servers, and telling somebody to seed a baseline in that state sends them
353
+ // to the wrong file entirely.
354
+ const state = sessionMcpState({ projectPath: project, scopes: scopeFilter ? [scopeFilter] : undefined });
355
+ const { rows } = state;
322
356
 
323
357
  if (args.flags.get("--json") === true) {
358
+ // The same fields the human output carries. A caller scripting against this
359
+ // read `servers: []` as "nothing configured" while every tool call was being
360
+ // refused, and an exit code of 0 agreed with it.
324
361
  process.stdout.write(`${JSON.stringify({
325
362
  project,
363
+ usable: state.usable,
364
+ blockingConfig: state.blockingConfig,
326
365
  servers: rows.map((row) => ({
327
366
  name: row.name,
328
367
  scope: row.scope,
368
+ origin: row.origin,
369
+ requiresApproval: row.requiresApproval,
329
370
  file: row.file,
330
371
  state: row.readiness.state,
331
372
  detail: row.readiness.detail,
332
373
  entry: maskServerEntry(row.entry)
333
374
  }))
334
375
  }, null, 2)}\n`);
335
- return 0;
376
+ return state.usable ? 0 : 1;
336
377
  }
337
378
 
379
+ for (const problem of state.blockingConfig) process.stdout.write(`BLOCKED every tool call: ${problem.detail}\n`);
380
+
338
381
  if (rows.length === 0) {
339
- process.stdout.write("No MCP servers configured.\n");
340
- process.stdout.write("Seed the pinned baseline with: piagent-mcp --preset core --scope global\n");
341
- return 0;
382
+ if (state.usable) {
383
+ process.stdout.write("No MCP servers configured.\n");
384
+ process.stdout.write("Seed the pinned baseline with: piagent-mcp --preset core --scope global\n");
385
+ }
386
+ } else {
387
+ process.stdout.write(`${formatServerTable(rows)}\n`);
388
+ if (state.blocked.length > 0) {
389
+ process.stdout.write(`\n${state.blocked.length} server(s) not usable yet. Run \`piagent-mcp doctor\`.\n`);
390
+ }
342
391
  }
343
-
344
- process.stdout.write(`${formatServerTable(rows)}\n`);
345
- const blocked = blockedRows(rows);
346
- if (blocked.length > 0) process.stdout.write(`\n${blocked.length} server(s) not usable yet. Run \`piagent-mcp doctor\`.\n`);
347
- return 0;
392
+ // Out of the no-rows branch: the exit code answers "can MCP work here", and
393
+ // that question does not depend on how many rows happened to print.
394
+ return state.usable ? 0 : 1;
348
395
  }
349
396
 
350
397
  /** @param {ReturnType<typeof parseArgs>} args @param {boolean} enabled */
@@ -352,6 +399,7 @@ function commandToggle(args, enabled) {
352
399
  const name = args.positionals[0];
353
400
  if (!name) throw new UsageError(`${enabled ? "enable" : "disable"} needs a server name`);
354
401
  const located = locateServer(name, args.flags);
402
+ assertOwnedByThisTool(located, enabled ? "enable" : "disable");
355
403
  const config = readMcpConfig(located.file);
356
404
  const entry = { ...(isRecord(config.mcpServers[name]) ? config.mcpServers[name] : {}) };
357
405
  if (enabled) delete entry.enabled;
@@ -384,9 +432,19 @@ function commandDecide(args, decision) {
384
432
  if (decision === "approved" && before.state === "pending" && args.flags.get("--force") !== true) {
385
433
  // Approving without reading it is the failure this gate exists to prevent, so
386
434
  // the definition is put in front of the operator before the decision lands.
387
- process.stdout.write(`${JSON.stringify(maskServerEntry(server.entry), null, 2)}\n`);
435
+ // The merged entry, not this layer's fragment: the adapter merges a
436
+ // same-named server key by key across layers, so a repository declaring only
437
+ // `{"args": [...]}` runs with a command it never showed the operator.
438
+ process.stdout.write(`${JSON.stringify(
439
+ maskServerEntry(mergedServerEntry({ projectPath: project, name, entry: server.entry })), null, 2
440
+ )}\n`);
441
+ }
442
+ // A decision that was not written is not a decision. Printing "Approved" while
443
+ // the store rejected the write would leave the operator waiting for a gate that
444
+ // is still blocking, with nothing on screen to explain why.
445
+ if (!recordApproval({ projectPath: project, name, entry: server.entry, decision })) {
446
+ throw new UsageError(`could not write ${approvalStorePath() ?? "the approval store"}; ${name} is still ${before.state}`);
388
447
  }
389
- recordApproval({ projectPath: project, name, entry: server.entry, decision });
390
448
  const after = approvalState({ projectPath: project, name, entry: server.entry });
391
449
  process.stdout.write(`${decision === "approved" ? "Approved" : "Rejected"} MCP server ${name} (${server.scope}) for ${project}\n`);
392
450
  process.stdout.write(`Pinned to this definition: ${after.digest}\n`);
@@ -398,7 +456,9 @@ function commandDecide(args, decision) {
398
456
  function commandReset(args) {
399
457
  const project = projectPath(args.flags);
400
458
  const name = args.positionals[0];
401
- clearApproval({ projectPath: project, name });
459
+ if (!clearApproval({ projectPath: project, name })) {
460
+ throw new UsageError(`could not write ${approvalStorePath() ?? "the approval store"}; nothing was forgotten`);
461
+ }
402
462
  process.stdout.write(name
403
463
  ? `Forgot the decision for ${name} in ${project}\n`
404
464
  : `Forgot every MCP approval decision for ${project}\n`);
@@ -408,7 +468,12 @@ function commandReset(args) {
408
468
  /** @param {ReturnType<typeof parseArgs>} args */
409
469
  function commandDoctor(args) {
410
470
  const project = projectPath(args.flags);
411
- const rows = collectServerRows({ projectPath: project });
471
+ // Resolved before the no-servers shortcut below. A layer nobody can parse
472
+ // produces no rows, so reporting "no servers configured" and stopping would
473
+ // answer the question exactly backwards. The guard refuses every tool call
474
+ // under `blockingConfig`, and this is where somebody comes to find out why.
475
+ const state = sessionMcpState({ projectPath: project });
476
+ const { rows, unreadable: broken, imports, blockingConfig: unverifiable } = state;
412
477
 
413
478
  // The one check too slow for a session start: a daemon that is installed but
414
479
  // not running looks exactly like one that is running until something asks it.
@@ -422,22 +487,46 @@ function commandDoctor(args) {
422
487
  process.stdout.write(`${JSON.stringify({
423
488
  project,
424
489
  dockerRunning,
425
- servers: rows.map((row) => ({ name: row.name, scope: row.scope, state: row.readiness.state, detail: row.readiness.detail, remedy: row.readiness.remedy }))
490
+ servers: rows.map((row) => ({ name: row.name, scope: row.scope, state: row.readiness.state, detail: row.readiness.detail, remedy: row.readiness.remedy })),
491
+ unreadableLayers: broken,
492
+ repositoryImports: imports.map((layer) => ({ scope: layer.scope, kinds: layer.kinds })),
493
+ unverifiableConfig: unverifiable
426
494
  }, null, 2)}\n`);
427
- return blockedRows(rows).length > 0 ? 1 : 0;
495
+ return state.blocked.length > 0 || broken.length > 0 || unverifiable.length > 0 ? 1 : 0;
428
496
  }
429
497
 
498
+ for (const layer of broken) process.stdout.write(`Unreadable ${layer.scope} config: ${layer.detail}\n`);
499
+ for (const problem of unverifiable) process.stdout.write(`BLOCKED every tool call: ${problem.detail}\n`);
500
+
430
501
  if (rows.length === 0) {
502
+ if (unverifiable.length > 0 || broken.length > 0) {
503
+ process.stdout.write(`\n${unverifiable.length + broken.length} MCP config problem(s) need attention.\n`);
504
+ return 1;
505
+ }
431
506
  process.stdout.write("No MCP servers configured.\n");
432
507
  return 0;
433
508
  }
434
509
 
435
510
  const report = formatDoctorReport(rows, { dockerRunning, rerun: "piagent-mcp doctor" });
436
511
  for (const line of report.lines) process.stdout.write(`${line}\n`);
437
- process.stdout.write(report.problems === 0
512
+
513
+ // A layer nothing can parse hides whatever it defines and reads as empty, so
514
+ // it is counted (and already printed above). An import is named but not
515
+ // counted: the servers it brings in are in the report above with their own
516
+ // state, and this line says where they came from, which reading the
517
+ // repository's own config would not reveal.
518
+ for (const layer of imports) {
519
+ process.stdout.write(
520
+ `${layer.file} imports servers from ${layer.kinds.join(", ")} config; ` +
521
+ "they need approval here like any server the repository defines.\n"
522
+ );
523
+ }
524
+
525
+ const problems = report.problems + broken.length + unverifiable.length;
526
+ process.stdout.write(problems === 0
438
527
  ? "\nPASS: every configured MCP server can be reached.\n"
439
- : `\n${report.problems} MCP server(s) need attention.\n`);
440
- return report.problems === 0 ? 0 : 1;
528
+ : `\n${problems} MCP problem(s) need attention.\n`);
529
+ return problems === 0 ? 0 : 1;
441
530
  }
442
531
 
443
532
  /** @param {ReturnType<typeof parseArgs>} args */
@@ -22,6 +22,15 @@ USAGE
22
22
  FORMAT="text"
23
23
  PROVIDER=""
24
24
 
25
+ require_value() {
26
+ local option="$1"
27
+ local value="${2:-}"
28
+ if [[ -z "$value" || "$value" == --* ]]; then
29
+ echo "Missing value for $option" >&2
30
+ exit 2
31
+ fi
32
+ }
33
+
25
34
  while [[ $# -gt 0 ]]; do
26
35
  case "$1" in
27
36
  --json)
@@ -29,7 +38,8 @@ while [[ $# -gt 0 ]]; do
29
38
  shift
30
39
  ;;
31
40
  --provider)
32
- PROVIDER="${2:-}"
41
+ require_value "$1" "${2:-}"
42
+ PROVIDER="$2"
33
43
  shift 2
34
44
  ;;
35
45
  -h|--help)
@@ -5,23 +5,49 @@ usage() {
5
5
  cat <<'USAGE'
6
6
  Usage:
7
7
  scripts/pi-session-stats.sh [project-path] [session-file]
8
+ scripts/pi-session-stats.sh --history [project-path] [history-options]
8
9
 
9
10
  Examples:
10
11
  scripts/pi-session-stats.sh .
11
12
  scripts/pi-session-stats.sh /path/to/project
12
13
  scripts/pi-session-stats.sh /path/to/project ~/.pi/agent/sessions/.../session.jsonl
14
+ scripts/pi-session-stats.sh --history . --days 7
15
+ scripts/pi-session-stats.sh --history --all-projects --csv
13
16
 
14
17
  Notes:
15
18
  - Reads exact Pi token/cache/cost totals via RPC get_session_stats.
16
19
  - If session-file is omitted, the newest persisted session whose header cwd matches project-path is used.
20
+ - Use --history for finished/old session totals read directly from Pi JSONL.
17
21
  USAGE
18
22
  }
19
23
 
24
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
25
+ HISTORY_SCRIPT="$SCRIPT_DIR/pi-usage-history.mjs"
26
+
20
27
  if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
21
28
  usage
22
29
  exit 0
23
30
  fi
24
31
 
32
+ for argument in "$@"; do
33
+ case "$argument" in
34
+ --history|--all-projects|--days|--since|--until|--format|--json|--csv|--markdown|--include-subagents|--no-subagents|--limit|--sessions-dir)
35
+ exec node "$HISTORY_SCRIPT" "$@"
36
+ ;;
37
+ esac
38
+ done
39
+
40
+ # Both arguments are positional and the project path comes first, so a flag in
41
+ # either slot is a typo rather than a path. Saying so beats failing later with a
42
+ # message about a directory that was never meant to be one.
43
+ for argument in "${1:-}" "${2:-}"; do
44
+ if [[ "$argument" == -* ]]; then
45
+ echo "FAIL: this script takes <project-path> [session-file], not flags; received: $argument" >&2
46
+ usage >&2
47
+ exit 2
48
+ fi
49
+ done
50
+
25
51
  PROJECT_PATH="${1:-.}"
26
52
  SESSION_FILE="${2:-}"
27
53
 
@@ -79,7 +105,7 @@ fi
79
105
 
80
106
  RPC_OUTPUT="$(
81
107
  printf '%s\n' '{"type":"get_session_stats","id":"piagent-usage"}' \
82
- | pi --mode rpc --session "$SESSION_FILE" --offline --approve
108
+ | pi -ne --mode rpc --session "$SESSION_FILE" --offline --approve
83
109
  )"
84
110
 
85
111
  node --input-type=module - "$RPC_OUTPUT" <<'NODE'