@piagent/platform 1.1.9 → 1.2.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 (81) hide show
  1. package/CHANGELOG.md +175 -1
  2. package/README.md +2 -2
  3. package/docs/command-reference-vietnamese.md +19 -5
  4. package/docs/distribution-standard.md +7 -7
  5. package/docs/mcp-and-tools.md +154 -2
  6. package/docs/model-options.md +10 -8
  7. package/docs/operator-manual-vietnamese.md +25 -13
  8. package/docs/publishing-for-teams.md +2 -0
  9. package/docs/quickstart-vietnamese.md +2 -2
  10. package/docs/release-install-policy.md +6 -6
  11. package/docs/security-threat-model.md +4 -3
  12. package/docs/team-onboarding.md +4 -4
  13. package/docs/usage-observability.md +29 -0
  14. package/docs/vercel-docs-site.md +34 -4
  15. package/docs-site/architecture.html +152 -0
  16. package/docs-site/assets/docs.css +1001 -0
  17. package/docs-site/assets/docs.js +84 -0
  18. package/docs-site/commands.html +203 -0
  19. package/docs-site/content/architecture.html +29 -0
  20. package/docs-site/content/commands.html +80 -0
  21. package/docs-site/content/index.html +80 -0
  22. package/docs-site/content/mcp-approval.html +133 -0
  23. package/docs-site/content/mcp-auth.html +76 -0
  24. package/docs-site/content/mcp-servers.html +111 -0
  25. package/docs-site/content/mcp.html +120 -0
  26. package/docs-site/content/permissions.html +54 -0
  27. package/docs-site/content/profiles.html +68 -0
  28. package/docs-site/content/quickstart.html +144 -0
  29. package/docs-site/content/runtime.html +45 -0
  30. package/docs-site/content/scope.html +71 -0
  31. package/docs-site/content/security.html +51 -0
  32. package/docs-site/content/team.html +23 -0
  33. package/docs-site/content/workflows.html +47 -0
  34. package/docs-site/index.html +131 -1534
  35. package/docs-site/mcp-approval.html +256 -0
  36. package/docs-site/mcp-auth.html +199 -0
  37. package/docs-site/mcp-servers.html +234 -0
  38. package/docs-site/mcp.html +243 -0
  39. package/docs-site/permissions.html +177 -0
  40. package/docs-site/profiles.html +191 -0
  41. package/docs-site/quickstart.html +267 -0
  42. package/docs-site/runtime.html +168 -0
  43. package/docs-site/scope.html +194 -0
  44. package/docs-site/security.html +174 -0
  45. package/docs-site/team.html +142 -0
  46. package/docs-site/workflows.html +170 -0
  47. package/package.json +4 -2
  48. package/packages/piagent-core/capabilities/capability-core.js +58 -5
  49. package/packages/piagent-core/capabilities/capability-sources.js +73 -12
  50. package/packages/piagent-core/extensions/document-intake.ts +5 -5
  51. package/packages/piagent-core/extensions/piagent-guard.ts +432 -2
  52. package/packages/piagent-core/extensions/policy-core.js +1586 -98
  53. package/packages/piagent-core/mcp/mcp-approval-store.js +216 -0
  54. package/packages/piagent-core/mcp/mcp-auth-readiness.js +225 -0
  55. package/packages/piagent-core/mcp/mcp-command-actions.js +400 -0
  56. package/packages/piagent-core/mcp/mcp-config-layers.js +466 -0
  57. package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
  58. package/packages/piagent-core/mcp/mcp-server-catalog.js +150 -0
  59. package/packages/piagent-core/mcp/mcp-server-entry.js +293 -0
  60. package/packages/piagent-core/mcp/mcp-session-view.js +236 -0
  61. package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
  62. package/packages/piagent-core/package.json +7 -3
  63. package/packages/piagent-core/prompts/model-options.md +8 -8
  64. package/scripts/build-docs-site.mjs +421 -0
  65. package/scripts/configure-model-scope.sh +21 -5
  66. package/scripts/configure-subagents.sh +18 -5
  67. package/scripts/init-project.sh +15 -8
  68. package/scripts/install-global.sh +5 -5
  69. package/scripts/mcp-manage.mjs +656 -0
  70. package/scripts/pi-model-catalog.sh +11 -1
  71. package/scripts/pi-session-stats.sh +27 -1
  72. package/scripts/pi-usage-history.mjs +567 -0
  73. package/scripts/piagent-cli.mjs +1 -1
  74. package/scripts/preview-docs-site.mjs +88 -0
  75. package/scripts/quality-benchmark.sh +37 -9
  76. package/scripts/setup.sh +23 -15
  77. package/scripts/team-doctor.sh +18 -2
  78. package/scripts/uninstall-global.sh +10 -4
  79. package/scripts/verify-local.sh +65 -19
  80. package/scripts/verify-release-identity.mjs +37 -2
  81. package/scripts/configure-mcp.sh +0 -349
@@ -0,0 +1,216 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { stableJson } from "../capabilities/capability-core.js";
6
+
7
+ // Approval for MCP servers that arrive inside a repository.
8
+ //
9
+ // A repository can carry `.mcp.json` and `.pi/mcp.json`. Cloning one and opening
10
+ // a session should not hand its author a process on this machine with this
11
+ // operator's credentials, so a server found in those files is not usable until
12
+ // somebody here says so.
13
+ //
14
+ // The record lives in the operator's home, never in the repository. That is the
15
+ // whole point: a repository that could approve its own servers has not been
16
+ // gated. It also means a fork, a rename, or a second checkout starts unapproved
17
+ // rather than inheriting a decision made about different code.
18
+ //
19
+ // What is stored is the digest of the entry that was approved. Approval is
20
+ // consent to what the server runs, so a changed command, URL or argument list is
21
+ // a new question — the same split the capability lock draws between what a
22
+ // project consented to and which build produced it.
23
+
24
+ const STORE_FILE = "piagent-mcp-approvals.json";
25
+ const MAX_STORE_BYTES = 256 * 1024;
26
+ const SCHEMA_VERSION = 1;
27
+
28
+ /** @param {{home?: string}} [options] @returns {string|undefined} */
29
+ export function approvalStorePath(options = {}) {
30
+ const base = options.home ?? os.homedir();
31
+ if (typeof base !== "string" || base.length === 0) return undefined;
32
+ return path.join(base, ".pi", STORE_FILE);
33
+ }
34
+
35
+ /**
36
+ * The digest of a server entry, over the definition only. Two projects that
37
+ * declare the same server produce the same digest, which is fine: the store is
38
+ * keyed by project as well, so approving one does not approve the other.
39
+ *
40
+ * This is a content identifier, not a credential hash, and SHA-256 is the right
41
+ * primitive for it. The question it answers is "is this the same definition the
42
+ * operator approved", so it has to be fast, unsalted and stable across runs —
43
+ * every property a password hash is deliberately built to lack. Nothing is ever
44
+ * verified against it, and no secret reaches it: `add` refuses to write a
45
+ * credential value into MCP config and takes a `${VAR}` reference instead, so
46
+ * what gets hashed is a command line, a URL, variable names, and an OAuth client
47
+ * id and scope list, all of which are public identifiers.
48
+ *
49
+ * @param {unknown} entry
50
+ * @returns {string}
51
+ */
52
+ export function serverEntryDigest(entry) {
53
+ return `sha256:${crypto.createHash("sha256").update(stableJson(entry)).digest("hex")}`;
54
+ }
55
+
56
+ /**
57
+ * A project is identified by its resolved path. Resolved, because a checkout
58
+ * reached through a symlink is the same checkout, and because macOS reaches
59
+ * `/var` that way.
60
+ * @param {string} projectPath
61
+ * @returns {string}
62
+ */
63
+ export function projectKey(projectPath) {
64
+ try {
65
+ return fs.realpathSync(projectPath);
66
+ } catch {
67
+ return path.resolve(projectPath);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * The store, and whether the empty case means "nothing decided" or "nobody could
73
+ * read it". Both read as pending, which is the right answer for a gate and the
74
+ * wrong one for a write: overwriting a store this process could not parse throws
75
+ * away every decision recorded in it, and the operator's only clue would be that
76
+ * their other projects silently returned to pending.
77
+ *
78
+ * @param {{home?: string}} [options]
79
+ * @returns {{projects: Record<string, Record<string, {digest?: string, decision?: string, decidedAt?: string}>>, readable: boolean, detail?: string}}
80
+ */
81
+ export function loadApprovalStore(options = {}) {
82
+ const file = approvalStorePath(options);
83
+ if (!file) return { projects: {}, readable: false, detail: "no home directory to read an approval store from" };
84
+ let raw;
85
+ try {
86
+ // A store larger than this is not something this code wrote.
87
+ if (fs.statSync(file).size > MAX_STORE_BYTES) {
88
+ return { projects: {}, readable: false, detail: `${file} is larger than ${MAX_STORE_BYTES} bytes` };
89
+ }
90
+ raw = fs.readFileSync(file, "utf8");
91
+ } catch (error) {
92
+ // Absent is the ordinary state before the first decision.
93
+ if (error && typeof error === "object" && error.code === "ENOENT") return { projects: {}, readable: true };
94
+ return { projects: {}, readable: false, detail: `cannot read ${file}` };
95
+ }
96
+ let document;
97
+ try {
98
+ document = JSON.parse(raw);
99
+ } catch (error) {
100
+ return { projects: {}, readable: false, detail: `cannot parse ${file}: ${error instanceof Error ? error.message : String(error)}` };
101
+ }
102
+ const projects = document?.projects;
103
+ if (projects === undefined && document && typeof document === "object" && !Array.isArray(document)) {
104
+ // A document with no `projects` key is a store this code did not write.
105
+ return { projects: {}, readable: false, detail: `${file} has no projects object` };
106
+ }
107
+ if (!projects || typeof projects !== "object" || Array.isArray(projects)) {
108
+ return { projects: {}, readable: false, detail: `${file} has no projects object` };
109
+ }
110
+ return { projects, readable: true };
111
+ }
112
+
113
+ /** @param {{home?: string}} [options] @returns {Record<string, Record<string, {digest?: string, decision?: string, decidedAt?: string}>>} */
114
+ export function readApprovalStore(options = {}) {
115
+ // An unreadable store denies rather than permits: every server reads as
116
+ // undecided, which is the state that blocks.
117
+ return loadApprovalStore(options).projects;
118
+ }
119
+
120
+ /**
121
+ * @param {Record<string, unknown>} projects
122
+ * @param {{home?: string}} [options]
123
+ * @returns {boolean}
124
+ */
125
+ export function writeApprovalStore(projects, options = {}) {
126
+ const file = approvalStorePath(options);
127
+ if (!file) return false;
128
+ const document = { schemaVersion: SCHEMA_VERSION, projects };
129
+ try {
130
+ fs.mkdirSync(path.dirname(file), { recursive: true });
131
+ const temporary = `${file}.${process.pid}.tmp`;
132
+ fs.writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 });
133
+ fs.renameSync(temporary, file);
134
+ return true;
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * @typedef {"approved"|"rejected"|"pending"|"changed"} ApprovalState
142
+ */
143
+
144
+ /**
145
+ * What the operator has decided about this server as it is defined right now.
146
+ *
147
+ * `changed` is separated from `pending` on purpose. They both block, but they are
148
+ * different events: one is a server nobody has looked at, the other is a server
149
+ * that was approved and is no longer what was approved. Collapsing them would
150
+ * hide the second, which is the one worth reading.
151
+ *
152
+ * @param {{projectPath: string, name: string, entry: unknown, store?: Record<string, unknown>, home?: string}} options
153
+ * @returns {{state: ApprovalState, digest: string, approvedDigest?: string}}
154
+ */
155
+ export function approvalState(options) {
156
+ const store = options.store ?? readApprovalStore({ home: options.home });
157
+ const digest = serverEntryDigest(options.entry);
158
+ const record = /** @type {Record<string, {digest?: string, decision?: string}>|undefined} */ (
159
+ store[projectKey(options.projectPath)]
160
+ )?.[options.name];
161
+ if (!record || typeof record.decision !== "string") return { state: "pending", digest };
162
+ if (record.decision === "rejected") return { state: "rejected", digest, approvedDigest: record.digest };
163
+ if (record.decision !== "approved") return { state: "pending", digest };
164
+ if (record.digest !== digest) return { state: "changed", digest, approvedDigest: record.digest };
165
+ return { state: "approved", digest, approvedDigest: record.digest };
166
+ }
167
+
168
+ /**
169
+ * @param {{projectPath: string, name: string, entry: unknown, decision: "approved"|"rejected", now?: number, home?: string}} options
170
+ * @returns {boolean}
171
+ */
172
+ export function recordApproval(options) {
173
+ const loaded = loadApprovalStore({ home: options.home });
174
+ // Writing on top of a store nobody could parse would drop every decision it
175
+ // holds -- including the ones for other projects, which the operator would
176
+ // find out about only by watching them go back to pending one at a time.
177
+ if (!loaded.readable) return false;
178
+ const store = loaded.projects;
179
+ const key = projectKey(options.projectPath);
180
+ const project = { ...(store[key] ?? {}) };
181
+ project[options.name] = {
182
+ decision: options.decision,
183
+ digest: serverEntryDigest(options.entry),
184
+ decidedAt: new Date(options.now ?? Date.now()).toISOString()
185
+ };
186
+ return writeApprovalStore({ ...store, [key]: project }, { home: options.home });
187
+ }
188
+
189
+ /**
190
+ * Drop a decision so the server returns to pending. Used by `piagent-mcp reset`
191
+ * and when a server is removed, so a later server reusing the name does not
192
+ * inherit the decision — the digest check would catch it, but leaving a record
193
+ * for a server that no longer exists is just debris.
194
+ * @param {{projectPath: string, name?: string, home?: string}} options
195
+ * @returns {boolean}
196
+ */
197
+ export function clearApproval(options) {
198
+ const loaded = loadApprovalStore({ home: options.home });
199
+ if (!loaded.readable) return false;
200
+ const store = loaded.projects;
201
+ const key = projectKey(options.projectPath);
202
+ // Nothing recorded is the state this function exists to reach, so it succeeds.
203
+ // Returning false here would be indistinguishable from a failed write, and the
204
+ // caller has to be able to tell those apart.
205
+ if (!store[key]) return true;
206
+ if (!options.name) {
207
+ const { [key]: _removed, ...rest } = store;
208
+ return writeApprovalStore(rest, { home: options.home });
209
+ }
210
+ const { [options.name]: _dropped, ...project } = store[key];
211
+ const next = Object.keys(project).length > 0 ? { ...store, [key]: project } : (() => {
212
+ const { [key]: _empty, ...rest } = store;
213
+ return rest;
214
+ })();
215
+ return writeApprovalStore(next, { home: options.home });
216
+ }
@@ -0,0 +1,225 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { REPOSITORY_SCOPES } from "./mcp-config-layers.js";
4
+ import { approvalState } from "./mcp-approval-store.js";
5
+ import { CATALOG_OPTIONAL_ENV } from "./mcp-server-catalog.js";
6
+
7
+ // Whether a configured server can actually be used, answered from state this
8
+ // process already has. No network, no child process: this runs at session start,
9
+ // and a check that costs a round trip would be paid on every session for
10
+ // information that changes when somebody edits a config.
11
+ //
12
+ // The line it will not cross is guessing. Two things are knowable here — an
13
+ // environment variable a config references is set or it is not, an executable is
14
+ // on PATH or it is not — and those are reported. Whether an OAuth sign-in is
15
+ // still valid is known to Pi and not to this file, so it is reported as
16
+ // "sign-in is handled elsewhere" rather than as a failure that may not be one.
17
+
18
+ const ENV_REFERENCE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
19
+
20
+ /**
21
+ * @typedef {"disabled"|"rejected"|"approval-changed"|"pending-approval"|"needs-command"|"needs-env"|"oauth"|"ready"} ReadinessState
22
+ */
23
+
24
+ /**
25
+ * States that mean the server will not answer a tool call. `oauth` is not one of
26
+ * them: it may well be signed in already.
27
+ * @type {Set<ReadinessState>}
28
+ */
29
+ export const BLOCKING_STATES = new Set([
30
+ "disabled", "rejected", "approval-changed", "pending-approval", "needs-command", "needs-env"
31
+ ]);
32
+
33
+ /**
34
+ * Every `${VAR}` a server entry references, wherever it appears.
35
+ * @param {unknown} entry
36
+ * @returns {string[]}
37
+ */
38
+ export function referencedEnvVars(entry) {
39
+ const names = new Set();
40
+ walk(entry, (text) => {
41
+ for (const match of text.matchAll(ENV_REFERENCE)) names.add(match[1]);
42
+ });
43
+ // `bearerTokenEnvVar` names its variable directly instead of through a
44
+ // `${VAR}` reference, which is the entire point of the field: the token must
45
+ // not appear in the config. The walk above only recognises the reference form,
46
+ // so without this the one server setting that guarantees a credential is
47
+ // needed was the one setting that never produced a needs-env.
48
+ if (typeof entry?.bearerTokenEnvVar === "string" && entry.bearerTokenEnvVar.trim()) {
49
+ names.add(entry.bearerTokenEnvVar.trim());
50
+ }
51
+ return [...names].sort();
52
+ }
53
+
54
+ /** @param {unknown} value @param {(text: string) => void} visit @returns {void} */
55
+ function walk(value, visit) {
56
+ if (typeof value === "string") {
57
+ visit(value);
58
+ return;
59
+ }
60
+ if (Array.isArray(value)) {
61
+ for (const item of value) walk(item, visit);
62
+ return;
63
+ }
64
+ if (value && typeof value === "object") {
65
+ for (const item of Object.values(value)) walk(item, visit);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * @param {string} command
71
+ * @param {{env?: Record<string, string|undefined>}} [options]
72
+ * @returns {boolean}
73
+ */
74
+ export function commandOnPath(command, options = {}) {
75
+ const env = options.env ?? process.env;
76
+ if (typeof command !== "string" || command.length === 0) return false;
77
+ // An explicit path is checked where it points rather than searched for.
78
+ if (command.includes(path.sep)) return isExecutable(command);
79
+ for (const directory of (env.PATH ?? "").split(path.delimiter)) {
80
+ if (!directory) continue;
81
+ if (isExecutable(path.join(directory, command))) return true;
82
+ }
83
+ return false;
84
+ }
85
+
86
+ /** @param {string} file @returns {boolean} */
87
+ function isExecutable(file) {
88
+ try {
89
+ fs.accessSync(file, fs.constants.X_OK);
90
+ return fs.statSync(file).isFile();
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * @typedef {object} ServerReadiness
98
+ * @property {string} name
99
+ * @property {string} scope
100
+ * @property {ReadinessState} state
101
+ * @property {string} detail human-readable, never contains a value
102
+ * @property {string} [remedy] the command that resolves it
103
+ * @property {string[]} missingEnv
104
+ */
105
+
106
+ /**
107
+ * The import kind a server arrived through, or undefined when the layer named
108
+ * it directly.
109
+ * @param {{origin?: string}} server
110
+ * @returns {string|undefined}
111
+ */
112
+ function importedFrom(server) {
113
+ return server.origin?.startsWith("import:") ? server.origin.slice("import:".length) : undefined;
114
+ }
115
+
116
+ /**
117
+ * @param {{name: string, scope: string, entry: Record<string, unknown>, requiresApproval?: boolean, origin?: string}} server
118
+ * @param {{projectPath: string, env?: Record<string, string|undefined>, home?: string, store?: Record<string, unknown>}} options
119
+ * @returns {ServerReadiness}
120
+ */
121
+ export function evaluateServerReadiness(server, options) {
122
+ const env = options.env ?? process.env;
123
+ const base = { name: server.name, scope: server.scope, missingEnv: /** @type {string[]} */ ([]) };
124
+
125
+ if (server.entry.enabled === false) {
126
+ return { ...base, state: "disabled", detail: "disabled in config", remedy: `piagent-mcp enable ${server.name} --scope ${server.scope}` };
127
+ }
128
+
129
+ // `collectServers` decides this, because the scope a server is listed under
130
+ // is not the same question as who put it there: a `global` config importing
131
+ // a repository-relative kind reads its definitions out of the clone. Falling
132
+ // back to scope keeps a caller that builds a server record by hand working.
133
+ const needsApproval = server.requiresApproval ?? REPOSITORY_SCOPES.has(server.scope);
134
+ if (needsApproval) {
135
+ const approval = approvalState({
136
+ projectPath: options.projectPath,
137
+ name: server.name,
138
+ entry: server.entry,
139
+ store: options.store,
140
+ home: options.home
141
+ });
142
+ if (approval.state === "rejected") {
143
+ return { ...base, state: "rejected", detail: "rejected for this project", remedy: `piagent-mcp approve ${server.name}` };
144
+ }
145
+ if (approval.state === "changed") {
146
+ return {
147
+ ...base,
148
+ state: "approval-changed",
149
+ detail: "definition changed since it was approved",
150
+ remedy: `piagent-mcp get ${server.name} then piagent-mcp approve ${server.name}`
151
+ };
152
+ }
153
+ if (approval.state === "pending") {
154
+ return {
155
+ ...base,
156
+ state: "pending-approval",
157
+ // Naming the scope alone reads as a lie for an imported server: the
158
+ // scope is where the `imports` key sits, and the definition is in a
159
+ // different file entirely. Say which of the two it was.
160
+ detail: importedFrom(server)
161
+ ? `imported from ${importedFrom(server)} config by the ${server.scope} layer, and not approved here`
162
+ : `defined by this repository in ${server.scope} scope and not approved here`,
163
+ remedy: `piagent-mcp approve ${server.name}`
164
+ };
165
+ }
166
+ }
167
+
168
+ if (typeof server.entry.command === "string" && !commandOnPath(server.entry.command, { env })) {
169
+ return {
170
+ ...base,
171
+ state: "needs-command",
172
+ detail: `${server.entry.command} is not on PATH`,
173
+ remedy: `install ${server.entry.command}, or edit the server with piagent-mcp add ${server.name} --scope ${server.scope} -- <command>`
174
+ };
175
+ }
176
+
177
+ const optional = new Set(CATALOG_OPTIONAL_ENV[server.name] ?? []);
178
+ const missingEnv = referencedEnvVars(server.entry)
179
+ .filter((name) => !optional.has(name))
180
+ .filter((name) => !env[name]?.trim());
181
+ if (missingEnv.length > 0) {
182
+ return {
183
+ ...base,
184
+ missingEnv,
185
+ state: "needs-env",
186
+ detail: `${missingEnv.join(", ")} ${missingEnv.length === 1 ? "is" : "are"} referenced but not set`,
187
+ remedy: `export ${missingEnv[0]}=... in your shell profile`
188
+ };
189
+ }
190
+
191
+ if (server.entry.auth === "oauth") {
192
+ return {
193
+ ...base,
194
+ state: "oauth",
195
+ detail: "signs in with OAuth; Pi holds the session",
196
+ remedy: `/mcp-auth ${server.name} inside Pi if it is not signed in`
197
+ };
198
+ }
199
+
200
+ return { ...base, state: "ready", detail: "ready" };
201
+ }
202
+
203
+ /**
204
+ * One line for the session start, or nothing when every server is usable.
205
+ *
206
+ * Only definite problems are named. A server waiting on OAuth is left out
207
+ * because this process cannot tell a signed-in one from a signed-out one, and a
208
+ * notice that appears every session regardless of state stops being read.
209
+ *
210
+ * @param {ServerReadiness[]} readiness
211
+ * @returns {string|undefined}
212
+ */
213
+ export function readinessNotice(readiness) {
214
+ const blocked = readiness.filter((item) => BLOCKING_STATES.has(item.state));
215
+ if (blocked.length === 0) return undefined;
216
+ const waiting = blocked.filter((item) => item.state === "pending-approval" || item.state === "approval-changed");
217
+ const parts = blocked.slice(0, 4).map((item) => `${item.name} (${item.detail})`);
218
+ const overflow = blocked.length > parts.length ? `, and ${blocked.length - parts.length} more` : "";
219
+ const lead = waiting.length > 0
220
+ ? "MCP servers need a decision or setup"
221
+ : "MCP servers are configured but cannot connect";
222
+ // This line is only ever printed at session start, so it names the command
223
+ // that answers it from there rather than sending the reader to a shell.
224
+ return `${lead}: ${parts.join("; ")}${overflow}. Run \`/piagent-mcp doctor\` for what each one needs.`;
225
+ }