agent-trellis 0.6.3 → 0.8.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.
- package/README.md +97 -38
- package/dist/adapters/claude-code.js +3 -2
- package/dist/adapters/codex.js +3 -2
- package/dist/adapters/jsonMcp.d.ts +3 -1
- package/dist/adapters/jsonMcp.js +18 -4
- package/dist/adapters/kimi-code.d.ts +21 -0
- package/dist/adapters/kimi-code.js +107 -0
- package/dist/adapters/kiro.js +3 -2
- package/dist/adapters/mcpPlan.d.ts +26 -2
- package/dist/adapters/mcpPlan.js +135 -15
- package/dist/adapters/pi.js +22 -3
- package/dist/adapters/symlinkPlan.d.ts +7 -0
- package/dist/adapters/symlinkPlan.js +16 -7
- package/dist/cli.js +129 -20
- package/dist/commands/doctor.d.ts +7 -2
- package/dist/commands/doctor.js +38 -2
- package/dist/commands/init.d.ts +4 -2
- package/dist/commands/init.js +26 -13
- package/dist/commands/kimi.d.ts +16 -0
- package/dist/commands/kimi.js +64 -0
- package/dist/commands/manage.d.ts +39 -0
- package/dist/commands/manage.js +126 -0
- package/dist/commands/mcp.d.ts +22 -1
- package/dist/commands/mcp.js +65 -2
- package/dist/commands/mcpGateway.d.ts +6 -0
- package/dist/commands/mcpGateway.js +34 -14
- package/dist/commands/mcpImport.d.ts +27 -0
- package/dist/commands/mcpImport.js +336 -0
- package/dist/commands/memory.d.ts +5 -3
- package/dist/commands/memory.js +12 -8
- package/dist/commands/migrate.d.ts +37 -7
- package/dist/commands/migrate.js +253 -27
- package/dist/commands/onboard.d.ts +74 -2
- package/dist/commands/onboard.js +463 -72
- package/dist/commands/onboardVerdict.js +20 -7
- package/dist/commands/rollback.js +4 -1
- package/dist/commands/secretsAudit.d.ts +21 -12
- package/dist/commands/secretsAudit.js +39 -10
- package/dist/commands/skill.d.ts +17 -0
- package/dist/commands/skill.js +47 -2
- package/dist/commands/sync.js +2 -0
- package/dist/core/canonical.d.ts +58 -4
- package/dist/core/canonical.js +183 -11
- package/dist/core/capabilitySelection.d.ts +45 -0
- package/dist/core/capabilitySelection.js +124 -0
- package/dist/core/types.d.ts +25 -2
- package/dist/core/types.js +7 -0
- package/dist/lib/backup.d.ts +2 -0
- package/dist/lib/backup.js +4 -0
- package/dist/lib/builtinSkills.d.ts +2 -0
- package/dist/lib/builtinSkills.js +4 -0
- package/dist/lib/cliMetadata.d.ts +1 -0
- package/dist/lib/cliMetadata.js +14 -0
- package/dist/lib/gatewayBackend.d.ts +28 -0
- package/dist/lib/gatewayBackend.js +72 -1
- package/dist/lib/installAgent.d.ts +7 -5
- package/dist/lib/installAgent.js +4 -3
- package/dist/lib/mcpConnect.js +5 -1
- package/dist/lib/mcpMigrateRead.d.ts +1 -0
- package/dist/lib/mcpMigrateRead.js +3 -0
- package/dist/lib/mcpRuntime.d.ts +46 -0
- package/dist/lib/mcpRuntime.js +185 -0
- package/dist/lib/mcpStatusProvider.d.ts +10 -0
- package/dist/lib/mcpStatusProvider.js +25 -0
- package/dist/lib/mcpToolRegistry.d.ts +18 -0
- package/dist/lib/mcpToolRegistry.js +107 -14
- package/dist/lib/memoryGraph.d.ts +1 -1
- package/dist/lib/memoryGraph.js +9 -3
- package/dist/lib/memoryProvider.d.ts +58 -0
- package/dist/lib/memoryProvider.js +175 -0
- package/dist/lib/memoryStatus.d.ts +17 -0
- package/dist/lib/memoryStatus.js +88 -0
- package/dist/lib/nativeMemory.d.ts +19 -0
- package/dist/lib/nativeMemory.js +129 -0
- package/dist/lib/runtimeControlProvider.d.ts +16 -0
- package/dist/lib/runtimeControlProvider.js +168 -0
- package/dist/lib/secretEnv.d.ts +17 -0
- package/dist/lib/secretEnv.js +31 -1
- package/dist/lib/skillProvider.d.ts +13 -0
- package/dist/lib/skillProvider.js +210 -0
- package/dist/lib/taskProvider.d.ts +7 -0
- package/dist/lib/taskProvider.js +43 -0
- package/dist/lib/taskStore.d.ts +46 -0
- package/dist/lib/taskStore.js +156 -0
- package/dist/lib/terminalPicker.d.ts +23 -28
- package/dist/lib/terminalPicker.js +54 -164
- package/dist/pi-bridge/bundle.js +493 -51
- package/dist/pi-bridge/index.js +42 -21
- package/dist/pi-bridge/schemaTranslate.d.ts +4 -8
- package/dist/pi-bridge/schemaTranslate.js +4 -8
- package/dist/probes/kimi-code.d.ts +12 -0
- package/dist/probes/kimi-code.js +55 -0
- package/dist/sdk.d.ts +1 -1
- package/docs/architecture.md +224 -26
- package/docs/assets/agent-trellis-architecture-horizontal.svg +80 -0
- package/docs/assets/agent-trellis-architecture-vertical.svg +82 -0
- package/docs/assets/trellis-mark.svg +12 -0
- package/docs/getting-started.md +403 -35
- package/docs/overview.md +131 -0
- package/docs/overview.zh-CN.md +100 -0
- package/docs/research.md +59 -0
- package/docs/roadmap.md +10 -5
- package/package.json +5 -3
- package/schema/builtin-skills/trellis-runtime/SKILL.md +117 -0
- package/schema/capability-selection.example.yaml +23 -0
- package/schema/scope.example.yaml +1 -1
- package/schema/servers.example.yaml +15 -1
|
@@ -42,12 +42,25 @@ export function normalizeMigrateVerdict(plan) {
|
|
|
42
42
|
return [];
|
|
43
43
|
const items = [];
|
|
44
44
|
for (const item of plan.items) {
|
|
45
|
-
if (item.action
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
verdict
|
|
50
|
-
|
|
45
|
+
if (item.action === "conflict") {
|
|
46
|
+
const verdict = { stage: "migrate", severity: "blocked", message: item.detail };
|
|
47
|
+
if (item.remediation)
|
|
48
|
+
verdict.remediation = item.remediation;
|
|
49
|
+
items.push(verdict);
|
|
50
|
+
}
|
|
51
|
+
else if (item.action === "extract-secret") {
|
|
52
|
+
// The run succeeded — never `blocked` — but stands as a standing
|
|
53
|
+
// reminder, not a silent success: migrate never writes to the
|
|
54
|
+
// source agent's own file (design.md D2), so it still holds the
|
|
55
|
+
// real value in plaintext forever, independent of this extraction
|
|
56
|
+
// (trellis-migrate-extract-static-env-secrets design.md D8).
|
|
57
|
+
items.push({
|
|
58
|
+
stage: "migrate",
|
|
59
|
+
severity: "warning",
|
|
60
|
+
message: item.detail,
|
|
61
|
+
remediation: `the source agent's own configuration for "${item.name}" was not modified and may still hold "${item.extractVarName}"'s real value in plaintext — clean it up by hand if you want it gone from there too`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
51
64
|
}
|
|
52
65
|
return items;
|
|
53
66
|
}
|
|
@@ -103,7 +116,7 @@ export function normalizeSecretsAuditVerdict(report) {
|
|
|
103
116
|
return [];
|
|
104
117
|
return report.findings.map((finding) => {
|
|
105
118
|
const item = { stage: "secrets audit", severity: "blocked", message: `${finding.file}: ${finding.detail}` };
|
|
106
|
-
if (finding.agent !== "environment")
|
|
119
|
+
if (finding.agent !== "environment" && finding.agent !== "canonical")
|
|
107
120
|
item.agent = finding.agent;
|
|
108
121
|
return item;
|
|
109
122
|
});
|
|
@@ -134,7 +134,10 @@ async function restoreOperation(homeDir, runId, op) {
|
|
|
134
134
|
}
|
|
135
135
|
export async function applyRollbackPlan(homeDir, runId, manifest, items) {
|
|
136
136
|
const restorePaths = new Set(items.filter((i) => i.action === "restore").map((i) => i.path));
|
|
137
|
-
|
|
137
|
+
// Restore in reverse write order. A single onboarding transaction may
|
|
138
|
+
// update servers.yaml several times (mode, memory, routes); replaying
|
|
139
|
+
// restores forward would leave the file at an intermediate snapshot.
|
|
140
|
+
for (const op of [...manifest.operations].reverse()) {
|
|
138
141
|
if (!restorePaths.has(op.path))
|
|
139
142
|
continue;
|
|
140
143
|
await restoreOperation(homeDir, runId, op);
|
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `trellis secrets audit` — reads every present, MCP-capable agent's real
|
|
3
|
-
* config file
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* (trellis-
|
|
3
|
+
* config file and checks it against `CanonicalSource.secretsPolicy`: a
|
|
4
|
+
* literal-value scan against `rejectPatterns`, and a declared-env-var-name
|
|
5
|
+
* check against `allowedVars`. Also checks, agent-agnostically, whether
|
|
6
|
+
* every env var name declared across canonical `mcp.servers[*].env`
|
|
7
|
+
* actually resolves to a value via the same `resolveSecretEnv` the pi
|
|
8
|
+
* bridge uses (trellis-secrets-env-management) — authoritative for pi, a
|
|
9
|
+
* best-effort proxy for the other three (design.md D3 in that change).
|
|
10
|
+
*
|
|
11
|
+
* Also scans canonical's own `mcp/servers.yaml` for the reject-pattern
|
|
12
|
+
* literal-value check (trellis-migrate-extract-static-env-secrets design.md
|
|
13
|
+
* D11): a literal in `command`/`url`/`args`/`headers` is now accepted into
|
|
14
|
+
* canonical by `migrate` rather than refused (no natural name to extract
|
|
15
|
+
* to, or no `${VAR}` resolution proven for every consumer), so canonical
|
|
16
|
+
* is no longer guaranteed clean by construction the way it used to be —
|
|
17
|
+
* this keeps that acceptance from being silent. Never the
|
|
18
|
+
* unexpected-var-name check, which is about agent-native serialized
|
|
19
|
+
* `env`/`env_vars` syntax specifically. Read-only — never writes
|
|
20
|
+
* anything. Fails non-zero on any finding (trellis-secrets-audit-p3).
|
|
13
21
|
*/
|
|
14
22
|
import type { AgentId } from "../core/types.js";
|
|
15
23
|
export interface RunSecretsAuditOptions {
|
|
@@ -23,8 +31,9 @@ export interface RunSecretsAuditOptions {
|
|
|
23
31
|
}
|
|
24
32
|
export interface SecretsFinding {
|
|
25
33
|
/** "environment" for `missing-env-value` — that check isn't scoped to
|
|
26
|
-
* any single agent's config file
|
|
27
|
-
|
|
34
|
+
* any single agent's config file. "canonical" for a literal found in
|
|
35
|
+
* `~/.trellis/mcp/servers.yaml` itself (see module doc comment). */
|
|
36
|
+
agent: AgentId | "environment" | "canonical";
|
|
28
37
|
file: string;
|
|
29
38
|
kind: "literal-secret" | "unexpected-var-name" | "missing-env-value";
|
|
30
39
|
detail: string;
|
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `trellis secrets audit` — reads every present, MCP-capable agent's real
|
|
3
|
-
* config file
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* (trellis-
|
|
3
|
+
* config file and checks it against `CanonicalSource.secretsPolicy`: a
|
|
4
|
+
* literal-value scan against `rejectPatterns`, and a declared-env-var-name
|
|
5
|
+
* check against `allowedVars`. Also checks, agent-agnostically, whether
|
|
6
|
+
* every env var name declared across canonical `mcp.servers[*].env`
|
|
7
|
+
* actually resolves to a value via the same `resolveSecretEnv` the pi
|
|
8
|
+
* bridge uses (trellis-secrets-env-management) — authoritative for pi, a
|
|
9
|
+
* best-effort proxy for the other three (design.md D3 in that change).
|
|
10
|
+
*
|
|
11
|
+
* Also scans canonical's own `mcp/servers.yaml` for the reject-pattern
|
|
12
|
+
* literal-value check (trellis-migrate-extract-static-env-secrets design.md
|
|
13
|
+
* D11): a literal in `command`/`url`/`args`/`headers` is now accepted into
|
|
14
|
+
* canonical by `migrate` rather than refused (no natural name to extract
|
|
15
|
+
* to, or no `${VAR}` resolution proven for every consumer), so canonical
|
|
16
|
+
* is no longer guaranteed clean by construction the way it used to be —
|
|
17
|
+
* this keeps that acceptance from being silent. Never the
|
|
18
|
+
* unexpected-var-name check, which is about agent-native serialized
|
|
19
|
+
* `env`/`env_vars` syntax specifically. Read-only — never writes
|
|
20
|
+
* anything. Fails non-zero on any finding (trellis-secrets-audit-p3).
|
|
13
21
|
*/
|
|
14
22
|
import { existsSync, readFileSync } from "node:fs";
|
|
15
23
|
import { homedir } from "node:os";
|
|
@@ -57,6 +65,26 @@ function findMissingEnvValues(servers, policy) {
|
|
|
57
65
|
detail: `"${name}" is declared by a canonical MCP server's env but has no resolvable value`,
|
|
58
66
|
}));
|
|
59
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Reject-pattern-only scan of canonical's own `mcp/servers.yaml` (design.md
|
|
70
|
+
* D11) — no `unexpected-var-name` check here, that one is specifically
|
|
71
|
+
* about names extracted from an agent's own serialized `env`/`env_vars`
|
|
72
|
+
* syntax, not canonical's `env:` name list (which holding a name matching
|
|
73
|
+
* a reject pattern would be an absurd false positive, not a real finding).
|
|
74
|
+
*/
|
|
75
|
+
function auditCanonicalServersYaml(homeDir, policy) {
|
|
76
|
+
const file = join(homeDir, ".trellis", "mcp", "servers.yaml");
|
|
77
|
+
if (!existsSync(file))
|
|
78
|
+
return [];
|
|
79
|
+
const content = readFileSync(file, "utf-8");
|
|
80
|
+
const findings = [];
|
|
81
|
+
for (const pattern of policy.rejectPatterns) {
|
|
82
|
+
if (pattern.test(content)) {
|
|
83
|
+
findings.push({ agent: "canonical", file, kind: "literal-secret", detail: `matches reject pattern ${pattern}` });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return findings;
|
|
87
|
+
}
|
|
60
88
|
function auditFile(agent, file, content, policy, extractNames) {
|
|
61
89
|
const findings = [];
|
|
62
90
|
for (const pattern of policy.rejectPatterns) {
|
|
@@ -88,6 +116,7 @@ export async function collectSecretsAuditReport(opts = {}) {
|
|
|
88
116
|
findings.push(...auditFile(agent.id, file, content, canonical.secretsPolicy, agent.extractNames));
|
|
89
117
|
}
|
|
90
118
|
findings.push(...findMissingEnvValues(canonical.mcp.servers, canonical.secretsPolicy));
|
|
119
|
+
findings.push(...auditCanonicalServersYaml(homeDir, canonical.secretsPolicy));
|
|
91
120
|
return { findings };
|
|
92
121
|
}
|
|
93
122
|
export async function runSecretsAudit(opts = {}) {
|
package/dist/commands/skill.d.ts
CHANGED
|
@@ -8,6 +8,23 @@
|
|
|
8
8
|
* symlinkPlan.ts`'s pre-existing stale-symlink removal.
|
|
9
9
|
*/
|
|
10
10
|
import type { AgentId } from "../core/types.js";
|
|
11
|
+
import { type BackupSession } from "../lib/backup.js";
|
|
12
|
+
export type BuiltinSkillUpdateAction = "create" | "update" | "already-current";
|
|
13
|
+
export interface BuiltinSkillUpdatePlan {
|
|
14
|
+
name: "trellis-runtime";
|
|
15
|
+
action: BuiltinSkillUpdateAction;
|
|
16
|
+
detail: string;
|
|
17
|
+
path: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function collectBuiltinSkillUpdatePlan(homeDir?: string): BuiltinSkillUpdatePlan;
|
|
20
|
+
export declare function applyBuiltinSkillUpdatePlan(plan: BuiltinSkillUpdatePlan, backup?: BackupSession): void;
|
|
21
|
+
export declare function runSkillUpdateBuiltin(opts?: {
|
|
22
|
+
homeDir?: string;
|
|
23
|
+
json?: boolean;
|
|
24
|
+
dryRun?: boolean;
|
|
25
|
+
}): {
|
|
26
|
+
exitCode: number;
|
|
27
|
+
};
|
|
11
28
|
export interface SkillListEntry {
|
|
12
29
|
name: string;
|
|
13
30
|
scope: readonly AgentId[];
|
package/dist/commands/skill.js
CHANGED
|
@@ -7,16 +7,56 @@
|
|
|
7
7
|
* `decideDirImport` (src/lib/dirEquals.ts) and `src/adapters/
|
|
8
8
|
* symlinkPlan.ts`'s pre-existing stale-symlink removal.
|
|
9
9
|
*/
|
|
10
|
-
import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
|
10
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
+
import { dirname } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
13
15
|
import { decideDirImport } from "../lib/dirEquals.js";
|
|
14
16
|
import { findSkillFile } from "../lib/skillFile.js";
|
|
15
17
|
import { loadCanonicalSource } from "../core/canonical.js";
|
|
16
18
|
import { resolveScope } from "../core/types.js";
|
|
19
|
+
import { isBuiltinSkillName } from "../lib/builtinSkills.js";
|
|
20
|
+
import { openBackupSession } from "../lib/backup.js";
|
|
21
|
+
function builtinSkillTemplate() {
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
return readFileSync(join(here, "..", "..", "schema", "builtin-skills", "trellis-runtime", "SKILL.md"), "utf8");
|
|
24
|
+
}
|
|
25
|
+
export function collectBuiltinSkillUpdatePlan(homeDir = homedir()) {
|
|
26
|
+
const path = join(homeDir, ".trellis", "skills", "trellis-runtime", "SKILL.md");
|
|
27
|
+
if (!existsSync(path))
|
|
28
|
+
return { name: "trellis-runtime", action: "create", detail: "will install the package-owned Runtime Skill", path };
|
|
29
|
+
if (readFileSync(path, "utf8") === builtinSkillTemplate())
|
|
30
|
+
return { name: "trellis-runtime", action: "already-current", detail: "package-owned Runtime Skill is already current", path };
|
|
31
|
+
return { name: "trellis-runtime", action: "update", detail: "will refresh the package-owned Runtime Skill; existing Agent symlinks will see the update", path };
|
|
32
|
+
}
|
|
33
|
+
export function applyBuiltinSkillUpdatePlan(plan, backup) {
|
|
34
|
+
if (plan.action === "already-current")
|
|
35
|
+
return;
|
|
36
|
+
const content = builtinSkillTemplate();
|
|
37
|
+
mkdirSync(dirname(plan.path), { recursive: true });
|
|
38
|
+
if (backup)
|
|
39
|
+
backup.writeFile(plan.path, content);
|
|
40
|
+
else
|
|
41
|
+
writeFileSync(plan.path, content);
|
|
42
|
+
}
|
|
43
|
+
export function runSkillUpdateBuiltin(opts = {}) {
|
|
44
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
45
|
+
const plan = collectBuiltinSkillUpdatePlan(homeDir);
|
|
46
|
+
if (!opts.dryRun && plan.action !== "already-current") {
|
|
47
|
+
const backup = openBackupSession(homeDir, "skill-update-builtin");
|
|
48
|
+
applyBuiltinSkillUpdatePlan(plan, backup);
|
|
49
|
+
backup.finalize();
|
|
50
|
+
}
|
|
51
|
+
if (opts.json)
|
|
52
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
53
|
+
else
|
|
54
|
+
console.log(`${opts.dryRun ? "[dry run] " : ""}skill update-builtin\n [${plan.action}] ${plan.detail}`);
|
|
55
|
+
return { exitCode: 0 };
|
|
56
|
+
}
|
|
17
57
|
export function collectSkillList(homeDir = homedir()) {
|
|
18
58
|
const canonical = loadCanonicalSource(homeDir);
|
|
19
|
-
return canonical.skills.map((skill) => ({
|
|
59
|
+
return canonical.skills.filter((skill) => !isBuiltinSkillName(skill.name)).map((skill) => ({
|
|
20
60
|
name: skill.name,
|
|
21
61
|
scope: resolveScope(skill.scope, canonical.managedAgents),
|
|
22
62
|
}));
|
|
@@ -37,6 +77,9 @@ export function runSkillList(opts = {}) {
|
|
|
37
77
|
return { exitCode: 0 };
|
|
38
78
|
}
|
|
39
79
|
export function collectSkillAddPlan(name, fromPath, homeDir = homedir()) {
|
|
80
|
+
if (isBuiltinSkillName(name)) {
|
|
81
|
+
return { name, action: "conflict", detail: `"${name}" is a Trellis package-owned Skill and cannot be replaced with skill add` };
|
|
82
|
+
}
|
|
40
83
|
const skillFile = findSkillFile(fromPath);
|
|
41
84
|
if (!skillFile) {
|
|
42
85
|
return { name, action: "invalid-source", detail: `${fromPath} has no SKILL.md` };
|
|
@@ -77,6 +120,8 @@ export function runSkillAdd(name, fromPath, opts = {}) {
|
|
|
77
120
|
return { exitCode: plan.action === "conflict" || plan.action === "invalid-source" ? 1 : 0 };
|
|
78
121
|
}
|
|
79
122
|
export function collectSkillRemovePlan(name, homeDir = homedir()) {
|
|
123
|
+
if (isBuiltinSkillName(name))
|
|
124
|
+
return { name, action: "not-found" };
|
|
80
125
|
const dir = join(homeDir, ".trellis", "skills", name);
|
|
81
126
|
return { name, action: existsSync(dir) ? "removed" : "not-found" };
|
|
82
127
|
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -11,12 +11,14 @@ import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
|
|
|
11
11
|
import { CodexAdapter } from "../adapters/codex.js";
|
|
12
12
|
import { KiroAdapter } from "../adapters/kiro.js";
|
|
13
13
|
import { PiAdapter } from "../adapters/pi.js";
|
|
14
|
+
import { KimiCodeAdapter } from "../adapters/kimi-code.js";
|
|
14
15
|
import { openBackupSession } from "../lib/backup.js";
|
|
15
16
|
const ADAPTER_FACTORY = {
|
|
16
17
|
"claude-code": (homeDir) => new ClaudeCodeAdapter(homeDir),
|
|
17
18
|
codex: (homeDir) => new CodexAdapter(homeDir),
|
|
18
19
|
kiro: (homeDir) => new KiroAdapter(homeDir),
|
|
19
20
|
pi: (homeDir) => new PiAdapter(homeDir),
|
|
21
|
+
"kimi-code": (homeDir) => new KimiCodeAdapter(homeDir),
|
|
20
22
|
};
|
|
21
23
|
/** Only agents in `canonical.managedAgents` — an agent present on this
|
|
22
24
|
* machine but not managed gets no adapter at all, not a zero-item plan
|
package/dist/core/canonical.d.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* workspace scope"). See openspec/changes/trellis-sync-p1/specs/
|
|
5
5
|
* canonical-source-loading/spec.md for the exact contract this implements.
|
|
6
6
|
*/
|
|
7
|
-
import type { AgentId, CanonicalSource, McpServerDef } from "./types.js";
|
|
7
|
+
import type { AgentId, CapabilityDelivery, CanonicalSource, McpRoute, McpServerDef } from "./types.js";
|
|
8
|
+
import type { BackupSession } from "../lib/backup.js";
|
|
8
9
|
/**
|
|
9
10
|
* The on-disk shape for one server entry — `static_env`/`env_aliases`
|
|
10
11
|
* (snake_case, like every other multi-word key across `.trellis/*.yaml`)
|
|
@@ -27,10 +28,10 @@ export type ServersYamlWriteResult = {
|
|
|
27
28
|
ok: false;
|
|
28
29
|
error: string;
|
|
29
30
|
};
|
|
30
|
-
export declare function upsertServerYaml(path: string, name: string, def: McpServerDef): ServersYamlWriteResult;
|
|
31
|
+
export declare function upsertServerYaml(path: string, name: string, def: McpServerDef, backup?: BackupSession): ServersYamlWriteResult;
|
|
31
32
|
/** Inverse of `upsertServerYaml` — same preservation guarantee, same
|
|
32
33
|
* refusal posture on a missing/unparseable file. */
|
|
33
|
-
export declare function removeServerYaml(path: string, name: string): ServersYamlWriteResult;
|
|
34
|
+
export declare function removeServerYaml(path: string, name: string, backup?: BackupSession): ServersYamlWriteResult;
|
|
34
35
|
/**
|
|
35
36
|
* `trellis onboard --mcp-mode <value>` (trellis-onboard-mcp-mode) —
|
|
36
37
|
* everything needed to select or clear the top-level `hub`/`gateway`
|
|
@@ -56,7 +57,60 @@ export type McpMode = {
|
|
|
56
57
|
* starter file). Selecting one mode always clears the other two
|
|
57
58
|
* (design.md D3) — never left for the caller to remember.
|
|
58
59
|
*/
|
|
59
|
-
export declare function writeMcpModeYaml(path: string, mode: McpMode): ServersYamlWriteResult;
|
|
60
|
+
export declare function writeMcpModeYaml(path: string, mode: McpMode, backup?: BackupSession): ServersYamlWriteResult;
|
|
61
|
+
export type McpRoutesWriteResult = ServersYamlWriteResult;
|
|
62
|
+
/** Writes explicit per-agent routes while preserving legacy hub/gateway
|
|
63
|
+
* shorthand. An empty route map removes the optional key. */
|
|
64
|
+
export declare function writeMcpRoutesYaml(path: string, routes: Partial<Record<AgentId, McpRoute>>, backup?: BackupSession): McpRoutesWriteResult;
|
|
65
|
+
/** Writes per-agent capability delivery preferences into servers.yaml.
|
|
66
|
+
* Omitted entries continue to use the native default. */
|
|
67
|
+
export declare function writeMcpRuntimeDeliveryYaml(path: string, delivery: Partial<Record<AgentId, CapabilityDelivery>>, backup?: BackupSession): ServersYamlWriteResult;
|
|
68
|
+
export type SecretsPolicyWriteResult = {
|
|
69
|
+
ok: true;
|
|
70
|
+
} | {
|
|
71
|
+
ok: false;
|
|
72
|
+
error: string;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* `trellis migrate`'s static-env secret extraction
|
|
76
|
+
* (trellis-migrate-extract-static-env-secrets design.md D6) — the
|
|
77
|
+
* missing writer for `secrets.policy.yaml`, mirroring `writeMcpModeYaml`'s
|
|
78
|
+
* `Document`-based, comment-preserving mechanism. Sets `env_file` only
|
|
79
|
+
* when it is not already set — an already-configured value is a
|
|
80
|
+
* deliberate prior choice (design.md D3) and `resolveSecretEnv` treats
|
|
81
|
+
* `env_file` as the sole source once set, so silently repointing it
|
|
82
|
+
* would orphan every name already resolving from the old file. Appends
|
|
83
|
+
* `varName` to `allowed_vars` only if not already present (dedup, same
|
|
84
|
+
* "already there is a no-op" rule every other Trellis writer follows).
|
|
85
|
+
*/
|
|
86
|
+
export declare function writeSecretsPolicyExtraction(path: string, extraction: {
|
|
87
|
+
varName: string;
|
|
88
|
+
envFilePath: string;
|
|
89
|
+
}, backup?: BackupSession): SecretsPolicyWriteResult;
|
|
90
|
+
/**
|
|
91
|
+
* Ensures one exact line is present in a `.gitignore` file, creating the
|
|
92
|
+
* file if it doesn't exist yet — idempotent (a no-op if the line is
|
|
93
|
+
* already there), never disturbing any other line
|
|
94
|
+
* (trellis-migrate-extract-static-env-secrets design.md D5). Called
|
|
95
|
+
* lazily, immediately before the first real write to the local secrets
|
|
96
|
+
* file migrate's static-env extraction produces — never from `trellis
|
|
97
|
+
* init`'s own bootstrap, so a machine that never extracts a secret never
|
|
98
|
+
* gains this file at all.
|
|
99
|
+
*/
|
|
100
|
+
export declare function ensureGitignoreEntry(gitignorePath: string, line: string, backup?: BackupSession): void;
|
|
101
|
+
/**
|
|
102
|
+
* Ensures a shell rc file sources the local secrets env file on every new
|
|
103
|
+
* shell — one generic pointer block, appended once, idempotent (detected
|
|
104
|
+
* by its own marker comment, never duplicated). Deliberately never writes
|
|
105
|
+
* a literal secret value into the rc file: the block only names the path
|
|
106
|
+
* to the real `NAME=value` file migrate's extraction already writes and
|
|
107
|
+
* protects — adding a new variable later means editing that one file, the
|
|
108
|
+
* rc file never needs a second edit (trellis-migrate-extract-static-env-secrets
|
|
109
|
+
* design.md D10). `set -a`/`set +a` auto-exports the plain dotenv-format
|
|
110
|
+
* lines `parseDotenv` already expects, so that format never needs an
|
|
111
|
+
* `export` prefix of its own.
|
|
112
|
+
*/
|
|
113
|
+
export declare function ensureShellEnvSource(rcPath: string, envFilePath: string, backup?: BackupSession): void;
|
|
60
114
|
/**
|
|
61
115
|
* `homeDir` defaults to the real `~` and is only ever overridden for tests
|
|
62
116
|
* and `scripts/sandbox.sh` — the same seam P0's probes use
|
package/dist/core/canonical.js
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* workspace scope"). See openspec/changes/trellis-sync-p1/specs/
|
|
5
5
|
* canonical-source-loading/spec.md for the exact contract this implements.
|
|
6
6
|
*/
|
|
7
|
-
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import { basename, join } from "node:path";
|
|
10
|
-
import { isMap, parse as parseYaml, parseDocument } from "yaml";
|
|
9
|
+
import { basename, dirname, join } from "node:path";
|
|
10
|
+
import { isMap, isSeq, parse as parseYaml, parseDocument } from "yaml";
|
|
11
11
|
import { ALL_AGENTS } from "./types.js";
|
|
12
12
|
function fromServerDefYaml(def) {
|
|
13
13
|
const { static_env, env_aliases, ...rest } = def;
|
|
@@ -89,8 +89,39 @@ function loadServersYaml(path) {
|
|
|
89
89
|
knownHostInjected: parsed.known_host_injected ?? [],
|
|
90
90
|
hub: parsed.hub,
|
|
91
91
|
gateway: fromGatewayYaml(parsed.gateway),
|
|
92
|
+
routes: fromRoutesYaml(parsed.routes),
|
|
93
|
+
runtime: fromRuntimeYaml(parsed.runtime),
|
|
92
94
|
};
|
|
93
95
|
}
|
|
96
|
+
function fromRuntimeYaml(runtime) {
|
|
97
|
+
if (!runtime?.delivery)
|
|
98
|
+
return undefined;
|
|
99
|
+
const delivery = {};
|
|
100
|
+
for (const [rawAgent, rawDelivery] of Object.entries(runtime.delivery)) {
|
|
101
|
+
if (!ALL_AGENTS.includes(rawAgent))
|
|
102
|
+
continue;
|
|
103
|
+
if (rawDelivery !== "native" && rawDelivery !== "mcp" && rawDelivery !== "both")
|
|
104
|
+
continue;
|
|
105
|
+
delivery[rawAgent] = rawDelivery;
|
|
106
|
+
}
|
|
107
|
+
return Object.keys(delivery).length > 0 ? { delivery } : undefined;
|
|
108
|
+
}
|
|
109
|
+
function fromRoutesYaml(routes) {
|
|
110
|
+
if (!routes)
|
|
111
|
+
return undefined;
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const [rawAgent, rawRoute] of Object.entries(routes)) {
|
|
114
|
+
if (!ALL_AGENTS.includes(rawAgent))
|
|
115
|
+
continue;
|
|
116
|
+
if (!rawRoute?.mode || !["direct", "gateway", "hub"].includes(rawRoute.mode))
|
|
117
|
+
continue;
|
|
118
|
+
out[rawAgent] = {
|
|
119
|
+
mode: rawRoute.mode,
|
|
120
|
+
...(rawRoute.servers ? { servers: rawRoute.servers } : {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
124
|
+
}
|
|
94
125
|
/**
|
|
95
126
|
* A `gateway:` block with no `enabled` key reads as off, not as on — the
|
|
96
127
|
* only safe default for a field whose whole effect is to stop writing the
|
|
@@ -126,12 +157,26 @@ function fromGatewayYaml(gateway) {
|
|
|
126
157
|
* one-line, local fix — it never touches any sibling entry's own style,
|
|
127
158
|
* so a file someone deliberately kept flow-style elsewhere is untouched.
|
|
128
159
|
*/
|
|
160
|
+
/** Handles both a map (`servers: {}`) and a sequence (`allowed_vars: []`)
|
|
161
|
+
* — `trellis init`'s starter files use flow style for both kinds of
|
|
162
|
+
* still-empty collection, and inserting into either one via `Document`
|
|
163
|
+
* methods keeps rendering it as flow otherwise (same real bug this
|
|
164
|
+
* function was first written to fix, just a second collection type
|
|
165
|
+
* hitting it — trellis-migrate-extract-static-env-secrets). */
|
|
129
166
|
function forceBlockStyle(node) {
|
|
130
|
-
if (isMap(node)) {
|
|
167
|
+
if (isMap(node) || isSeq(node)) {
|
|
131
168
|
node.flow = false;
|
|
132
169
|
}
|
|
133
170
|
}
|
|
134
|
-
|
|
171
|
+
function persistText(path, content, backup) {
|
|
172
|
+
if (existsSync(path) && readFileSync(path, "utf-8") === content)
|
|
173
|
+
return;
|
|
174
|
+
if (backup)
|
|
175
|
+
backup.writeFile(path, content);
|
|
176
|
+
else
|
|
177
|
+
writeFileSync(path, content);
|
|
178
|
+
}
|
|
179
|
+
export function upsertServerYaml(path, name, def, backup) {
|
|
135
180
|
if (!existsSync(path)) {
|
|
136
181
|
return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
|
|
137
182
|
}
|
|
@@ -145,12 +190,12 @@ export function upsertServerYaml(path, name, def) {
|
|
|
145
190
|
doc.setIn(["servers", name], toServerDefYaml(def));
|
|
146
191
|
forceBlockStyle(doc.get("servers", true));
|
|
147
192
|
forceBlockStyle(doc.getIn(["servers", name], true));
|
|
148
|
-
|
|
193
|
+
persistText(path, doc.toString(), backup);
|
|
149
194
|
return { ok: true };
|
|
150
195
|
}
|
|
151
196
|
/** Inverse of `upsertServerYaml` — same preservation guarantee, same
|
|
152
197
|
* refusal posture on a missing/unparseable file. */
|
|
153
|
-
export function removeServerYaml(path, name) {
|
|
198
|
+
export function removeServerYaml(path, name, backup) {
|
|
154
199
|
if (!existsSync(path)) {
|
|
155
200
|
return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
|
|
156
201
|
}
|
|
@@ -162,7 +207,7 @@ export function removeServerYaml(path, name) {
|
|
|
162
207
|
return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
163
208
|
}
|
|
164
209
|
doc.deleteIn(["servers", name]);
|
|
165
|
-
|
|
210
|
+
persistText(path, doc.toString(), backup);
|
|
166
211
|
return { ok: true };
|
|
167
212
|
}
|
|
168
213
|
/**
|
|
@@ -175,7 +220,7 @@ export function removeServerYaml(path, name) {
|
|
|
175
220
|
* starter file). Selecting one mode always clears the other two
|
|
176
221
|
* (design.md D3) — never left for the caller to remember.
|
|
177
222
|
*/
|
|
178
|
-
export function writeMcpModeYaml(path, mode) {
|
|
223
|
+
export function writeMcpModeYaml(path, mode, backup) {
|
|
179
224
|
if (!existsSync(path)) {
|
|
180
225
|
return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
|
|
181
226
|
}
|
|
@@ -208,7 +253,56 @@ export function writeMcpModeYaml(path, mode) {
|
|
|
208
253
|
forceBlockStyle(doc.get("gateway", true));
|
|
209
254
|
break;
|
|
210
255
|
}
|
|
211
|
-
|
|
256
|
+
persistText(path, doc.toString(), backup);
|
|
257
|
+
return { ok: true };
|
|
258
|
+
}
|
|
259
|
+
/** Writes explicit per-agent routes while preserving legacy hub/gateway
|
|
260
|
+
* shorthand. An empty route map removes the optional key. */
|
|
261
|
+
export function writeMcpRoutesYaml(path, routes, backup) {
|
|
262
|
+
if (!existsSync(path)) {
|
|
263
|
+
return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
|
|
264
|
+
}
|
|
265
|
+
let doc;
|
|
266
|
+
try {
|
|
267
|
+
doc = parseDocument(readFileSync(path, "utf-8"));
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
271
|
+
}
|
|
272
|
+
const serialized = Object.fromEntries(Object.entries(routes).map(([agent, route]) => [agent, { mode: route.mode, ...(route.servers ? { servers: route.servers } : {}) }]));
|
|
273
|
+
if (Object.keys(serialized).length === 0) {
|
|
274
|
+
doc.delete("routes");
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
doc.set("routes", serialized);
|
|
278
|
+
forceBlockStyle(doc.get("routes", true));
|
|
279
|
+
}
|
|
280
|
+
persistText(path, doc.toString(), backup);
|
|
281
|
+
return { ok: true };
|
|
282
|
+
}
|
|
283
|
+
/** Writes per-agent capability delivery preferences into servers.yaml.
|
|
284
|
+
* Omitted entries continue to use the native default. */
|
|
285
|
+
export function writeMcpRuntimeDeliveryYaml(path, delivery, backup) {
|
|
286
|
+
if (!existsSync(path)) {
|
|
287
|
+
return { ok: false, error: path + " does not exist — run trellis init first" };
|
|
288
|
+
}
|
|
289
|
+
let doc;
|
|
290
|
+
try {
|
|
291
|
+
doc = parseDocument(readFileSync(path, "utf-8"));
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
295
|
+
}
|
|
296
|
+
const serialized = Object.fromEntries(Object.entries(delivery).map(([agent, mode]) => [agent, mode]));
|
|
297
|
+
if (Object.keys(serialized).length === 0) {
|
|
298
|
+
doc.delete("runtime");
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
doc.setIn(["runtime", "delivery"], serialized);
|
|
302
|
+
forceBlockStyle(doc.get("runtime", true));
|
|
303
|
+
forceBlockStyle(doc.getIn(["runtime", "delivery"], true));
|
|
304
|
+
}
|
|
305
|
+
persistText(path, doc.toString(), backup);
|
|
212
306
|
return { ok: true };
|
|
213
307
|
}
|
|
214
308
|
function loadSecretsPolicyYaml(path, homeDir) {
|
|
@@ -222,8 +316,86 @@ function loadSecretsPolicyYaml(path, homeDir) {
|
|
|
222
316
|
envFile: parsed.env_file ? parsed.env_file.replace(/^~(?=$|\/)/, homeDir) : undefined,
|
|
223
317
|
};
|
|
224
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* `trellis migrate`'s static-env secret extraction
|
|
321
|
+
* (trellis-migrate-extract-static-env-secrets design.md D6) — the
|
|
322
|
+
* missing writer for `secrets.policy.yaml`, mirroring `writeMcpModeYaml`'s
|
|
323
|
+
* `Document`-based, comment-preserving mechanism. Sets `env_file` only
|
|
324
|
+
* when it is not already set — an already-configured value is a
|
|
325
|
+
* deliberate prior choice (design.md D3) and `resolveSecretEnv` treats
|
|
326
|
+
* `env_file` as the sole source once set, so silently repointing it
|
|
327
|
+
* would orphan every name already resolving from the old file. Appends
|
|
328
|
+
* `varName` to `allowed_vars` only if not already present (dedup, same
|
|
329
|
+
* "already there is a no-op" rule every other Trellis writer follows).
|
|
330
|
+
*/
|
|
331
|
+
export function writeSecretsPolicyExtraction(path, extraction, backup) {
|
|
332
|
+
if (!existsSync(path)) {
|
|
333
|
+
return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
|
|
334
|
+
}
|
|
335
|
+
let doc;
|
|
336
|
+
try {
|
|
337
|
+
doc = parseDocument(readFileSync(path, "utf-8"));
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
341
|
+
}
|
|
342
|
+
if (doc.get("env_file") === undefined) {
|
|
343
|
+
doc.set("env_file", extraction.envFilePath);
|
|
344
|
+
}
|
|
345
|
+
// `.get()` returns the raw Seq node for a collection, not a plain
|
|
346
|
+
// array — `.toJS()` on the whole document is the reliable way to read
|
|
347
|
+
// a fully-unwrapped value back out before deciding whether to append.
|
|
348
|
+
const currentAllowedVars = (doc.toJS().allowed_vars ?? []);
|
|
349
|
+
if (!currentAllowedVars.includes(extraction.varName)) {
|
|
350
|
+
doc.set("allowed_vars", [...currentAllowedVars, extraction.varName]);
|
|
351
|
+
forceBlockStyle(doc.get("allowed_vars", true));
|
|
352
|
+
}
|
|
353
|
+
persistText(path, doc.toString(), backup);
|
|
354
|
+
return { ok: true };
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Ensures one exact line is present in a `.gitignore` file, creating the
|
|
358
|
+
* file if it doesn't exist yet — idempotent (a no-op if the line is
|
|
359
|
+
* already there), never disturbing any other line
|
|
360
|
+
* (trellis-migrate-extract-static-env-secrets design.md D5). Called
|
|
361
|
+
* lazily, immediately before the first real write to the local secrets
|
|
362
|
+
* file migrate's static-env extraction produces — never from `trellis
|
|
363
|
+
* init`'s own bootstrap, so a machine that never extracts a secret never
|
|
364
|
+
* gains this file at all.
|
|
365
|
+
*/
|
|
366
|
+
export function ensureGitignoreEntry(gitignorePath, line, backup) {
|
|
367
|
+
const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
|
|
368
|
+
const lines = existing.split("\n").map((l) => l.trim());
|
|
369
|
+
if (lines.includes(line))
|
|
370
|
+
return;
|
|
371
|
+
const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
372
|
+
persistText(gitignorePath, `${existing}${separator}${line}\n`, backup);
|
|
373
|
+
}
|
|
374
|
+
const SHELL_ENV_SOURCE_MARKER = "# >>> trellis mcp secrets >>>";
|
|
375
|
+
const SHELL_ENV_SOURCE_END_MARKER = "# <<< trellis mcp secrets <<<";
|
|
376
|
+
/**
|
|
377
|
+
* Ensures a shell rc file sources the local secrets env file on every new
|
|
378
|
+
* shell — one generic pointer block, appended once, idempotent (detected
|
|
379
|
+
* by its own marker comment, never duplicated). Deliberately never writes
|
|
380
|
+
* a literal secret value into the rc file: the block only names the path
|
|
381
|
+
* to the real `NAME=value` file migrate's extraction already writes and
|
|
382
|
+
* protects — adding a new variable later means editing that one file, the
|
|
383
|
+
* rc file never needs a second edit (trellis-migrate-extract-static-env-secrets
|
|
384
|
+
* design.md D10). `set -a`/`set +a` auto-exports the plain dotenv-format
|
|
385
|
+
* lines `parseDotenv` already expects, so that format never needs an
|
|
386
|
+
* `export` prefix of its own.
|
|
387
|
+
*/
|
|
388
|
+
export function ensureShellEnvSource(rcPath, envFilePath, backup) {
|
|
389
|
+
const existing = existsSync(rcPath) ? readFileSync(rcPath, "utf-8") : "";
|
|
390
|
+
if (existing.includes(SHELL_ENV_SOURCE_MARKER))
|
|
391
|
+
return;
|
|
392
|
+
const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
393
|
+
const block = `${SHELL_ENV_SOURCE_MARKER}\nif [ -f "${envFilePath}" ]; then\n set -a\n source "${envFilePath}"\n set +a\nfi\n${SHELL_ENV_SOURCE_END_MARKER}\n`;
|
|
394
|
+
mkdirSync(dirname(rcPath), { recursive: true });
|
|
395
|
+
persistText(rcPath, `${existing}${separator}${block}`, backup);
|
|
396
|
+
}
|
|
225
397
|
/** `undefined` if the name has no entry in scope.yaml's map — "shared with
|
|
226
|
-
* all
|
|
398
|
+
* all managed agents by default. A recognized but empty list is left as
|
|
227
399
|
* authored (an explicitly agent-less scope), not coerced to "all". */
|
|
228
400
|
/** Missing file and `agents: []` both resolve to `[]` — zero managed
|
|
229
401
|
* agents (D1), never "everyone." An unrecognized id is dropped with a
|