agent-trellis 0.13.0 → 0.14.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.
@@ -3,22 +3,61 @@
3
3
  * symlinked to canonical `agents.md` — specifically `AGENTS.md`, not
4
4
  * `AGENTS.override.md` (design.md D3, trellis-sync-p1: pi checks the
5
5
  * override name first, and Trellis's managed file is the baseline, not an
6
- * override of something else). pi has no native MCP client (docs/research.md),
7
- * so MCP access is delivered as a single symlinked bridge extension
8
- * instead of native config (trellis-pi-mcp-bridge-p4) — a different
9
- * mechanism from every other agent's config-generation adapter, but the
10
- * same symlink create/repair/remove machinery as skills/instructions.
6
+ * override of something else).
7
+ *
8
+ * MCP delivery is version-gated (trellis-pi-native-mcp-migration):
9
+ *
10
+ * - pi >= 1.1.0 has a native MCP client, so `trellis mcp sync` merges
11
+ * Trellis-managed entries into `~/.pi/agent/mcp.json` through the shared
12
+ * `planJsonMcp`/`applyJsonMcp` machinery (ownership ledger, conflict
13
+ * detection, user entries preserved) with the pi-shaped renderer from
14
+ * `piMcp.ts`. Gateway route → the single `trellis` runtime entry;
15
+ * agent-owned OAuth servers → bare `{url}` entries the user authorizes
16
+ * with `pi mcp login`.
17
+ * - Older pis (or an unprobeable version) keep the legacy delivery: a
18
+ * single symlinked bridge extension (`dist/pi-bridge/bundle.js`,
19
+ * trellis-pi-mcp-bridge-p4). The version gate never moves a machine
20
+ * FROM the bridge TO native until the mcp.json write succeeds — the
21
+ * bridge-removal item rides along in the same plan, ordered after the
22
+ * config write, and is withheld entirely when any write conflicts
23
+ * (design.md D3: the two deliveries may never be active at once, every
24
+ * tool would register twice).
11
25
  */
12
26
  import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
13
27
  import type { CanonicalSource } from "../core/types.js";
14
- import type { BackupSession } from "../lib/backup.js";
28
+ import { type BackupSession } from "../lib/backup.js";
15
29
  export declare class PiAdapter implements TrellisAdapter {
16
30
  private readonly homeDir;
31
+ /** Test seam: the version gate must not depend on whatever pi happens
32
+ * to be installed on the machine running the tests. */
33
+ private readonly versionProbe;
17
34
  readonly name = "pi";
18
35
  readonly id: "pi";
19
- constructor(homeDir?: string);
36
+ constructor(homeDir?: string,
37
+ /** Test seam: the version gate must not depend on whatever pi happens
38
+ * to be installed on the machine running the tests. */
39
+ versionProbe?: () => string | undefined);
20
40
  probe(): Promise<AdapterProbeResult>;
21
41
  plan(canonical: CanonicalSource): Promise<AdapterPlanItem[]>;
42
+ /**
43
+ * Native-delivery plan (design.md D1/D3). The write items come from the
44
+ * shared JSON machinery unchanged; the bridge-removal item is appended
45
+ * last and re-cast to kind "mcp" so it travels through `trellis mcp
46
+ * sync` — the same command that writes mcp.json — never through bare
47
+ * `trellis sync`. That is what makes the migration atomic from the
48
+ * user's point of view: the config lands and the bridge leaves in one
49
+ * apply, and a bare skills/instructions sync can neither remove the
50
+ * bridge early nor resurrect it afterwards.
51
+ */
52
+ private planNativeMcp;
53
+ /**
54
+ * Removal ownership for the bridge, mirroring `planSymlinks`' rule: the
55
+ * raw readlink target must resolve under this installation's
56
+ * `dist/pi-bridge/` directory, or be a byte-identical bundle at the
57
+ * Trellis artifact shape left by a previous install root. Anything else
58
+ * is the user's and stays.
59
+ */
60
+ private isOwnedBridgeSymlink;
22
61
  apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
23
62
  verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
24
63
  }
@@ -3,20 +3,39 @@
3
3
  * symlinked to canonical `agents.md` — specifically `AGENTS.md`, not
4
4
  * `AGENTS.override.md` (design.md D3, trellis-sync-p1: pi checks the
5
5
  * override name first, and Trellis's managed file is the baseline, not an
6
- * override of something else). pi has no native MCP client (docs/research.md),
7
- * so MCP access is delivered as a single symlinked bridge extension
8
- * instead of native config (trellis-pi-mcp-bridge-p4) — a different
9
- * mechanism from every other agent's config-generation adapter, but the
10
- * same symlink create/repair/remove machinery as skills/instructions.
6
+ * override of something else).
7
+ *
8
+ * MCP delivery is version-gated (trellis-pi-native-mcp-migration):
9
+ *
10
+ * - pi >= 1.1.0 has a native MCP client, so `trellis mcp sync` merges
11
+ * Trellis-managed entries into `~/.pi/agent/mcp.json` through the shared
12
+ * `planJsonMcp`/`applyJsonMcp` machinery (ownership ledger, conflict
13
+ * detection, user entries preserved) with the pi-shaped renderer from
14
+ * `piMcp.ts`. Gateway route → the single `trellis` runtime entry;
15
+ * agent-owned OAuth servers → bare `{url}` entries the user authorizes
16
+ * with `pi mcp login`.
17
+ * - Older pis (or an unprobeable version) keep the legacy delivery: a
18
+ * single symlinked bridge extension (`dist/pi-bridge/bundle.js`,
19
+ * trellis-pi-mcp-bridge-p4). The version gate never moves a machine
20
+ * FROM the bridge TO native until the mcp.json write succeeds — the
21
+ * bridge-removal item rides along in the same plan, ordered after the
22
+ * config write, and is withheld entirely when any write conflicts
23
+ * (design.md D3: the two deliveries may never be active at once, every
24
+ * tool would register twice).
11
25
  */
26
+ import { existsSync, lstatSync, readFileSync, readlinkSync } from "node:fs";
12
27
  import { dirname, join, sep } from "node:path";
13
28
  import { fileURLToPath } from "node:url";
14
29
  import { homedir } from "node:os";
15
- import { readFileSync } from "node:fs";
16
30
  import { isInScope } from "../core/adapter.js";
17
31
  import { usesNativeCapabilityDelivery } from "../core/types.js";
18
32
  import * as piProbe from "../probes/pi.js";
33
+ import { probePiVersion } from "../probes/pi.js";
19
34
  import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
35
+ import { applyJsonMcp, planJsonMcp } from "./jsonMcp.js";
36
+ import { piRenderer, supportsNativeMcp } from "./piMcp.js";
37
+ import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
38
+ import { currentLinkTarget } from "../lib/backup.js";
20
39
  const BRIDGE_SYMLINK_NAME = "trellis-mcp-bridge.js";
21
40
  /**
22
41
  * A source-checkout bridge can survive a switch to the packaged CLI. Adopt it
@@ -61,10 +80,15 @@ function resolveBridgeFile() {
61
80
  }
62
81
  export class PiAdapter {
63
82
  homeDir;
83
+ versionProbe;
64
84
  name = "pi";
65
85
  id = "pi";
66
- constructor(homeDir = homedir()) {
86
+ constructor(homeDir = homedir(),
87
+ /** Test seam: the version gate must not depend on whatever pi happens
88
+ * to be installed on the machine running the tests. */
89
+ versionProbe = probePiVersion) {
67
90
  this.homeDir = homeDir;
91
+ this.versionProbe = versionProbe;
68
92
  }
69
93
  async probe() {
70
94
  const snapshot = await piProbe.probe(this.homeDir);
@@ -88,6 +112,9 @@ export class PiAdapter {
88
112
  canonicalRoot,
89
113
  kind: "instructions",
90
114
  });
115
+ if (supportsNativeMcp(this.versionProbe())) {
116
+ return [...skillItems, ...instructionsItems, ...this.planNativeMcp(canonical)];
117
+ }
91
118
  const { file: bridgeFile, symlinkName } = resolveBridgeFile();
92
119
  const extensionItems = planSymlinks({
93
120
  rootDir: join(agentDir, "extensions"),
@@ -101,8 +128,104 @@ export class PiAdapter {
101
128
  });
102
129
  return [...skillItems, ...instructionsItems, ...extensionItems];
103
130
  }
131
+ /**
132
+ * Native-delivery plan (design.md D1/D3). The write items come from the
133
+ * shared JSON machinery unchanged; the bridge-removal item is appended
134
+ * last and re-cast to kind "mcp" so it travels through `trellis mcp
135
+ * sync` — the same command that writes mcp.json — never through bare
136
+ * `trellis sync`. That is what makes the migration atomic from the
137
+ * user's point of view: the config lands and the bridge leaves in one
138
+ * apply, and a bare skills/instructions sync can neither remove the
139
+ * bridge early nor resurrect it afterwards.
140
+ */
141
+ planNativeMcp(canonical) {
142
+ const agentDir = join(this.homeDir, ".pi", "agent");
143
+ const configPath = join(agentDir, "mcp.json");
144
+ const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
145
+ const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
146
+ const items = planJsonMcp({
147
+ configPath,
148
+ parsed,
149
+ mcp: canonical.mcp,
150
+ agentId: this.id,
151
+ managedAgents: canonical.managedAgents,
152
+ policy: canonical.secretsPolicy,
153
+ ownership,
154
+ render: piRenderer,
155
+ }).map((item) => {
156
+ // pi speaks stdio and streamable HTTP only — an sse server gets an
157
+ // honest conflict instead of an entry pi could never connect.
158
+ const write = item.mcpWrite;
159
+ if (write?.def.transport !== "sse")
160
+ return item;
161
+ return {
162
+ action: "conflict",
163
+ kind: "mcp",
164
+ target: item.target,
165
+ description: `MCP server "${write.name}" uses the sse transport, which pi does not support (stdio and streamable HTTP only) — no entry written to ${item.target}`,
166
+ remediation: "switch the server to streamable HTTP or stdio, or scope it away from pi",
167
+ };
168
+ });
169
+ const bridgePath = join(agentDir, "extensions", BRIDGE_SYMLINK_NAME);
170
+ const hasConflict = items.some((item) => item.action === "conflict");
171
+ if (!hasConflict && this.isOwnedBridgeSymlink(bridgePath)) {
172
+ items.push({
173
+ action: "remove",
174
+ kind: "mcp",
175
+ target: bridgePath,
176
+ description: `remove Trellis MCP bridge extension ${bridgePath} — pi >= 1.1.0 receives MCP natively via mcp.json`,
177
+ });
178
+ }
179
+ return items;
180
+ }
181
+ /**
182
+ * Removal ownership for the bridge, mirroring `planSymlinks`' rule: the
183
+ * raw readlink target must resolve under this installation's
184
+ * `dist/pi-bridge/` directory, or be a byte-identical bundle at the
185
+ * Trellis artifact shape left by a previous install root. Anything else
186
+ * is the user's and stays.
187
+ */
188
+ isOwnedBridgeSymlink(bridgePath) {
189
+ const stat = lstatSync(bridgePath, { throwIfNoEntry: false });
190
+ if (!stat?.isSymbolicLink())
191
+ return false;
192
+ const rawTarget = readlinkSync(bridgePath);
193
+ const { file } = resolveBridgeFile();
194
+ const bridgeRoot = dirname(file);
195
+ const resolved = rawTarget.startsWith(sep) ? rawTarget : join(dirname(bridgePath), rawTarget);
196
+ if (resolved === file || resolved.startsWith(bridgeRoot.endsWith(sep) ? bridgeRoot : `${bridgeRoot}${sep}`))
197
+ return true;
198
+ return isEquivalentTrellisBridge(resolved, file);
199
+ }
104
200
  async apply(plan, backup) {
105
- await applySymlinkPlan(plan, backup);
201
+ await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"), backup);
202
+ // Real config writes carry mcpWrite/mcpRemove; the bridge-removal
203
+ // item is the only kind:"mcp" entry without either, and it must run
204
+ // strictly after the config write (design.md D3).
205
+ const mcpWrites = plan.filter((item) => item.kind === "mcp" && (item.mcpWrite || item.mcpRemove));
206
+ const bridgeRemovals = plan.filter((item) => item.kind === "mcp" && item.action === "remove" && !item.mcpWrite && !item.mcpRemove);
207
+ if (mcpWrites.length > 0) {
208
+ const configPath = mcpWrites[0].target;
209
+ const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
210
+ const merged = applyJsonMcp(parsed, mcpWrites, piRenderer);
211
+ backup.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`);
212
+ let ownership = loadMcpOwnership(this.homeDir);
213
+ for (const item of mcpWrites) {
214
+ if (item.action === "create" && item.mcpWrite) {
215
+ ownership = recordOwned(ownership, this.id, item.mcpWrite.name, piRenderer(item.mcpWrite.def, item.mcpWrite.name));
216
+ }
217
+ else if (item.action === "remove" && item.mcpRemove) {
218
+ ownership = forgetOwned(ownership, this.id, item.mcpRemove.name);
219
+ }
220
+ }
221
+ saveMcpOwnership(this.homeDir, ownership);
222
+ }
223
+ for (const item of bridgeRemovals) {
224
+ const oldTarget = await currentLinkTarget(item.target);
225
+ if (oldTarget === undefined)
226
+ continue; // already gone — nothing to record
227
+ await backup.removeSymlink(item.target, oldTarget);
228
+ }
106
229
  }
107
230
  async verify(canonical) {
108
231
  const snapshot = await piProbe.probe(this.homeDir);
@@ -122,6 +245,26 @@ export class PiAdapter {
122
245
  mismatches.push(`skill "${name}" is present on disk but not in canonical scope for pi`);
123
246
  }
124
247
  }
248
+ if (supportsNativeMcp(this.versionProbe())) {
249
+ // Native mode drift: anything the plan still wants to change is by
250
+ // definition drift. The plan only ever names Trellis-owned entries
251
+ // (the ownership ledger gates removals), so user-authored servers
252
+ // can never surface here.
253
+ for (const item of this.planNativeMcp(canonical)) {
254
+ if (item.action === "create" && item.mcpWrite) {
255
+ mismatches.push(`mcp.json entry "${item.mcpWrite.name}" is missing or differs from canonical — run trellis mcp sync`);
256
+ }
257
+ else if (item.action === "remove" && item.mcpRemove) {
258
+ mismatches.push(`mcp.json entry "${item.mcpRemove.name}" is Trellis-owned but no longer in canonical — run trellis mcp sync`);
259
+ }
260
+ else if (item.action === "remove") {
261
+ mismatches.push(`the Trellis MCP bridge extension is still installed but pi >= 1.1.0 uses native mcp.json — run trellis mcp sync`);
262
+ }
263
+ else if (item.action === "conflict") {
264
+ mismatches.push(item.description);
265
+ }
266
+ }
267
+ }
125
268
  return mismatches.length === 0 ? { ok: true } : { ok: false, mismatches };
126
269
  }
127
270
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * pi's native MCP config (pi >= 1.1.0) — the entry renderer and the
3
+ * version gate that chooses between it and the legacy bridge extension
4
+ * (openspec/changes/pi-native-mcp-migration design.md D1/D2).
5
+ *
6
+ * pi's mcp.json has no `type` discriminator: an entry is stdio when it
7
+ * carries `command`, streamable HTTP when it carries `url`. `${VAR}`
8
+ * references in `env`/`headers` values are expanded by pi at spawn
9
+ * (verified empirically against pi 1.1.0 on 2026-10-11), so the
10
+ * zero-plaintext rendering every other JSON agent relies on works
11
+ * unchanged. sse is not a pi transport — the adapter turns sse servers
12
+ * into conflict items rather than writing entries pi cannot connect.
13
+ */
14
+ import type { McpServerDef } from "../core/types.js";
15
+ /** The pi release that introduced the native MCP client (`pi mcp`,
16
+ * `~/.pi/agent/mcp.json`). */
17
+ export declare const PI_NATIVE_MCP_SINCE = "1.1.0";
18
+ /**
19
+ * Version gate (design.md D2): native delivery only when the probed
20
+ * version is known to understand mcp.json. Anything unparseable —
21
+ * including "binary not on PATH" (undefined) — falls back to the bridge,
22
+ * never the other way round: writing config a pi cannot read is worse
23
+ * than keeping a working bridge.
24
+ */
25
+ export declare function supportsNativeMcp(version: string | undefined): boolean;
26
+ /**
27
+ * pi-shaped rendering of a canonical server definition — the same shape
28
+ * `pi mcp add` writes (observed 2026-10-11): stdio as
29
+ * `{command, args, env?}`, http as `{url, headers?}`, no `type` field.
30
+ * `env` names render as `${VAR}` references exactly like the other JSON
31
+ * agents; OAuth `auth` metadata is Trellis-side routing information and is
32
+ * never rendered — with one exception: a declared `auth.client_name` becomes
33
+ * the entry's `oauth.clientName`, the field `pi mcp add --oauth-client-name`
34
+ * writes, so providers that gate dynamic client registration by client name
35
+ * (Figma) accept `pi mcp login`'s registration and later syncs compare equal
36
+ * instead of stripping the block (oauth-client-name design.md D2).
37
+ */
38
+ export declare function piRenderer(def: McpServerDef, _name: string): Record<string, unknown>;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * pi's native MCP config (pi >= 1.1.0) — the entry renderer and the
3
+ * version gate that chooses between it and the legacy bridge extension
4
+ * (openspec/changes/pi-native-mcp-migration design.md D1/D2).
5
+ *
6
+ * pi's mcp.json has no `type` discriminator: an entry is stdio when it
7
+ * carries `command`, streamable HTTP when it carries `url`. `${VAR}`
8
+ * references in `env`/`headers` values are expanded by pi at spawn
9
+ * (verified empirically against pi 1.1.0 on 2026-10-11), so the
10
+ * zero-plaintext rendering every other JSON agent relies on works
11
+ * unchanged. sse is not a pi transport — the adapter turns sse servers
12
+ * into conflict items rather than writing entries pi cannot connect.
13
+ */
14
+ import { isOAuthAuth } from "../core/types.js";
15
+ import { resolvedEnvTextMap } from "../lib/mcpMigrateRead.js";
16
+ /** The pi release that introduced the native MCP client (`pi mcp`,
17
+ * `~/.pi/agent/mcp.json`). */
18
+ export const PI_NATIVE_MCP_SINCE = "1.1.0";
19
+ /**
20
+ * Version gate (design.md D2): native delivery only when the probed
21
+ * version is known to understand mcp.json. Anything unparseable —
22
+ * including "binary not on PATH" (undefined) — falls back to the bridge,
23
+ * never the other way round: writing config a pi cannot read is worse
24
+ * than keeping a working bridge.
25
+ */
26
+ export function supportsNativeMcp(version) {
27
+ if (version === undefined)
28
+ return false;
29
+ const match = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(version.trim());
30
+ if (!match)
31
+ return false;
32
+ const major = Number(match[1]);
33
+ const minor = Number(match[2]);
34
+ return major > 1 || (major === 1 && minor >= 1);
35
+ }
36
+ /**
37
+ * pi-shaped rendering of a canonical server definition — the same shape
38
+ * `pi mcp add` writes (observed 2026-10-11): stdio as
39
+ * `{command, args, env?}`, http as `{url, headers?}`, no `type` field.
40
+ * `env` names render as `${VAR}` references exactly like the other JSON
41
+ * agents; OAuth `auth` metadata is Trellis-side routing information and is
42
+ * never rendered — with one exception: a declared `auth.client_name` becomes
43
+ * the entry's `oauth.clientName`, the field `pi mcp add --oauth-client-name`
44
+ * writes, so providers that gate dynamic client registration by client name
45
+ * (Figma) accept `pi mcp login`'s registration and later syncs compare equal
46
+ * instead of stripping the block (oauth-client-name design.md D2).
47
+ */
48
+ export function piRenderer(def, _name) {
49
+ if (def.transport === "http" || def.transport === "sse") {
50
+ const entry = { url: def.url };
51
+ if (def.headers && Object.keys(def.headers).length > 0) {
52
+ entry.headers = def.headers;
53
+ }
54
+ if (isOAuthAuth(def.auth) && typeof def.auth === "object" && def.auth.clientName !== undefined) {
55
+ entry.oauth = { clientName: def.auth.clientName };
56
+ }
57
+ return entry;
58
+ }
59
+ const entry = { command: def.command, args: def.args ?? [] };
60
+ const env = resolvedEnvTextMap(def);
61
+ if (Object.keys(env).length > 0) {
62
+ entry.env = env;
63
+ }
64
+ return entry;
65
+ }
@@ -111,6 +111,12 @@ export function printReport(report, dryRun) {
111
111
  for (const item of [...created, ...removed, ...conflicts]) {
112
112
  console.log(` - [${item.action}] ${item.description}`);
113
113
  }
114
+ // pi does not watch mcp.json (verified 2026-10-11): without this hint
115
+ // a changed config silently only takes effect on the next pi launch
116
+ // (trellis-pi-native-mcp-migration design.md D5).
117
+ if (agent === "pi" && (created.length > 0 || removed.length > 0)) {
118
+ console.log(" pi does not watch mcp.json — restart pi or run /reload to pick up MCP changes");
119
+ }
114
120
  }
115
121
  }
116
122
  function serversYamlPath(homeDir) {
@@ -10,9 +10,10 @@ import type { BackupSession } from "../lib/backup.js";
10
10
  * The on-disk shape for one server entry — `static_env`/`env_aliases`
11
11
  * (snake_case, like every other multi-word key across `.trellis/*.yaml`)
12
12
  * are translated to `McpServerDef.staticEnv`/`envAliases` (camelCase)
13
- * below, and `auth`'s object form carries `client_id`/`client_secret_env`
14
- * the same way (trellis-mcp-static-env-and-disabled-servers,
15
- * trellis-migrate-env-var-alias, trellis-mcp-oauth-static-client).
13
+ * below, and `auth`'s object form carries `client_id`/`client_secret_env`/
14
+ * `client_name` the same way (trellis-mcp-static-env-and-disabled-servers,
15
+ * trellis-migrate-env-var-alias, trellis-mcp-oauth-static-client,
16
+ * oauth-client-name).
16
17
  */
17
18
  type McpServerDefYaml = Omit<McpServerDef, "staticEnv" | "envAliases" | "auth"> & {
18
19
  static_env?: Record<string, string>;
@@ -28,6 +29,7 @@ type McpAuthYaml = string | {
28
29
  owner?: unknown;
29
30
  client_id?: unknown;
30
31
  client_secret_env?: unknown;
32
+ client_name?: unknown;
31
33
  };
32
34
  /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
33
35
  * `undefined` fields so the written YAML never gets a literal `null`
@@ -34,12 +34,12 @@ function fromAuthYaml(serverName, auth) {
34
34
  if (auth === "oauth")
35
35
  return "oauth";
36
36
  if (typeof auth !== "object" || auth === null) {
37
- throw new Error(`mcp/servers.yaml: "${serverName}" auth must be the scalar "oauth" or { kind: oauth, client_id?, client_secret_env? }`);
37
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth must be the scalar "oauth" or { kind: oauth, client_id?, client_secret_env?, client_name? }`);
38
38
  }
39
- const { kind, owner, client_id, client_secret_env, ...extra } = auth;
39
+ const { kind, owner, client_id, client_secret_env, client_name, ...extra } = auth;
40
40
  const unknownKey = Object.keys(extra)[0];
41
41
  if (unknownKey !== undefined) {
42
- throw new Error(`mcp/servers.yaml: "${serverName}" auth has unrecognized key "${unknownKey}" — expected kind, owner, client_id, client_secret_env`);
42
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth has unrecognized key "${unknownKey}" — expected kind, owner, client_id, client_secret_env, client_name`);
43
43
  }
44
44
  if (kind !== "oauth") {
45
45
  throw new Error(`mcp/servers.yaml: "${serverName}" auth.kind must be "oauth" — kind is the only recognized discriminator`);
@@ -50,6 +50,9 @@ function fromAuthYaml(serverName, auth) {
50
50
  if (client_secret_env !== undefined && (typeof client_secret_env !== "string" || !isValidSecretVarName(client_secret_env))) {
51
51
  throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_secret_env must be a variable NAME (e.g. FIGMA_CLIENT_SECRET), never a secret value`);
52
52
  }
53
+ if (client_name !== undefined && typeof client_name !== "string") {
54
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_name must be a string`);
55
+ }
53
56
  const out = { kind: "oauth" };
54
57
  if (owner !== undefined && owner !== "agent" && owner !== "trellis")
55
58
  throw new Error(`mcp/servers.yaml: "${serverName}" auth.owner must be agent or trellis`);
@@ -59,6 +62,8 @@ function fromAuthYaml(serverName, auth) {
59
62
  out.clientId = client_id;
60
63
  if (typeof client_secret_env === "string")
61
64
  out.clientSecretEnv = client_secret_env;
65
+ if (typeof client_name === "string")
66
+ out.clientName = client_name;
62
67
  return out;
63
68
  }
64
69
  /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
@@ -80,6 +85,7 @@ export function toServerDefYaml(def) {
80
85
  ...(auth.owner !== undefined ? { owner: auth.owner } : {}),
81
86
  ...(auth.clientId !== undefined ? { client_id: auth.clientId } : {}),
82
87
  ...(auth.clientSecretEnv !== undefined ? { client_secret_env: auth.clientSecretEnv } : {}),
88
+ ...(auth.clientName !== undefined ? { client_name: auth.clientName } : {}),
83
89
  };
84
90
  }
85
91
  for (const key of Object.keys(out)) {
@@ -19,6 +19,10 @@ export interface McpAuthConfig {
19
19
  owner?: "agent" | "trellis";
20
20
  clientId?: string;
21
21
  clientSecretEnv?: string;
22
+ /** Self-asserted display name an Agent presents at dynamic client
23
+ * registration — for providers that gate DCR by client name (Figma).
24
+ * Public metadata, not a credential (oauth-client-name design.md D1). */
25
+ clientName?: string;
22
26
  }
23
27
  /** True when `auth` classifies the server as OAuth, in either form. Every
24
28
  * former `def.auth === "oauth"` check must go through this now that the
@@ -7948,12 +7948,12 @@ function fromServerDefYaml(serverName, def) {
7948
7948
  function fromAuthYaml(serverName, auth2) {
7949
7949
  if (auth2 === "oauth") return "oauth";
7950
7950
  if (typeof auth2 !== "object" || auth2 === null) {
7951
- throw new Error(`mcp/servers.yaml: "${serverName}" auth must be the scalar "oauth" or { kind: oauth, client_id?, client_secret_env? }`);
7951
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth must be the scalar "oauth" or { kind: oauth, client_id?, client_secret_env?, client_name? }`);
7952
7952
  }
7953
- const { kind, owner, client_id, client_secret_env, ...extra } = auth2;
7953
+ const { kind, owner, client_id, client_secret_env, client_name, ...extra } = auth2;
7954
7954
  const unknownKey = Object.keys(extra)[0];
7955
7955
  if (unknownKey !== void 0) {
7956
- throw new Error(`mcp/servers.yaml: "${serverName}" auth has unrecognized key "${unknownKey}" \u2014 expected kind, owner, client_id, client_secret_env`);
7956
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth has unrecognized key "${unknownKey}" \u2014 expected kind, owner, client_id, client_secret_env, client_name`);
7957
7957
  }
7958
7958
  if (kind !== "oauth") {
7959
7959
  throw new Error(`mcp/servers.yaml: "${serverName}" auth.kind must be "oauth" \u2014 kind is the only recognized discriminator`);
@@ -7964,11 +7964,15 @@ function fromAuthYaml(serverName, auth2) {
7964
7964
  if (client_secret_env !== void 0 && (typeof client_secret_env !== "string" || !isValidSecretVarName(client_secret_env))) {
7965
7965
  throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_secret_env must be a variable NAME (e.g. FIGMA_CLIENT_SECRET), never a secret value`);
7966
7966
  }
7967
+ if (client_name !== void 0 && typeof client_name !== "string") {
7968
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_name must be a string`);
7969
+ }
7967
7970
  const out = { kind: "oauth" };
7968
7971
  if (owner !== void 0 && owner !== "agent" && owner !== "trellis") throw new Error(`mcp/servers.yaml: "${serverName}" auth.owner must be agent or trellis`);
7969
7972
  if (owner !== void 0) out.owner = owner;
7970
7973
  if (typeof client_id === "string") out.clientId = client_id;
7971
7974
  if (typeof client_secret_env === "string") out.clientSecretEnv = client_secret_env;
7975
+ if (typeof client_name === "string") out.clientName = client_name;
7972
7976
  return out;
7973
7977
  }
7974
7978
  function trellisRoot(homeDir) {
@@ -5,10 +5,13 @@
5
5
  * and `dist/core/resource-loader.js` (`loadContextFileFromDir(resolvedAgentDir)`),
6
6
  * not by assumption. See design.md D5 (openspec/changes/trellis-doctor-p0).
7
7
  *
8
- * pi has no native MCP client (docs/research.md) — nothing to read or
9
- * probe, so `mcpServers` is always empty here. That's real information
10
- * (this is why P4 has to write a runtime bridge extension instead of
11
- * generating config), not an omission.
8
+ * pi >= 1.1.0 has a native MCP client (`~/.pi/agent/mcp.json`), which the
9
+ * adapter manages (trellis-pi-native-mcp-migration); the probe still
10
+ * reports `mcpServers: []` because server health is the runtime's job,
11
+ * not the probe's. `probePiVersion` is split out so the adapter's
12
+ * native-vs-bridge version gate shares exactly this exec and tests can
13
+ * inject a seam.
12
14
  */
13
15
  import type { AgentSnapshot } from "../core/types.js";
16
+ export declare function probePiVersion(): string | undefined;
14
17
  export declare function probe(homeDir?: string): Promise<AgentSnapshot>;
package/dist/probes/pi.js CHANGED
@@ -5,16 +5,26 @@
5
5
  * and `dist/core/resource-loader.js` (`loadContextFileFromDir(resolvedAgentDir)`),
6
6
  * not by assumption. See design.md D5 (openspec/changes/trellis-doctor-p0).
7
7
  *
8
- * pi has no native MCP client (docs/research.md) — nothing to read or
9
- * probe, so `mcpServers` is always empty here. That's real information
10
- * (this is why P4 has to write a runtime bridge extension instead of
11
- * generating config), not an omission.
8
+ * pi >= 1.1.0 has a native MCP client (`~/.pi/agent/mcp.json`), which the
9
+ * adapter manages (trellis-pi-native-mcp-migration); the probe still
10
+ * reports `mcpServers: []` because server health is the runtime's job,
11
+ * not the probe's. `probePiVersion` is split out so the adapter's
12
+ * native-vs-bridge version gate shares exactly this exec and tests can
13
+ * inject a seam.
12
14
  */
13
15
  import { execFileSync } from "node:child_process";
14
16
  import { existsSync } from "node:fs";
15
17
  import { homedir } from "node:os";
16
18
  import { join } from "node:path";
17
19
  import { pathRef, scanSkillRoot } from "../lib/probeCommon.js";
20
+ export function probePiVersion() {
21
+ try {
22
+ return execFileSync("pi", ["--version"], { encoding: "utf-8", timeout: 5_000 }).trim();
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
18
28
  /** Priority order pi itself checks (`loadContextFileFromDir`), first match wins. */
19
29
  const INSTRUCTIONS_CANDIDATES = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
20
30
  export async function probe(homeDir = homedir()) {
@@ -25,13 +35,8 @@ export async function probe(homeDir = homedir()) {
25
35
  if (!present) {
26
36
  return { agent: "pi", present: false, skillRoots: [], mcpServers: [], diagnostics: [] };
27
37
  }
28
- let version;
29
- try {
30
- version = execFileSync("pi", ["--version"], { encoding: "utf-8", timeout: 5_000 }).trim();
31
- }
32
- catch {
33
- // config exists without the binary on PATH — unusual, still probeable
34
- }
38
+ // config can exist without the binary on PATH — unusual, still probeable
39
+ const version = probePiVersion();
35
40
  const skillRoot = scanSkillRoot(skillsPath);
36
41
  let instructionsFile;
37
42
  for (const candidate of INSTRUCTIONS_CANDIDATES) {
@@ -291,6 +291,44 @@ that advertises `--prompt`, `--resume`, and `--output-format`. A desktop-only
291
291
  profile can still receive managed configuration; Trellis never launches the
292
292
  desktop application's private helper or bundled runtime path.
293
293
 
294
+ ## pi native MCP (pi ≥ 1.1.0)
295
+
296
+ pi 1.1.0 added a native MCP client, and Trellis delivers to it the same way
297
+ as every other JSON-config agent: `trellis mcp sync` merges Trellis-managed
298
+ entries into `~/.pi/agent/mcp.json` — the single `trellis` edge entry in
299
+ gateway mode, plus a bare `{url}` entry for each agent-owned OAuth server.
300
+ Servers you added yourself (anything Trellis never wrote) are preserved
301
+ untouched, and the legacy bridge extension (`~/.pi/agent/extensions/
302
+ trellis-mcp-bridge.js`) is removed in the same run, after the config write.
303
+
304
+ Two pi-specific behaviors to know:
305
+
306
+ - pi does not watch `mcp.json`: after a sync that changed it, restart pi or
307
+ run `/reload` in an open session (sync prints this hint).
308
+ - Agent-owned OAuth servers (e.g. `figma`) are authorized by pi itself:
309
+ run `pi mcp login <name>` once; the grant lives in pi's own token store
310
+ (`~/.pi/agent/mcp-auth.json`), and `trellis.mcp.status` reports them as
311
+ `agent-managed`.
312
+ - Some providers (Figma, while in beta) gate OAuth dynamic client
313
+ registration by an allowlisted `client_name`, which rejects pi's default.
314
+ Declare the name canonically and pi registers under it:
315
+
316
+ ```yaml
317
+ figma:
318
+ transport: http
319
+ url: https://mcp.figma.com/mcp
320
+ auth:
321
+ kind: oauth
322
+ client_name: Claude Code
323
+ ```
324
+
325
+ Trellis then writes `oauth.clientName` into the pi entry — the same field
326
+ `pi mcp add --oauth-client-name` writes — so syncs converge instead of
327
+ stripping it.
328
+
329
+ On pi older than 1.1.0 nothing changes: the bridge extension remains the
330
+ delivery mechanism and `mcp.json` is never written.
331
+
294
332
  ## Trellis Runtime Skill
295
333
 
296
334
  `trellis-runtime` is the built-in operating guide that Trellis installs into
@@ -1,6 +1,6 @@
1
1
  # Scheduled OAuth renewal — `trellis mcp renew`
2
2
 
3
- The gateway and the pi bridge already refresh OAuth tokens silently before
3
+ The gateway/runtime edge already refreshes OAuth tokens silently before
4
4
  every upstream call. `trellis mcp renew` moves that renewal *earlier*: run
5
5
  on a schedule, it refreshes whatever is expiring and reports — with a
6
6
  non-zero exit code — anything that needs a human, before an Agent session
@@ -17,7 +17,8 @@ classification is explicit (mcp-auth-routing-policy), and ownership decides
17
17
  whose problem the credential is (trellis-desktop-oauth-ownership): an
18
18
  agent-owned server (the default, e.g. scalar `auth: oauth`) is authorized by
19
19
  the Agent's own client, never reads Trellis's token store, and is neither
20
- renewed nor reported here. The command never opens a browser and never
20
+ renewed nor reported here — for pi ≥ 1.1.0 that client-side authorization is
21
+ `pi mcp login <name>`, stored in pi's own `~/.pi/agent/mcp-auth.json`. The command never opens a browser and never
21
22
  performs an initial grant; that remains `trellis mcp auth <name>`
22
23
  (mcp-gateway-hosting design.md D9). Exit code: `0` when every Trellis-owned
23
24
  server is valid or refreshed, `1` when any needs `trellis mcp auth`, has
package/docs/research.md CHANGED
@@ -236,10 +236,13 @@ Reverse-engineered directly from the installed binary
236
236
  - pi **natively discovers** `AGENTS.override.md`, `AGENTS.md`, `CLAUDE.md`
237
237
  (in that priority order) — instructions and `SKILL.md`-format skills need
238
238
  no adapter work at all.
239
- - pi has **no native MCP client**. The only `mcpServers` string in its bundle
240
- belongs to the Google Gen AI SDK's Vertex tool-schema translator, which
241
- explicitly throws `"mcpServers parameter is not supported"` — unrelated to
242
- MCP proper.
239
+ - pi **gained a native MCP client in 1.1.0** (`pi mcp add/list/login`,
240
+ `~/.pi/agent/mcp.json`, stdio + streamable HTTP, `${VAR}` expansion in
241
+ `env`/`headers`, OAuth into its own `mcp-auth.json` store) — Trellis delivers
242
+ to it natively (trellis-pi-native-mcp-migration). Before 1.1.0 the only
243
+ `mcpServers` string in its bundle belonged to the Google Gen AI SDK's Vertex
244
+ tool-schema translator, which explicitly throws `"mcpServers parameter is
245
+ not supported"` — that era is why the bridge extension below exists.
243
246
  - pi's capability surface is **extensions**: JS modules loaded via
244
247
  `--extension`/discovery, with a real API (`createExtension`,
245
248
  `registerTool`). Giving pi access to the same MCP servers every other agent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-trellis",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "A unified Code Agent Runtime for Skills, MCP, Memory, and shared instructions across Claude Code, Codex, Kiro, pi, Kimi Code, and ZCode.",
5
5
  "license": "MIT",
6
6
  "author": "Paul Leo",