agent-trellis 0.5.2 → 0.6.0

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 (52) hide show
  1. package/dist/adapters/codex.js +4 -1
  2. package/dist/adapters/jsonMcp.js +4 -1
  3. package/dist/adapters/kiro.js +9 -1
  4. package/dist/adapters/mcpPlan.d.ts +15 -0
  5. package/dist/adapters/mcpPlan.js +51 -2
  6. package/dist/adapters/symlinkPlan.js +3 -0
  7. package/dist/cli.js +37 -2
  8. package/dist/commands/doctor.d.ts +13 -3
  9. package/dist/commands/doctor.js +20 -10
  10. package/dist/commands/mcpAuth.d.ts +28 -0
  11. package/dist/commands/mcpAuth.js +99 -0
  12. package/dist/commands/mcpGateway.d.ts +53 -0
  13. package/dist/commands/mcpGateway.js +148 -0
  14. package/dist/commands/migrate.d.ts +4 -0
  15. package/dist/commands/migrate.js +28 -4
  16. package/dist/commands/onboard.d.ts +34 -0
  17. package/dist/commands/onboard.js +110 -10
  18. package/dist/commands/onboardVerdict.d.ts +55 -0
  19. package/dist/commands/onboardVerdict.js +175 -0
  20. package/dist/core/adapter.d.ts +7 -0
  21. package/dist/core/canonical.js +16 -0
  22. package/dist/core/types.d.ts +30 -4
  23. package/dist/lib/backup.js +32 -3
  24. package/dist/lib/gatewayBackend.d.ts +75 -0
  25. package/dist/lib/gatewayBackend.js +131 -0
  26. package/dist/lib/mcpConnect.d.ts +53 -0
  27. package/dist/lib/mcpConnect.js +123 -0
  28. package/dist/lib/mcpToolRegistry.d.ts +89 -0
  29. package/dist/lib/mcpToolRegistry.js +94 -0
  30. package/dist/lib/oauth/discovery.d.ts +53 -0
  31. package/dist/lib/oauth/discovery.js +95 -0
  32. package/dist/lib/oauth/flow.d.ts +47 -0
  33. package/dist/lib/oauth/flow.js +193 -0
  34. package/dist/lib/oauth/lock.d.ts +25 -0
  35. package/dist/lib/oauth/lock.js +98 -0
  36. package/dist/lib/oauth/pkce.d.ts +27 -0
  37. package/dist/lib/oauth/pkce.js +40 -0
  38. package/dist/lib/oauth/refresh.d.ts +33 -0
  39. package/dist/lib/oauth/refresh.js +93 -0
  40. package/dist/lib/oauth/store.d.ts +55 -0
  41. package/dist/lib/oauth/store.js +85 -0
  42. package/dist/pi-bridge/bundle.js +14376 -14336
  43. package/dist/pi-bridge/index.d.ts +5 -9
  44. package/dist/pi-bridge/index.js +6 -106
  45. package/dist/pi-bridge/schemaTranslate.d.ts +8 -2
  46. package/dist/pi-bridge/schemaTranslate.js +8 -4
  47. package/docs/architecture.md +88 -19
  48. package/docs/getting-started.md +120 -5
  49. package/docs/research.md +166 -0
  50. package/docs/roadmap.md +17 -0
  51. package/package.json +4 -3
  52. package/schema/servers.example.yaml +24 -7
@@ -12,7 +12,12 @@
12
12
  * this bridge actually uses are declared here.
13
13
  */
14
14
  import type { TSchema } from "typebox";
15
+ import { withTimeout } from "../lib/mcpConnect.js";
15
16
  import { toPiContent } from "./schemaTranslate.js";
17
+ /** Re-exported so `trellis-mcp-connect-timeout`'s existing test keeps
18
+ * importing it from the module it was written against — proving the
19
+ * extraction changed nothing it could observe. */
20
+ export { withTimeout };
16
21
  interface PiToolResult {
17
22
  content: ReturnType<typeof toPiContent>;
18
23
  details: unknown;
@@ -28,14 +33,6 @@ interface PiExtensionAPI {
28
33
  registerTool(tool: PiToolDefinition): void;
29
34
  on?(event: "session_shutdown", handler: () => Promise<void> | void): void;
30
35
  }
31
- /**
32
- * A hanging server (process alive, protocol response never sent) leaves
33
- * `promise` permanently unsettled — indistinguishable from "still
34
- * starting up" without a bound. Racing against a timeout converts that
35
- * into an ordinary rejection, which every caller here already knows how
36
- * to isolate (design.md D1, trellis-mcp-connect-timeout).
37
- */
38
- export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T>;
39
36
  /**
40
37
  * `homeDir`/`connectTimeoutMs` default to the real `~` / 10s — only
41
38
  * overridable for tests, same seam every other Trellis entry point
@@ -45,4 +42,3 @@ export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number, m
45
42
  * `factory(pi)` — what pi actually does — works unchanged).
46
43
  */
47
44
  export default function trellisMcpBridge(pi: PiExtensionAPI, homeDir?: string, connectTimeoutMs?: number): Promise<void>;
48
- export {};
@@ -11,109 +11,17 @@
11
11
  * from Trellis's own install tree). Only the handful of fields/methods
12
12
  * this bridge actually uses are declared here.
13
13
  */
14
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15
- import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
16
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
17
- import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
18
14
  import { homedir } from "node:os";
19
15
  import { loadCanonicalSource } from "../core/canonical.js";
20
16
  import { resolveMcpPlan } from "../adapters/mcpPlan.js";
21
- import { resolveSecretEnv } from "../lib/secretEnv.js";
22
- import { extractTemplateVarNames } from "../lib/envVarNames.js";
17
+ import { connectServer, withTimeout, DEFAULT_CONNECT_TIMEOUT_MS } from "../lib/mcpConnect.js";
23
18
  import { ALL_AGENTS } from "../core/types.js";
24
19
  import { bridgedToolName, toParametersSchema, toPiContent } from "./schemaTranslate.js";
20
+ /** Re-exported so `trellis-mcp-connect-timeout`'s existing test keeps
21
+ * importing it from the module it was written against — proving the
22
+ * extraction changed nothing it could observe. */
23
+ export { withTimeout };
25
24
  const CLIENT_INFO = { name: "trellis-mcp-bridge", version: "0.0.0" };
26
- const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
27
- /**
28
- * A hanging server (process alive, protocol response never sent) leaves
29
- * `promise` permanently unsettled — indistinguishable from "still
30
- * starting up" without a bound. Racing against a timeout converts that
31
- * into an ordinary rejection, which every caller here already knows how
32
- * to isolate (design.md D1, trellis-mcp-connect-timeout).
33
- */
34
- export function withTimeout(promise, timeoutMs, message) {
35
- return new Promise((resolve, reject) => {
36
- const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
37
- promise.then((value) => {
38
- clearTimeout(timer);
39
- resolve(value);
40
- }, (err) => {
41
- clearTimeout(timer);
42
- reject(err);
43
- });
44
- });
45
- }
46
- /**
47
- * A timed-out (or otherwise failed) connect attempt must not leak the
48
- * transport's own resources — for `StdioClientTransport` specifically,
49
- * an unclosed transport means an orphaned child process that outlives
50
- * this failed attempt indefinitely (confirmed by a real leaked
51
- * subprocess during this change's own test run). `transport.close()` is
52
- * best-effort: a transport that never fully connected may itself error
53
- * on close, but the original connect failure is what the caller needs
54
- * to see, not a secondary cleanup error.
55
- */
56
- async function connectWithCleanup(client, transport, timeoutMs, message) {
57
- try {
58
- await withTimeout(client.connect(transport), timeoutMs, message);
59
- return client;
60
- }
61
- catch (err) {
62
- try {
63
- await transport.close();
64
- }
65
- catch {
66
- // best-effort; the original connect failure is what matters
67
- }
68
- throw err;
69
- }
70
- }
71
- async function connectStdio(def, secretsPolicy, timeoutMs) {
72
- const client = new Client(CLIENT_INFO, { capabilities: {} });
73
- const envAliases = def.envAliases ?? {};
74
- // `envAliases`' values are source variable names — resolved through the
75
- // exact same call as `env`'s own names, never delivered as the raw
76
- // `${sourceName}` placeholder text a consumer with no `${VAR}` runtime
77
- // of its own (like this bridge) would otherwise crash on parsing
78
- // (trellis-migrate-env-var-alias, the real notion-on-pi bug).
79
- const resolved = resolveSecretEnv([...(def.env ?? []), ...Object.values(envAliases)], secretsPolicy);
80
- const namedEnv = Object.fromEntries((def.env ?? []).map((name) => [name, resolved[name] ?? ""]));
81
- const aliasEnv = Object.fromEntries(Object.entries(envAliases).map(([targetKey, sourceName]) => [targetKey, resolved[sourceName] ?? ""]));
82
- const transport = new StdioClientTransport({
83
- command: def.command,
84
- args: def.args,
85
- // staticEnv merges last: a literal, intentionally-plain value (an
86
- // email, an environment tag) always wins over an unresolved name-only
87
- // entry's empty-string fallback for the same key — though in practice
88
- // resolveMcpPlan's D6 refusal never lets an unresolved name reach
89
- // this point at all (trellis-mcp-static-env-and-disabled-servers).
90
- env: { ...getDefaultEnvironment(), ...namedEnv, ...aliasEnv, ...(def.staticEnv ?? {}) },
91
- });
92
- return connectWithCleanup(client, transport, timeoutMs, `connect timed out after ${timeoutMs}ms`);
93
- }
94
- function resolveHeaders(def, secretsPolicy) {
95
- if (!def.headers || Object.keys(def.headers).length === 0)
96
- return undefined;
97
- const names = Object.keys(def.headers).flatMap((key) => extractTemplateVarNames(def.headers[key]));
98
- const resolved = resolveSecretEnv(names, secretsPolicy);
99
- const result = {};
100
- for (const [key, template] of Object.entries(def.headers)) {
101
- result[key] = template.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => resolved[name] ?? "");
102
- }
103
- return result;
104
- }
105
- async function connectHttp(def, secretsPolicy, timeoutMs) {
106
- const client = new Client(CLIENT_INFO, { capabilities: {} });
107
- const headers = resolveHeaders(def, secretsPolicy);
108
- const opts = headers ? { requestInit: { headers } } : undefined;
109
- return connectWithCleanup(client, new StreamableHTTPClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
110
- }
111
- async function connectSse(def, secretsPolicy, timeoutMs) {
112
- const client = new Client(CLIENT_INFO, { capabilities: {} });
113
- const headers = resolveHeaders(def, secretsPolicy);
114
- const opts = headers ? { requestInit: { headers } } : undefined;
115
- return connectWithCleanup(client, new SSEClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
116
- }
117
25
  function registerServerTools(pi, serverName, client, timeoutMs) {
118
26
  return withTimeout(client.listTools(), timeoutMs, `listTools timed out after ${timeoutMs}ms`).then((result) => {
119
27
  for (const tool of result.tools) {
@@ -178,15 +86,7 @@ export default async function trellisMcpBridge(pi, homeDir = homedir(), connectT
178
86
  await Promise.all(desired.map(async ({ name, def }) => {
179
87
  let client;
180
88
  try {
181
- if (def.transport === "http") {
182
- client = await connectHttp(def, canonical.secretsPolicy, connectTimeoutMs);
183
- }
184
- else if (def.transport === "sse") {
185
- client = await connectSse(def, canonical.secretsPolicy, connectTimeoutMs);
186
- }
187
- else {
188
- client = await connectStdio(def, canonical.secretsPolicy, connectTimeoutMs);
189
- }
89
+ client = await connectServer(def, canonical.secretsPolicy, connectTimeoutMs, CLIENT_INFO);
190
90
  }
191
91
  catch (err) {
192
92
  // One unreachable/misconfigured (including permanently hanging —
@@ -51,5 +51,11 @@ export declare function toParametersSchema(inputSchema: unknown): TSchema;
51
51
  * (design.md D3). */
52
52
  export declare function toPiContent(items: McpContentItem[]): PiContent[];
53
53
  /** `${serverName}__${toolName}` — deterministic, collision-free across
54
- * servers sharing pi.registerTool()'s single flat namespace (design.md D5). */
55
- export declare function bridgedToolName(serverName: string, toolName: string): string;
54
+ * servers sharing pi.registerTool()'s single flat namespace (design.md D5).
55
+ *
56
+ * Now one implementation shared with the gateway's own aggregation rather
57
+ * than two identical ones: the naming convention is the contract between
58
+ * a prefixed name and the server it routes to, and two copies of it could
59
+ * drift into routing tools to the wrong upstream
60
+ * (trellis-mcp-gateway-hosting design.md D3). */
61
+ export { prefixedToolName as bridgedToolName } from "../lib/mcpToolRegistry.js";
@@ -34,7 +34,11 @@ export function toPiContent(items) {
34
34
  });
35
35
  }
36
36
  /** `${serverName}__${toolName}` — deterministic, collision-free across
37
- * servers sharing pi.registerTool()'s single flat namespace (design.md D5). */
38
- export function bridgedToolName(serverName, toolName) {
39
- return `${serverName}__${toolName}`;
40
- }
37
+ * servers sharing pi.registerTool()'s single flat namespace (design.md D5).
38
+ *
39
+ * Now one implementation shared with the gateway's own aggregation rather
40
+ * than two identical ones: the naming convention is the contract between
41
+ * a prefixed name and the server it routes to, and two copies of it could
42
+ * drift into routing tools to the wrong upstream
43
+ * (trellis-mcp-gateway-hosting design.md D3). */
44
+ export { prefixedToolName as bridgedToolName } from "../lib/mcpToolRegistry.js";
@@ -234,28 +234,97 @@ one header simply can't reach Codex through Trellis, and is refused
234
234
  there (not silently dropped) while still reaching every other agent.
235
235
 
236
236
  **Real OAuth** (browser redirect, short-lived access token, refresh
237
- token) is not implemented anywhere in Trellis, on purpose. All three
238
- native-config agents already have their own real, working flow for it:
239
-
240
- - Claude Code: `claude mcp add --client-id/--client-secret/--callback-port`
241
- - Codex: a dedicated `codex mcp login`/`codex mcp logout` pair
242
- - Kiro: `oauth`/`oauthScopes` fields in its own real
243
- `~/.kiro/settings/mcp.json` schema
244
-
245
- pi has none of this — its bridge is Trellis's own code, and the MCP
246
- SDK's `authProvider: OAuthClientProvider` option is a full
247
- redirect-and-refresh flow that doesn't fit a synchronously-loaded
248
- extension's lifecycle. A remote server that requires real OAuth simply
249
- isn't reachable through the pi bridge today — a real, stated limitation,
250
- not something papered over with a partial implementation.
237
+ token) was, for most of this project's life, deliberately not built:
238
+ all three native-config agents already have their own working flow —
239
+ Claude Code's `claude mcp add --client-id/--client-secret/--callback-port`,
240
+ Codex's `codex mcp login`/`logout` pair, Kiro's `oauth`/`oauthScopes`
241
+ fields — and pi, whose bridge is Trellis's own code, simply couldn't
242
+ reach such a server at all.
243
+
244
+ That changed with gateway mode (below), which made it unavoidable: in
245
+ gateway mode the agent connects to Trellis, and Trellis connects to the
246
+ remote server, so the agent's own OAuth flow is no longer on the path.
247
+ Trellis now implements the client half itself — discovery (RFC 8414 /
248
+ RFC 9728), dynamic client registration (RFC 7591), authorization code
249
+ with PKCE S256 (RFC 7636), and the refresh grant — in `src/lib/oauth/`.
250
+
251
+ Two rules shape it, both from the constraint that the gateway is spawned
252
+ silently by an agent with no terminal attached:
253
+
254
+ - **Only `trellis mcp auth <server>` ever opens a browser.** It is run by
255
+ a human, once. The gateway never initiates an interactive flow under
256
+ any circumstance; a server with no stored credential is skipped the
257
+ same way an unreachable one is.
258
+ - **Refreshing is silent, and serialized by a lock.** A `refresh_token`
259
+ grant needs neither a browser nor a callback, so the gateway does it
260
+ at connect time. The lock (`~/.trellis/mcp/oauth/<name>.lock`) exists
261
+ because most authorization servers rotate the refresh token and
262
+ invalidate its predecessor — two concurrent refreshes would leave one
263
+ party holding a credential that is already dead.
264
+
265
+ Tokens live one-file-per-server at `~/.trellis/mcp/oauth/<name>.json`,
266
+ mode 0600, never in `servers.yaml` and never in the OS keychain (macOS's
267
+ `security add-generic-password` fails with exit 152/154 in exactly the
268
+ non-interactive, no-GUI-session context the gateway runs in — measured,
269
+ not assumed).
270
+
271
+ In direct mode nothing here applies: each agent keeps using its own
272
+ native OAuth flow, exactly as before.
273
+
274
+ ## MCP gateway mode
275
+
276
+ Gateway mode collapses an agent's MCP config to one entry, like hub mode
277
+ — but what sits behind it is a local subprocess Trellis owns
278
+ (`trellis mcp-gateway --agent <id>`), not a URL someone operates. Set
279
+ `mcp.gateway.enabled` in `mcp/servers.yaml`; add `mcp.gateway.agents` to
280
+ narrow it to specific agents, or omit it to cover every managed agent.
281
+ `gateway` and `hub` are independent fields and may both be set — gateway
282
+ wins for any agent it covers.
283
+
284
+ **Lifecycle: there isn't one.** The agent spawns it like any other stdio
285
+ MCP server and it exits when the session ends. No daemon, no
286
+ start/stop/status command, nothing to monitor. Two things make that
287
+ true rather than aspirational:
288
+
289
+ - The gateway watches its own stdin for EOF and tears everything down.
290
+ The MCP SDK's `StdioServerTransport` binds only `'data'` and
291
+ `'error'`, so it never reports EOF, and POSIX re-parents orphans to
292
+ launchd rather than killing them — without explicit teardown every
293
+ session would leak a gateway plus its whole upstream set, forever.
294
+ - Upstream failures are isolated. One server that is unreachable,
295
+ misconfigured, or permanently hanging is logged and skipped; every
296
+ other server's tools are still served.
297
+
298
+ **What it does:** resolves the same in-scope server set direct mode
299
+ would have written for that agent (same `resolveMcpPlan`, so per-server
300
+ `agents:` scope, `enabled: false`, host-injected collisions,
301
+ literal-secret refusals and unresolved `env` names all behave
302
+ identically), connects each one, and exposes their tools as
303
+ `<server>__<tool>`. Secrets resolve through `resolveSecretEnv` exactly as
304
+ in direct mode — the gateway introduces no second path by which a value
305
+ could reach disk.
306
+
307
+ **What it changes for the agents:** Codex's single-`bearer_token_env_var`
308
+ limitation stops applying, because Codex no longer receives any server's
309
+ `headers` — the gateway holds them. Remote servers needing real OAuth
310
+ become reachable from every agent, including pi, for the same reason.
311
+
312
+ **The seam.** `src/commands/mcpGateway.ts` depends on a `GatewayBackend`
313
+ interface and nothing below it — never a connection, transport, or
314
+ server definition. Today the only implementation connects upstreams
315
+ in-process, one gateway per agent session. The confirmed direction is a
316
+ single shared service holding one connection set for all agents, with
317
+ the per-session process becoming a thin client forwarding over a Unix
318
+ socket, speaking MCP itself (so no second protocol to own). That is a
319
+ substitution at one construction site: the entry written into each
320
+ agent's config is identical either way, so converging later requires no
321
+ re-sync and nothing the user notices.
251
322
 
252
323
  ## What Trellis explicitly does not build
253
324
 
254
- - An MCP aggregator/gateway's actual routing/proxy logic (hub mode above
255
- lets you point every agent at one, but Trellis doesn't implement one)
256
- - Real OAuth for remote MCP servers (browser redirect, token storage,
257
- refresh) — every agent that has its own native flow keeps using it;
258
- see "Static header auth vs. real OAuth" above
325
+ - A resident MCP gateway daemon. Gateway mode above is a per-session
326
+ subprocess with no lifecycle; hub mode points at something you operate.
327
+ Neither is a service Trellis starts, supervises, or keeps running.
259
328
  - A memory backend (defaults to `@modelcontextprotocol/server-memory`,
260
329
  documented in `schema/servers.example.yaml`; mem0/OpenMemory and
261
330
  totalrecallai-class semantic-search servers documented as opt-in
@@ -17,8 +17,9 @@ $ trellis onboard
17
17
 
18
18
  Runs `init`, detects which of Claude Code/Codex/Kiro/pi are on this machine,
19
19
  then resolves two independent choices before running `migrate`, `sync`,
20
- `mcp sync`, `memory sync`, and `secrets audit` — the whole onboarding path,
21
- no follow-up commands to type by hand:
20
+ `mcp sync`, `memory sync`, `secrets audit`, and a final health scan — the
21
+ whole onboarding path, no follow-up commands to type by hand, and no need
22
+ to run `trellis doctor` separately to know whether it actually worked.
22
23
 
23
24
  1. **Migration source** — read from, at most one, never written back to.
24
25
  "Real content" means skills, custom instructions, *or* real MCP servers
@@ -65,16 +66,72 @@ memory sync
65
66
 
66
67
  secrets audit
67
68
  ✅ no findings — every present agent's real config and every declared env var passed all checks
69
+
70
+ health scan (trellis doctor)
71
+ ✅ no findings
72
+
73
+ verdict
74
+ ✅ nothing needs attention — every stage completed cleanly and verified
75
+ exit code: 0 — nothing blocked this run
68
76
  ```
69
77
 
70
78
  No agent named `claude-code` appears in the `sync`/`mcp sync` output above —
71
79
  it's present and was the migration source, but it isn't managed, so it's
72
80
  never even probed as a sync target, not just left with zero items.
73
81
 
82
+ **The `verdict` block is always the last thing printed**, including on a
83
+ run that had nothing wrong — the exit code and the last line on screen
84
+ always agree, so a conflict earlier in the run can never be hidden behind
85
+ a later stage's own unrelated success line. Every conflict from every
86
+ stage is collected there, each with a concrete next action, not just a
87
+ restatement of what went wrong:
88
+
89
+ ```
90
+ verdict
91
+ ❌ 1 blocking issue(s):
92
+ - [sync/claude-code] ~/.claude/CLAUDE.md exists and is not a Trellis-managed symlink — left untouched
93
+ → back up ~/.claude/CLAUDE.md's real content if you need it, remove the file, then re-run sync
94
+ exit code: 1 — at least one blocking issue above
95
+ ```
96
+
97
+ **A real (non-`--dry-run`) run verifies its own writes**, immediately
98
+ after making them — a dry-run re-plan of `sync`/`mcp sync` against exactly
99
+ what was just written, checked for anything still outstanding. This is
100
+ what actually answers "did this take effect," distinct from the health
101
+ scan below it: `trellis doctor`'s own checks compare agents against each
102
+ other and never read canonical, so they cannot prove a write held — only
103
+ a re-plan against canonical can. If a write somehow didn't hold (a
104
+ filesystem permission problem, a race), it surfaces in the verdict as its
105
+ own `blocked` item, separate from whatever the write's own stage reported.
106
+
107
+ **The health scan is `trellis doctor` itself**, run at the end against
108
+ every agent (not just the ones this run manages) — so a genuinely new
109
+ problem this run happened to create or reveal shows up without a separate
110
+ `trellis doctor` invocation. It never spawns a configured MCP server to
111
+ check it (`--probe-mcp` is never passed): the worst possible moment to
112
+ start reaching real external services with real credentials is a user's
113
+ first-ever run of this command. A finding about an agent this run doesn't
114
+ manage is shown, labelled as such, and doesn't fail the run — onboard
115
+ didn't touch that agent, so its drift isn't this run's problem to fix.
116
+
117
+ On a real terminal, each stage prints a `[n/6] <stage>` progress line to
118
+ stderr as it starts — `sync`/`mcp sync` spawn real processes and can take
119
+ a few seconds, and this keeps the screen from going silent while that
120
+ happens. It never appears on stdout, so `trellis onboard > report.txt`
121
+ still captures exactly the report and verdict, nothing else; it's silent
122
+ entirely under `--json` or when stdout isn't a real terminal.
123
+
74
124
  Add `--dry-run` to preview the entire chain — init/migrate/sync/mcp
75
125
  sync/memory sync, including what would be written to
76
- `~/.trellis/managed.yaml` — with zero writes anywhere (secrets audit is
77
- always read-only, with or without the flag).
126
+ `~/.trellis/managed.yaml` — with zero writes anywhere (secrets audit and
127
+ the health scan are always read-only, with or without the flag; the
128
+ write-verification step above has nothing to check on a dry run and is
129
+ skipped entirely). On a real terminal, a `--dry-run` ends by offering to
130
+ apply the plan you just read — "No" is the highlighted default, so Enter
131
+ declines; accepting re-plans against current state and applies for real,
132
+ rather than replaying the exact preview you saw (state may have changed
133
+ while you were reading it). `--json` and non-interactive runs are never
134
+ offered anything.
78
135
 
79
136
  **A managed agent's own real content still isn't overwritten.** If you
80
137
  explicitly include the source in `--manage`, sync still never overwrites
@@ -315,7 +372,8 @@ below.
315
372
  Distributes `~/.trellis/mcp/servers.yaml` to every **managed** agent's
316
373
  native MCP config — same restriction as `sync`, see above. See [`schema/servers.example.yaml`](../schema/servers.example.yaml)
317
374
  for the full documented shape — server definitions, per-agent scoping,
318
- known-host-injected collision avoidance, and hub mode.
375
+ known-host-injected collision avoidance, hub mode, and gateway mode
376
+ (below).
319
377
 
320
378
  ```
321
379
  $ trellis mcp sync
@@ -367,6 +425,63 @@ unexpanded `${SOURCE_NAME}` text to any agent with no `${VAR}` runtime
367
425
  of its own — which is exactly what broke pi's Notion connection before
368
426
  this field existed.
369
427
 
428
+ ## Gateway mode — one entry per agent instead of N
429
+
430
+ Add this to `servers.yaml` and every managed agent's config collapses to
431
+ a single MCP entry:
432
+
433
+ ```yaml
434
+ gateway:
435
+ enabled: true
436
+ ```
437
+
438
+ Then re-run `trellis mcp sync`. Instead of one native entry per server,
439
+ each agent gets one stdio entry pointing at `trellis mcp-gateway`. The
440
+ agent spawns it like any other stdio MCP server; it connects out to your
441
+ servers, and exits when the session ends. There is nothing to start,
442
+ stop, or monitor.
443
+
444
+ Your servers' tools appear to the agent as `<server>__<tool>` — the
445
+ `gitlab` server's `search` becomes `gitlab__search`.
446
+
447
+ Add `agents: [claude-code, codex]` under `gateway:` to narrow it, leaving
448
+ the rest in direct mode. Gateway mode and `hub` are independent; both can
449
+ be set, and gateway wins for any agent it covers.
450
+
451
+ Two things stop being problems in gateway mode. Codex can normally only
452
+ express a single `Authorization: Bearer ${VAR}` header, so a server
453
+ needing more than one was refused for Codex — in gateway mode Codex never
454
+ sees any server's headers, so it just works. And remote servers requiring
455
+ real OAuth become reachable from every agent, including pi.
456
+
457
+ ### `trellis mcp auth <server-name>`
458
+
459
+ For a remote (`http`/`sse`) server that uses OAuth, authorize it once:
460
+
461
+ ```
462
+ $ trellis mcp auth notion-remote
463
+ ✅ notion-remote — authorized (expires 2026-09-16T11:24:03.000Z)
464
+ credentials: ~/.trellis/mcp/oauth/notion-remote.json
465
+ ```
466
+
467
+ This opens your browser, completes the flow, and stores the result 0600
468
+ under `~/.trellis/mcp/oauth/` — never in `servers.yaml`, so canonical
469
+ stays safe to read, diff, and commit.
470
+
471
+ Run it once per server. After that the gateway refreshes the token
472
+ silently whenever it expires; it never opens a browser and never prompts,
473
+ because it is spawned by an agent with no terminal attached. Re-running
474
+ this command when the stored token is still valid does nothing (`--force`
475
+ overrides); when it has expired but is renewable, it refreshes without a
476
+ browser.
477
+
478
+ If a server has no stored credential, the gateway skips that one server
479
+ and keeps serving every other — it does not fail to start.
480
+
481
+ This command only applies to `http`/`sse` servers. stdio servers get
482
+ their credentials from `env`/`env_aliases` as described above; running
483
+ `mcp auth` on one tells you so rather than opening a pointless browser.
484
+
370
485
  ## `trellis memory sync`
371
486
 
372
487
  Ingests `~/.trellis/memories/*.md` into the actual on-disk file
package/docs/research.md CHANGED
@@ -179,6 +179,172 @@ Reverse-engineered directly from the installed binary
179
179
  genuine code, not config templating — see
180
180
  [`src/adapters/pi/`](../src/adapters/pi).
181
181
 
182
+ ## Cross-machine / cloud sharing — surveyed 2026-09-15, future direction confirmed
183
+
184
+ Trellis today is single-machine-only (`docs/architecture.md`'s "Global vs.
185
+ workspace scope" section deliberately scopes out project-local merge, but
186
+ says nothing about cross-*machine* sync — that's a separate, real gap, not
187
+ something already decided against). **Cloud/cross-machine sharing is
188
+ confirmed as a future requirement, not a maybe** — the open question is
189
+ sequencing and substrate choice, not whether to build it.
190
+
191
+ Two genuinely different problems got explored together and need to stay
192
+ separate in any design that follows:
193
+
194
+ 1. **Keeping `~/.trellis` canonical source consistent across a person's own
195
+ multiple machines** (config sync — small files, low write frequency).
196
+ 2. **Coordinating agent execution across machines / providing a shared
197
+ runtime substrate** (running-process orchestration — a different problem
198
+ with different failure modes, not a natural extension of #1).
199
+
200
+ ### `cloudflare/computer` — evaluated, not adopted
201
+
202
+ [cloudflare/computer](https://github.com/cloudflare/computer) gives an
203
+ agent a durable virtual filesystem (SQLite inside a Durable Object,
204
+ `@cloudflare/dofs`) plus a pluggable execution backend (container FUSE
205
+ mount, isolate shell via `just-bash`, or isolate JS in a Dynamic Worker) —
206
+ one `workspace.runtime.exec(source, { backend })` entrypoint. Aimed at
207
+ SaaS-scale "give every concurrent agent task its own cheap, horizontally-
208
+ scalable workspace," not at syncing a handful of config files between a
209
+ person's own laptop and desktop.
210
+
211
+ Explicitly **PREVIEW ONLY** per the project's own README: "APIs are
212
+ unstable... NOT suitable for production use at this time." That alone
213
+ rules it out as a dependency today, separate from the architectural
214
+ mismatch.
215
+
216
+ The one reusable idea, if problem #1 above is tackled with a real sync
217
+ protocol later rather than git: `@cloudflare/dofs`'s sync building blocks
218
+ (`applyChanges`/`stageBlob`/`materialiseChange`/`fetchChanges`/
219
+ `buildManifest`/`currentRev`/watermark cursors) are a clean change-set +
220
+ content-addressed-blob + watermark model worth studying as a *design
221
+ reference*, not a dependency — adopting the package itself means adopting
222
+ Cloudflare Durable Objects as a required backend, which is a vendor
223
+ commitment Trellis has never needed for anything else it does (everything
224
+ else runs as a local CLI against local files).
225
+
226
+ **Recommendation for problem #1**: start with git as the sync transport
227
+ (`~/.trellis` is already just markdown/YAML — a natural git repo; `trellis
228
+ sync` gains a `git pull --rebase`/`git push` step, conflicts resolved by
229
+ git's own mechanism) before building or adopting any bespoke sync engine.
230
+ Matches the project's own existing principle for MCP hub mode — "no engine
231
+ switch in the type, only a URL" — apply the same discipline here: don't
232
+ build sync-engine abstraction ahead of validating that git alone doesn't
233
+ already solve it.
234
+
235
+ ### Letta Cloud's "Agent state" — evaluated as a hosted-state model, not adopted
236
+
237
+ Surveyed 2026-09-16 after recognizing the shape from
238
+ [letta-ai/letta-code](https://github.com/letta-ai/letta-code): Letta Cloud
239
+ keeps an agent's memory, identity, and conversation history entirely
240
+ server-side; any machine (laptop, Cloud VM, Mac Mini, GitHub Actions) can
241
+ run the `letta server` harness against that same state
242
+ ([README](https://github.com/letta-ai/letta-code/blob/main/README.md),
243
+ [Constellation docs](https://docs.letta.com/letta-code/constellation)).
244
+ Confirmed by reading the actual mechanism, not just the marketing diagram:
245
+
246
+ - **Attach is outbound-only.** `letta server --env-name "work-laptop"`
247
+ opens one outbound WebSocket to Letta Cloud — no inbound port, ever
248
+ ([BYOM docs](https://docs.letta.com/platform/computers/byom)). OAuth
249
+ device-flow credentials persist under `~/.letta/` so reconnects are
250
+ silent.
251
+ - **"Teleportation" (moving execution between machines) is a sequenced
252
+ single-writer handoff, never concurrent.** The source machine finishes
253
+ its current tool call, stops accepting new work, and releases before
254
+ the destination starts; "a conversation can have only one teleport in
255
+ flight at a time," and a failed handoff leaves the conversation on the
256
+ source rather than in a split state
257
+ ([Teleportation docs](https://docs.letta.com/platform/computers/teleportation)).
258
+ Memory, message history, pending tool approvals, credentials, and
259
+ permission mode move with it; files, working directories, running
260
+ processes, and installed dependencies do not — the destination
261
+ reconstructs its own environment rather than receiving a filesystem
262
+ snapshot.
263
+ - **Even with a fully hosted state store, Letta's own docs still tell
264
+ users to mirror agent memory to GitHub** via `/memory-repository` as a
265
+ backup. The vendor that built the hosted-state alternative does not
266
+ treat it as a substitute for a plain, git-friendly copy.
267
+
268
+ **Not adopted as a model for Trellis's own cross-machine problem, and the
269
+ reason is structural, not a maturity/cost judgment**: Letta's "agent
270
+ state" is the runtime state of an agent Letta itself executes — its own
271
+ first-party memory and conversation loop. Trellis has no equivalent to
272
+ host: canonical (`~/.trellis`) is *configuration projected into* other
273
+ vendors' agents (Claude Code, Codex, Kiro), whose own sessions, auth, and
274
+ runtime state Trellis never touches and has no execution loop of its own
275
+ to hand off. There is no "conversation" for a Trellis-hosted service to
276
+ teleport — Trellis's job ends at writing correct config to disk on the
277
+ one machine it's invoked on. Building a hosted canonical-state service to
278
+ mirror Letta's shape would mean Trellis becoming a resident,
279
+ account-backed service — the same "material shift... needs its own
280
+ explicit sign-off" line `trellis-mcp-gateway-hosting`'s proposal.md
281
+ already drew around operating so much as a spawned subprocess, applied
282
+ here to something with a much larger blast radius (a user's actual
283
+ configuration data, not a per-session child process).
284
+
285
+ **Two mechanisms are worth recording anyway — not as new ideas to adopt,
286
+ but as independent confirmation that designs already committed to
287
+ elsewhere in this project land on the same shape a production
288
+ multi-machine system converged on:**
289
+
290
+ 1. Outbound-only, no-daemon-to-babysit attach is exactly
291
+ `trellis-mcp-gateway-hosting`'s own shape (design.md D2/D13) — an
292
+ agent spawns `trellis mcp-gateway`, it connects out, it exits with the
293
+ session. Letta using the identical pattern for "any machine attaches
294
+ to hosted state" is evidence the shape generalizes, not a coincidence
295
+ worth re-deriving from scratch if problem #2 (a shared runtime
296
+ substrate) is ever built.
297
+ 2. Sequenced single-writer handoff — finish, release, then let the next
298
+ party proceed — is exactly the OAuth refresh lock's shape
299
+ (`trellis-mcp-gateway-hosting` design.md D16, `withServerLock`:
300
+ acquire, re-read to check whether the work is already done, act, or
301
+ defer). Same problem (two parties who might touch shared state at
302
+ once), same answer (serialize, never split), arrived at independently
303
+ in a completely different subsystem.
304
+
305
+ **Standing recommendation unchanged**: git remains the right transport
306
+ for problem #1. This survey is a reason to be confident in that choice,
307
+ not a reason to revisit it — the hosted-state alternative was evaluated
308
+ on its merits and excluded on structural grounds, and even its own
309
+ vendor keeps a git-based fallback for the same category of data Trellis
310
+ already treats git-friendly YAML/Markdown as the source of truth for.
311
+
312
+ ### Sandbox / execution substrate landscape — problem #2
313
+
314
+ Surveyed for "a place agents actually run" rather than "a place config
315
+ gets copied to":
316
+
317
+ | Project | Model | License / status | Notes |
318
+ |---|---|---|---|
319
+ | [dagger/container-use](https://github.com/dagger/container-use) | MCP server; each agent gets an isolated container + its own git branch | Apache-2.0-adjacent, early/active | Works with *any* MCP-compatible agent, no vendor lock-in per its own README; `git checkout <branch>` to review any agent's work. Lightest-weight fit for Trellis's existing "MCP is the one place we write real integration code" model — could ship as a documented default MCP server entry with zero new adapter code, the same way `memory` (P6) was added |
320
+ | [awslabs/cli-agent-orchestrator](https://github.com/awslabs/cli-agent-orchestrator) (CAO) | Local `cao-server` + per-agent isolated tmux sessions; supervisor/worker delegation; Web UI, HTTP API, PTY WebSocket; K8s deployment (shared workspace, per-pod state, credential delivery) | Apache-2.0, active (AWS) | **Directly supports Kiro CLI, Claude Code, and Codex CLI** — three of Trellis's four managed agents by name. Closest existing match to "run agents more smoothly + fleet management," and the closest existing candidate for problem #2 (cross-machine coordination) via its K8s deployment story. Not yet verified against Trellis's own canonical source/scope model — CAO has its own agent-profile concept, which is a second place "what an agent is configured to do" could live, the exact fragmentation class `docs/research.md`'s mcp-router incident (§ MCP aggregation) already warns about |
321
+ | [daytonaio/daytona](https://github.com/daytonaio/daytona) | Firecracker-level sandbox infra, multi-language SDKs | Open-source repo, **but its own README states core development moved to a private codebase as of June 2026 — "no longer maintained"** | Ruled out: not an active open-source target to build on |
322
+ | [e2b-dev/E2B](https://github.com/e2b-dev/E2B), [opensandbox-group/OpenSandbox](https://github.com/opensandbox-group/OpenSandbox) | Firecracker microVM / Docker+K8s one-shot code-execution sandboxes | E2B mostly managed (weak self-host story); OpenSandbox self-hostable | Both model "spin up a sandbox per code execution," not "keep a long-lived agent runtime consistent across a person's machines" — weaker fit than container-use/CAO for Trellis's actual use case |
323
+
324
+ ### Recommended path, not yet built
325
+
326
+ 1. **Cheapest, ships now, zero new adapter code**: document `container-use`
327
+ as an optional MCP server entry in `schema/servers.example.yaml`
328
+ (same pattern P6 used for `memory`) — every managed agent gets isolated,
329
+ disposable containers per task through the exact mechanism Trellis
330
+ already has for distributing MCP config. No cross-machine claim yet,
331
+ just "agents run more safely/smoothly," which is this session's own
332
+ stated ask.
333
+ 2. **Validate cross-machine config sync with git before building anything
334
+ bespoke** (problem #1) — smallest possible experiment, no new runtime
335
+ dependency.
336
+ 3. **Evaluate CAO as the substrate for problem #2** (cross-machine
337
+ coordination) only after #1 and #2 are separately validated as real,
338
+ distinct needs — specifically check whether its agent-profile model can
339
+ be made to read Trellis's canonical source rather than becoming a
340
+ second source of truth. This is new-project-scale work, not an
341
+ incremental Trellis phase — should get its own openspec proposal with
342
+ explicit success criteria before any code is written.
343
+
344
+ None of the above is built yet. This section exists so the next design
345
+ pass starts from "what was already checked" instead of re-surveying the
346
+ same ground.
347
+
182
348
  ## Codex — three hard constraints learned by breaking them
183
349
 
184
350
  All three below came from real incidents, not upfront design, and any Codex