@edspencer/paddock 0.62.0 → 0.64.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 (91) hide show
  1. package/README.md +32 -4
  2. package/package.json +2 -2
  3. package/packages/server/dist/adoptable.d.ts +18 -0
  4. package/packages/server/dist/adoptable.js +1 -1
  5. package/packages/server/dist/app.js +17 -2
  6. package/packages/server/dist/claude-home.js +9 -1
  7. package/packages/server/dist/claude-mcp.d.ts +16 -4
  8. package/packages/server/dist/claude-mcp.js +83 -61
  9. package/packages/server/dist/claude-plugins.d.ts +131 -0
  10. package/packages/server/dist/claude-plugins.js +394 -0
  11. package/packages/server/dist/cli/args.d.ts +1 -1
  12. package/packages/server/dist/cli/args.js +9 -0
  13. package/packages/server/dist/cli/import-chats.js +1 -1
  14. package/packages/server/dist/git.d.ts +44 -1
  15. package/packages/server/dist/git.js +79 -11
  16. package/packages/server/dist/herdctl-agent-config.d.ts +7 -1
  17. package/packages/server/dist/herdctl-agent-config.js +20 -4
  18. package/packages/server/dist/herdctl.d.ts +7 -1
  19. package/packages/server/dist/herdctl.js +9 -2
  20. package/packages/server/dist/management-ops.js +2 -1
  21. package/packages/server/dist/mcp-servers.d.ts +31 -12
  22. package/packages/server/dist/mcp-servers.js +127 -33
  23. package/packages/server/dist/project-paths.d.ts +72 -4
  24. package/packages/server/dist/project-paths.js +76 -4
  25. package/packages/server/dist/project-types.d.ts +109 -11
  26. package/packages/server/dist/projects.d.ts +161 -3
  27. package/packages/server/dist/projects.js +466 -64
  28. package/packages/server/dist/routes/git.js +6 -3
  29. package/packages/server/dist/routes/projects.js +16 -2
  30. package/packages/server/dist/self-mcp-types.d.ts +6 -4
  31. package/packages/server/dist/sweep.js +10 -5
  32. package/packages/server/package.json +1 -1
  33. package/packages/web/dist/assets/{InstanceConfigPage-Ca905oey.js → InstanceConfigPage-Blr1a-hx.js} +1 -1
  34. package/packages/web/dist/assets/{MarkdownRenderer-ChlC1ezH.js → MarkdownRenderer-R6YzfI9Y.js} +1 -1
  35. package/packages/web/dist/assets/{ProjectRedirect-CslqbhUb.js → ProjectRedirect-Cy-7Thc-.js} +1 -1
  36. package/packages/web/dist/assets/{ProjectView-DkNdilGo.js → ProjectView-bMX4GEau.js} +9 -9
  37. package/packages/web/dist/assets/{ProjectsGrid-BrlvLc-Y.js → ProjectsGrid-C9XHf4eS.js} +1 -1
  38. package/packages/web/dist/assets/{arc-CGqCI5nQ.js → arc-B_42F7Yk.js} +1 -1
  39. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Cp4KTxE2.js → architectureDiagram-3BPJPVTR-BAXzSOyw.js} +1 -1
  40. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-yFxaTv04.js → blockDiagram-GPEHLZMM-BdYEdA-3.js} +1 -1
  41. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-59lhWjRt.js → c4Diagram-AAUBKEIU-Cc4o15R5.js} +1 -1
  42. package/packages/web/dist/assets/channel-BZnRb_8C.js +2 -0
  43. package/packages/web/dist/assets/{chunk-2J33WTMH-BrpXGnJg.js → chunk-2J33WTMH-CeuxHjIm.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-4BX2VUAB-UxBDb5pi.js → chunk-4BX2VUAB-DnZfrAaV.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-55IACEB6-D4ZJyUHW.js → chunk-55IACEB6-DFE6UYOT.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-727SXJPM-DWdPj_8E.js → chunk-727SXJPM-YVHQ5cHc.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BIpNbflY.js → chunk-AQP2D5EJ-5BYSbg-F.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-FMBD7UC4-f6iJDfdI.js → chunk-FMBD7UC4-HBcelndR.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-ND2GUHAM-Bz3TKIJk.js → chunk-ND2GUHAM-2AwEsdxJ.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-QZHKN3VN-buQmkafK.js → chunk-QZHKN3VN-BPpaZ6ct.js} +1 -1
  51. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BGCpL0-K.js +2 -0
  52. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BGCpL0-K.js +2 -0
  53. package/packages/web/dist/assets/{core-TyFI6p0n.js → core-Cu_4vw1U.js} +1 -1
  54. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CIkdi0XF.js → cose-bilkent-S5V4N54A-DWkF0NrF.js} +1 -1
  55. package/packages/web/dist/assets/{dagre-BM42HDAG-BnLJCfNs.js → dagre-BM42HDAG-DTgNmO9I.js} +1 -1
  56. package/packages/web/dist/assets/{diagram-2AECGRRQ-D0OqfDB9.js → diagram-2AECGRRQ-DC28b7YS.js} +1 -1
  57. package/packages/web/dist/assets/{diagram-5GNKFQAL-DQ2spGrK.js → diagram-5GNKFQAL-_WvuLno6.js} +1 -1
  58. package/packages/web/dist/assets/{diagram-KO2AKTUF-RDIVQ_S3.js → diagram-KO2AKTUF-BEYxPfWq.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-LMA3HP47-DKoTfh2C.js → diagram-LMA3HP47-Biyev7x0.js} +1 -1
  60. package/packages/web/dist/assets/{diagram-OG6HWLK6-6k0OuSAL.js → diagram-OG6HWLK6-u47NLW3S.js} +1 -1
  61. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-B-oQaYAC.js → erDiagram-TEJ5UH35-Bud2ISlK.js} +1 -1
  62. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-B7qpYe9C.js → flowDiagram-I6XJVG4X-D-18vLQ1.js} +1 -1
  63. package/packages/web/dist/assets/{format-PHdEBNOi.js → format-CQ2BDlby.js} +1 -1
  64. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-SfBsqMmx.js → ganttDiagram-6RSMTGT7-CzyRX39N.js} +1 -1
  65. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CuM4Rl_i.js → gitGraphDiagram-PVQCEYII-DLogfrjg.js} +1 -1
  66. package/packages/web/dist/assets/{index-D_1ogxg1.js → index-B0gJ0qWe.js} +14 -14
  67. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-Dq-32Oj4.js → infoDiagram-5YYISTIA-OgNPt6RG.js} +1 -1
  68. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-BLFHCj_o.js → ishikawaDiagram-YF4QCWOH-CrM0t0_X.js} +1 -1
  69. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-CHP-Yu63.js → journeyDiagram-JHISSGLW-xbxfHfwR.js} +1 -1
  70. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-DRuehSqn.js → kanban-definition-UN3LZRKU-DpszBaZb.js} +1 -1
  71. package/packages/web/dist/assets/{linear-Bwz2vnH7.js → linear-BLujdbjQ.js} +1 -1
  72. package/packages/web/dist/assets/{mermaid.core-CeMMnCip.js → mermaid.core-DzEhy9Qq.js} +4 -4
  73. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CEdqeoRW.js → mindmap-definition-RKZ34NQL-DaH3Lmoo.js} +1 -1
  74. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BdDXBx87.js → pieDiagram-4H26LBE5-BxeW1kuf.js} +1 -1
  75. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bovd1Zhx.js → quadrantDiagram-W4KKPZXB-BKs2CT0r.js} +1 -1
  76. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DiyrKnJ2.js → requirementDiagram-4Y6WPE33-CVdO-hXD.js} +1 -1
  77. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-BuX7TRVw.js → sankeyDiagram-5OEKKPKP-yusj_kJC.js} +1 -1
  78. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-BjetZWJo.js → sequenceDiagram-3UESZ5HK-C0h-9c8Y.js} +1 -1
  79. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-4AUBV6dF.js → stateDiagram-AJRCARHV-BSBY8Psb.js} +1 -1
  80. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-4DGE0Z1g.js +2 -0
  81. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BpQ8g8Bm.js → timeline-definition-PNZ67QCA-DEFUdNb8.js} +1 -1
  82. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Cj99BWER.js → vennDiagram-CIIHVFJN-DunhMqxK.js} +1 -1
  83. package/packages/web/dist/assets/{wardley-L42UT6IY-S7AGTOWw.js → wardley-L42UT6IY-CXJsBGKl.js} +1 -1
  84. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-LNXkUA7S.js → wardleyDiagram-YWT4CUSO-DfT_-dqX.js} +1 -1
  85. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-DGdTh4Xk.js → xychartDiagram-2RQKCTM6-BysTtULW.js} +1 -1
  86. package/packages/web/dist/index.html +1 -1
  87. package/packages/web/dist/sw.js +1 -1
  88. package/packages/web/dist/assets/channel-CZs6Z1i5.js +0 -2
  89. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CrbEUUCd.js +0 -2
  90. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CrbEUUCd.js +0 -2
  91. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Cq84MOtb.js +0 -2
@@ -1,6 +1,7 @@
1
1
  import type { PaddockConfig } from "./config.js";
2
2
  import type { Project } from "./projects.js";
3
3
  import { type McpSources } from "./claude-mcp.js";
4
+ import { type HostPluginSource } from "./claude-plugins.js";
4
5
  import { type PaddockTrigger } from "./trigger-config.js";
5
6
  /**
6
7
  * A project's keeper agent config. Inherits the fleet `defaults` (runtime,
@@ -34,8 +35,13 @@ import { type PaddockTrigger } from "./trigger-config.js";
34
35
  * which is both the isolated mode and what every existing caller means. The
35
36
  * KEEPER is the only agent that gets them: the sweeper is tool-less and each
36
37
  * trigger declares its own narrow allowlist, so neither could call one anyway.
38
+ *
39
+ * `hostPlugins` is the same story for the host's Claude Code plugins (#700),
40
+ * resolved once at boot by `claude-plugins.ts` and gated by `claude.instructions`
41
+ * rather than `claude.mcpServers` — see that module for why. Keeper-only for the
42
+ * same reasons, and empty unless the lever is on.
37
43
  */
38
- export declare function buildAgentConfig(cfg: PaddockConfig, project: Project, modelOverride?: string, mcpSources?: McpSources): Record<string, unknown> & {
44
+ export declare function buildAgentConfig(cfg: PaddockConfig, project: Project, modelOverride?: string, mcpSources?: McpSources, hostPlugins?: HostPluginSource): Record<string, unknown> & {
39
45
  name: string;
40
46
  };
41
47
  /**
@@ -14,6 +14,7 @@ import { promises as fs } from "node:fs";
14
14
  import path from "node:path";
15
15
  import YAML from "yaml";
16
16
  import { EMPTY_MCP_SOURCES, mcpServersFor, mcpToolPattern, } from "./claude-mcp.js";
17
+ import { EMPTY_HOST_PLUGINS } from "./claude-plugins.js";
17
18
  import { DEFAULT_MODEL, SWEEPER_DEFAULT_MODEL, DEFAULT_PERMISSION_MODE, DEFAULT_MAX_TURNS, } from "./models.js";
18
19
  import { triggerToAgentToolConfig, triggersToHerdctlSchedules, curatorTriggerOf, } from "./trigger-config.js";
19
20
  import { keeperAgentName, sweeperAgentName, triggerAgentName, browserMcpServers, FLEET_ALLOWED_TOOLS, DENIED_TOOLS, KEEPER_MAX_CONCURRENT, KEEPER_SESSION_TIMEOUT, } from "./herdctl-agent-names.js";
@@ -49,8 +50,13 @@ import { keeperAgentName, sweeperAgentName, triggerAgentName, browserMcpServers,
49
50
  * which is both the isolated mode and what every existing caller means. The
50
51
  * KEEPER is the only agent that gets them: the sweeper is tool-less and each
51
52
  * trigger declares its own narrow allowlist, so neither could call one anyway.
53
+ *
54
+ * `hostPlugins` is the same story for the host's Claude Code plugins (#700),
55
+ * resolved once at boot by `claude-plugins.ts` and gated by `claude.instructions`
56
+ * rather than `claude.mcpServers` — see that module for why. Keeper-only for the
57
+ * same reasons, and empty unless the lever is on.
52
58
  */
53
- export function buildAgentConfig(cfg, project, modelOverride, mcpSources = EMPTY_MCP_SOURCES) {
59
+ export function buildAgentConfig(cfg, project, modelOverride, mcpSources = EMPTY_MCP_SOURCES, hostPlugins = EMPTY_HOST_PLUGINS) {
54
60
  const config = {
55
61
  name: keeperAgentName(project.slug),
56
62
  description: project.summary || `Claude Code agent for project ${project.name}.`,
@@ -142,9 +148,19 @@ export function buildAgentConfig(cfg, project, modelOverride, mcpSources = EMPTY
142
148
  // Patterns already on the defaults are filtered out, so nothing is restated
143
149
  // for an instance with no external servers (or one whose only server paddock's
144
150
  // own overrode above) — that config stays byte-identical to before this lever.
145
- const extra = Object.keys(external)
146
- .map(mcpToolPattern)
147
- .filter((pattern) => !FLEET_ALLOWED_TOOLS.includes(pattern));
151
+ //
152
+ // The host's Claude Code PLUGINS ride on the same two mechanisms (#700, and the
153
+ // `plugins` passthrough herdctl 5.32.0 added for it). They are a separate key
154
+ // rather than more `mcp_servers` because a plugin is a directory the engine
155
+ // loads, not a server paddock can describe — but their servers land in the same
156
+ // tool namespace and need the same allowlist widening, under names paddock has
157
+ // to derive rather than read (`claude-plugins.ts`).
158
+ if (hostPlugins.plugins.length > 0)
159
+ config.plugins = hostPlugins.plugins;
160
+ const extra = [
161
+ ...Object.keys(external).map(mcpToolPattern),
162
+ ...hostPlugins.toolPatterns,
163
+ ].filter((pattern) => !FLEET_ALLOWED_TOOLS.includes(pattern));
148
164
  if (extra.length > 0)
149
165
  config.allowed_tools = [...FLEET_ALLOWED_TOOLS, ...extra];
150
166
  return config;
@@ -51,6 +51,7 @@ import type { PaddockConfig } from "./config.js";
51
51
  import type { Project } from "./projects.js";
52
52
  import { type PaddockTrigger } from "./trigger-config.js";
53
53
  import { type McpSources } from "./claude-mcp.js";
54
+ import { type HostPluginSource } from "./claude-plugins.js";
54
55
  import { type AdoptableSummary, type FilterReason } from "./adoptable.js";
55
56
  /**
56
57
  * One session an import did NOT bring in, with a reason a UI can show verbatim.
@@ -121,6 +122,7 @@ export declare function sliceTranscriptAtUuid(raw: string, cutUuid: string, endO
121
122
  export declare class HerdctlService {
122
123
  private readonly cfg;
123
124
  private readonly mcpSources;
125
+ private readonly hostPlugins;
124
126
  private fleet;
125
127
  private started;
126
128
  /**
@@ -193,8 +195,12 @@ export declare class HerdctlService {
193
195
  * all under `mcpServers: own`. Defaults to empty so every existing caller —
194
196
  * including the several tests that construct this with `{} as PaddockConfig` —
195
197
  * keeps meaning "isolated".
198
+ *
199
+ * `hostPlugins` is the host's Claude Code plugins (#700), read at boot by
200
+ * `loadHostPlugins` under `claude.instructions: host`, and handed in for
201
+ * exactly the same reasons.
196
202
  */
197
- constructor(cfg: PaddockConfig, mcpSources?: McpSources);
203
+ constructor(cfg: PaddockConfig, mcpSources?: McpSources, hostPlugins?: HostPluginSource);
198
204
  /**
199
205
  * The Claude home every transcript symlink is planted in, plus where those
200
206
  * symlinks point (#620, #691). One accessor, so no call site can quietly take
@@ -57,6 +57,7 @@ import { ensureProjectChats, projectChatsDir } from "./transcripts.js";
57
57
  import { triggerRunsOnOwnAgent, isCuratorTrigger, } from "./trigger-config.js";
58
58
  import { buildAgentConfig, buildSweeperConfig, sweeperWorkingDir, buildTriggerConfig, ensureConfigFile as writeBootConfigFile, } from "./herdctl-agent-config.js";
59
59
  import { EMPTY_MCP_SOURCES } from "./claude-mcp.js";
60
+ import { EMPTY_HOST_PLUGINS } from "./claude-plugins.js";
60
61
  import * as jobs from "./herdctl-jobs.js";
61
62
  import { JobsDirIndex } from "./herdctl-jobs-index.js";
62
63
  import { AdoptableIndex } from "./adoptable.js";
@@ -164,6 +165,7 @@ export function sliceTranscriptAtUuid(raw, cutUuid, endOnCompletedAssistant = fa
164
165
  export class HerdctlService {
165
166
  cfg;
166
167
  mcpSources;
168
+ hostPlugins;
167
169
  fleet = null;
168
170
  started = false;
169
171
  /**
@@ -238,10 +240,15 @@ export class HerdctlService {
238
240
  * all under `mcpServers: own`. Defaults to empty so every existing caller —
239
241
  * including the several tests that construct this with `{} as PaddockConfig` —
240
242
  * keeps meaning "isolated".
243
+ *
244
+ * `hostPlugins` is the host's Claude Code plugins (#700), read at boot by
245
+ * `loadHostPlugins` under `claude.instructions: host`, and handed in for
246
+ * exactly the same reasons.
241
247
  */
242
- constructor(cfg, mcpSources = EMPTY_MCP_SOURCES) {
248
+ constructor(cfg, mcpSources = EMPTY_MCP_SOURCES, hostPlugins = EMPTY_HOST_PLUGINS) {
243
249
  this.cfg = cfg;
244
250
  this.mcpSources = mcpSources;
251
+ this.hostPlugins = hostPlugins;
245
252
  }
246
253
  /**
247
254
  * The Claude home every transcript symlink is planted in, plus where those
@@ -1508,7 +1515,7 @@ export class HerdctlService {
1508
1515
  // existing unit-test seams (which reach `sweeperAgentConfig`/`ensureConfigFile`
1509
1516
  // via a cast) and internal callers keep working.
1510
1517
  keeperAgentConfig(project, modelOverride) {
1511
- return buildAgentConfig(this.cfg, project, modelOverride, this.mcpSources);
1518
+ return buildAgentConfig(this.cfg, project, modelOverride, this.mcpSources, this.hostPlugins);
1512
1519
  }
1513
1520
  sweeperAgentConfig(project) {
1514
1521
  return buildSweeperConfig(this.cfg, project);
@@ -308,8 +308,9 @@ export function buildManagementOps(ctx, params) {
308
308
  name: project.name,
309
309
  dir: project.dir,
310
310
  workingDir: project.workingDir,
311
- repoBacked: project.repoBacked === true,
311
+ managed: project.managed === true,
312
312
  ...(project.repo ? { repo: project.repo } : {}),
313
+ ...(project.path ? { path: project.path } : {}),
313
314
  agentRegistered,
314
315
  };
315
316
  },
@@ -48,33 +48,46 @@
48
48
  * Unlike a management token, an MCP `env` entry is not necessarily secret
49
49
  * (`NOTION_VERSION: "2022-06-28"` is not), so an inline value is a WARNING and
50
50
  * not an error — but the warning is emitted for any key that looks like a
51
- * credential, and nothing in this module ever prints a value. Every diagnostic
52
- * names keys, variable names and server names only; a `url` is reported with its
53
- * query string and userinfo stripped, because that is where an API key rides.
54
- * {@link describeServer} is the single place a server is rendered for a human,
55
- * so there is one function to audit rather than a scattering of template strings.
51
+ * credential, and nothing in this module ever prints a value. `headers` follows
52
+ * the same rule and for a stronger reason: `Authorization` is the usual reason a
53
+ * header is declared at all. Every diagnostic names keys, variable names and
54
+ * server names only; a `url` is reported with its query string and userinfo
55
+ * stripped, because that is where an API key rides. {@link describeServer} is the
56
+ * single place a server is rendered for a human, so there is one function to
57
+ * audit rather than a scattering of template strings.
56
58
  *
57
59
  * The resolved values do land in the frozen `PaddockConfig` (as management tokens
58
60
  * already do). They must never reach an API response: `instance-config.ts`
59
61
  * publishes only the fields in its own `FIELDS` table, and this block is
60
62
  * deliberately absent from it.
61
63
  *
64
+ * One exposure the `env:` indirection does NOT close: under `driveMode: batch`
65
+ * the resolved value reaches a COMMAND LINE. See {@link argvExposure} — and note
66
+ * that carrying `headers` (#700) widens it, because an `Authorization` bearer is
67
+ * a likelier long-lived credential than an `env` entry.
68
+ *
62
69
  * ## Validation is strict, and drops rather than degrades
63
70
  *
64
71
  * Step 5 passes a host server it cannot carry faithfully, with a warning, on the
65
72
  * grounds that a user who declared a server elsewhere should get it plus a
66
73
  * warning rather than nothing plus a warning. That reasoning inverts here,
67
74
  * because the user typed this file: an unusable declaration is a mistake they can
68
- * fix, and a `headers:` block that is silently dropped is an authentication
69
- * failure that looks like a broken server. So anything that cannot be carried is
70
- * an ERROR and that server is not attached — including an unknown key, which is
71
- * how a typo (`arg:` for `args:`) otherwise becomes a server that starts wrong.
75
+ * fix. So anything that cannot be carried is an ERROR and that server is not
76
+ * attached — including an unknown key, which is how a typo (`arg:` for `args:`)
77
+ * otherwise becomes a server that starts wrong.
78
+ *
79
+ * `headers:` and `type: sse` USED to be in that category, because herdctl's
80
+ * schema had no field for either. herdctl 5.32.0 (#446) carries both verbatim,
81
+ * so refusing them would now reject a declaration that works — they are accepted,
82
+ * and `headers` is a first-class secret-bearing field with the same `env:VAR`
83
+ * resolution and the same never-print rule as `env`.
72
84
  *
73
85
  * Never a boot failure, though: one bad server drops itself and the rest attach,
74
86
  * matching `resolveManagementApiConfig`. A typo in a capability must not take an
75
87
  * instance down.
76
88
  */
77
89
  import type { McpServerDefs, McpServerDef } from "./claude-mcp.js";
90
+ import type { DriveMode } from "./models.js";
78
91
  /** On-disk shape of one entry of the `mcpServers:` block. Every field optional. */
79
92
  export interface McpServerConfigFile {
80
93
  /** Executable for a stdio server. Mutually exclusive with {@link url}. */
@@ -83,11 +96,11 @@ export interface McpServerConfigFile {
83
96
  args?: string[];
84
97
  /** Environment for the server process. Values may be `env:VAR_NAME`. */
85
98
  env?: Record<string, string>;
86
- /** Endpoint for a streamable-HTTP server. Mutually exclusive with {@link command}. */
99
+ /** Endpoint for a remote server. Mutually exclusive with {@link command}. */
87
100
  url?: string;
88
- /** Accepted only as `stdio`/`http`, and only when it agrees with the above. */
101
+ /** `stdio` for a {@link command}; `http` or `sse` for a {@link url}. */
89
102
  type?: string;
90
- /** Present only in a MISCONFIGURED file: the engine cannot carry headers. */
103
+ /** Headers for a remote server. Values may be `env:VAR_NAME`. */
91
104
  headers?: Record<string, string>;
92
105
  }
93
106
  /** On-disk shape of the whole block: a map of server name → declaration. */
@@ -165,4 +178,10 @@ export declare function declaredMcpNotices(opts: {
165
178
  hostNames?: readonly string[];
166
179
  /** Whether paddock's own browser server is attached (it wins a name clash). */
167
180
  browserMcp?: boolean;
181
+ /**
182
+ * The instance's drive mode, which decides whether a declared `env` value ends
183
+ * up on a command line. Omitted ⇒ say nothing (the several callers that only
184
+ * want the inventory line).
185
+ */
186
+ driveMode?: DriveMode;
168
187
  }): DeclaredMcpNotice[];
@@ -65,12 +65,15 @@ export function describeServer(name, def) {
65
65
  if (def.command)
66
66
  parts.push(`stdio: ${def.command.split(/[\\/]/).pop()}`);
67
67
  if (def.url)
68
- parts.push(`http: ${redactUrl(def.url)}`);
68
+ parts.push(`${def.type ?? "http"}: ${redactUrl(def.url)}`);
69
69
  if (def.args?.length)
70
70
  parts.push(`${def.args.length} args`);
71
71
  const envCount = Object.keys(def.env ?? {}).length;
72
72
  if (envCount > 0)
73
73
  parts.push(`${envCount} env ${envCount === 1 ? "entry" : "entries"}`);
74
+ const headerCount = Object.keys(def.headers ?? {}).length;
75
+ if (headerCount > 0)
76
+ parts.push(`${headerCount} ${headerCount === 1 ? "header" : "headers"}`);
74
77
  return `${name} (${parts.join(", ")})`;
75
78
  }
76
79
  /**
@@ -102,18 +105,18 @@ function resolveLeaf(raw, env, where) {
102
105
  * Validate + resolve one declaration into the shape herdctl's `McpServerSchema`
103
106
  * carries, or explain why it cannot be.
104
107
  *
105
- * The rejections are the interesting part, and each is a thing step 5 discovered
106
- * the engine strips silently:
108
+ * The one rejection left that is about the engine rather than about the file is
109
+ * **an unknown key** — the only defence against `arg:`/`envs:`/`commands:`, which
110
+ * would otherwise yield a server that starts with the wrong argv.
107
111
  *
108
- * - **`headers`** — there is no field for them, so a bearer-authenticated server
109
- * would arrive unauthenticated AND miss its stored OAuth token, which is keyed
110
- * on a hash that includes the headers. Step 5 passes such a host server with a
111
- * warning; a hand-written declaration gets an error instead, because the user
112
- * typed the header and can act on being told it does nothing.
113
- * - **`type: sse`** — every `url` is mapped to `type: "http"` downstream, so an
114
- * sse server is silently downgraded, with the same OAuth-key consequence.
115
- * - **an unknown key** — the only defence against `arg:`/`envs:`/`commands:`,
116
- * which would otherwise yield a server that starts with the wrong argv.
112
+ * `headers` and `type: sse` were rejections too until herdctl 5.32.0, because
113
+ * the schema had no field for either: a bearer-authenticated server arrived
114
+ * unauthenticated and missed its stored OAuth token (keyed on a hash that
115
+ * includes the headers and the type), and an `sse` url was silently connected to
116
+ * as HTTP. Both are now carried verbatim, so both are accepted — `headers`
117
+ * through the same `env:VAR` resolution as `env`, and `type` checked only for
118
+ * agreeing with the declaration (`stdio` for a `command`, `http`/`sse` for a
119
+ * `url`).
117
120
  */
118
121
  function narrowDeclaration(name, raw, env) {
119
122
  const where = `mcpServers.${name}`;
@@ -135,16 +138,10 @@ function narrowDeclaration(name, raw, env) {
135
138
  return fail(`${where}: is not a mapping`);
136
139
  const unknown = Object.keys(raw).filter((k) => !KNOWN_KEYS.has(k));
137
140
  if (unknown.length > 0) {
138
- return fail(`${where}: unrecognised key(s) ${unknown.join(", ")} (supported: command, args, env, url) ` +
141
+ return fail(`${where}: unrecognised key(s) ${unknown.join(", ")} ` +
142
+ `(supported: command, args, env, url, type, headers) ` +
139
143
  `— a mistyped key would otherwise be dropped without a trace`);
140
144
  }
141
- if (raw.headers !== undefined) {
142
- const keys = isRecord(raw.headers) ? Object.keys(raw.headers) : [];
143
- return fail(`${where}: \`headers\`${keys.length > 0 ? ` (${keys.join(", ")})` : ""} cannot be ` +
144
- `carried — the engine's MCP schema has no field for them, so the server would arrive ` +
145
- `unauthenticated and its stored OAuth token (keyed on a hash that includes the headers) ` +
146
- `would not be found either`);
147
- }
148
145
  const hasCommand = typeof raw.command === "string" && raw.command.trim().length > 0;
149
146
  const hasUrl = typeof raw.url === "string" && raw.url.trim().length > 0;
150
147
  if (hasCommand && hasUrl) {
@@ -153,20 +150,21 @@ function narrowDeclaration(name, raw, env) {
153
150
  if (!hasCommand && !hasUrl) {
154
151
  return fail(`${where}: declares neither a \`command\` nor a \`url\`, so there is nothing to start`);
155
152
  }
153
+ const server = {};
156
154
  if (raw.type !== undefined) {
157
155
  const type = String(raw.type).trim().toLowerCase();
158
- if (type === "sse") {
159
- return fail(`${where}: \`type: sse\` cannot be carried — every \`url\` server is connected to as ` +
160
- `streamable HTTP downstream, so an sse server would be silently downgraded (and its ` +
161
- `stored OAuth token, keyed on a hash that includes the type, not found). Declare it ` +
162
- `as \`http\` if the server supports it`);
163
- }
164
- const expected = hasUrl ? "http" : "stdio";
165
- if (type !== expected) {
166
- return fail(`${where}: \`type: ${type}\` disagrees with the declaration (expected ${expected})`);
156
+ // `sse` joined `http` here in herdctl 5.32.0; an explicit type now wins over
157
+ // the bare-`url` ⇒ `http` inference rather than being stripped on the way
158
+ // down. It still has to agree with the rest of the declaration, because a
159
+ // `type` that disagrees is a typo and starting the wrong transport is a
160
+ // confusing failure rather than a loud one.
161
+ const expected = hasUrl ? ["http", "sse"] : ["stdio"];
162
+ if (!expected.includes(type)) {
163
+ return fail(`${where}: \`type: ${type}\` disagrees with the declaration ` +
164
+ `(expected ${expected.join(" or ")})`);
167
165
  }
166
+ server.type = type;
168
167
  }
169
- const server = {};
170
168
  if (hasCommand) {
171
169
  const res = resolveLeaf(raw.command.trim(), env, `${where}.command`);
172
170
  if (!res.ok)
@@ -178,9 +176,9 @@ function narrowDeclaration(name, raw, env) {
178
176
  if (!res.ok)
179
177
  return { errors, warnings: [...warnings, `${res.error} — server not attached`] };
180
178
  server.url = res.value;
181
- // A key in the query string is the usual way a remote MCP endpoint is
182
- // authenticated now that `headers` cannot be carried, and this file is
183
- // git-tracked. Say so once; never echo the query itself.
179
+ // A key in the query string is one of the two ways a remote MCP endpoint is
180
+ // authenticated (the other, `headers`, is carryable again since #700), and
181
+ // this file is git-tracked. Say so once; never echo the query itself.
184
182
  if (!raw.url.startsWith(ENV_REF_PREFIX)) {
185
183
  let parsed;
186
184
  try {
@@ -231,6 +229,34 @@ function narrowDeclaration(name, raw, env) {
231
229
  if (Object.keys(resolved).length > 0)
232
230
  server.env = resolved;
233
231
  }
232
+ if (raw.headers !== undefined) {
233
+ if (!hasUrl) {
234
+ return fail(`${where}.headers: only a \`url\` server can carry headers`);
235
+ }
236
+ if (!isRecord(raw.headers)) {
237
+ return fail(`${where}.headers: must be a mapping of header name to value`);
238
+ }
239
+ const resolved = {};
240
+ for (const [key, value] of Object.entries(raw.headers)) {
241
+ if (typeof value !== "string") {
242
+ return fail(`${where}.headers.${key}: must be a string`);
243
+ }
244
+ const res = resolveLeaf(value, env, `${where}.headers.${key}`);
245
+ if (!res.ok)
246
+ return { errors, warnings: [...warnings, `${res.error} — server not attached`] };
247
+ resolved[key] = res.value;
248
+ // Same heuristic as `env`, one rule to learn — and `Authorization` is the
249
+ // usual reason a header is declared at all, so this fires on the common
250
+ // case rather than the exotic one.
251
+ if (!value.startsWith(ENV_REF_PREFIX) && SECRET_ISH_KEY_RE.test(key)) {
252
+ warnings.push(`${where}.headers.${key}: looks like a credential and is written into the config file ` +
253
+ `itself, which is git-tracked — prefer \`${key}: ${ENV_REF_PREFIX}VAR_NAME\` and set ` +
254
+ `the value in the environment`);
255
+ }
256
+ }
257
+ if (Object.keys(resolved).length > 0)
258
+ server.headers = resolved;
259
+ }
234
260
  return { server, errors, warnings };
235
261
  }
236
262
  /**
@@ -265,6 +291,62 @@ export function resolveDeclaredMcpServers(file, env) {
265
291
  }
266
292
  return { servers, errors, warnings };
267
293
  }
294
+ /**
295
+ * The one way a declared credential leaves paddock, said out loud.
296
+ *
297
+ * Everything else in this module is about keeping a resolved value out of the
298
+ * places paddock controls — the log, an error, the Settings API. It does that
299
+ * completely, and it is still not the whole story, because of what herdctl does
300
+ * with the record afterwards:
301
+ *
302
+ * ```js
303
+ * // @herdctl/core, runner/runtime/cli-runtime.js
304
+ * const mcpServers = transformMcpServers(options.agent.mcp_servers);
305
+ * const mcpConfig = JSON.stringify({ mcpServers });
306
+ * args.push("--mcp-config", mcpConfig); // ← env values and all
307
+ * ```
308
+ *
309
+ * A process ARGUMENT is not private on Linux: `/proc/<pid>/cmdline` is
310
+ * world-readable by default (no `hidepid`), and `ps` prints it. So on the CLI
311
+ * runtime every declared server's `env` — and, since #700 made the field
312
+ * carryable, its `headers` — is legible to any local user for the lifetime of
313
+ * each `claude` invocation. Observed, not
314
+ * inferred: `test/integration/declared-mcp-argv.test.ts` drives a real turn and
315
+ * reads the token back out of the spawned process's argv.
316
+ *
317
+ * The SDK runtime does NOT do this. It hands the same record to the SDK
318
+ * in-process, and the stdio server it spawns receives the value in its
319
+ * environment, where `/proc/<pid>/environ` is owner-only — which is exactly what
320
+ * Claude Code itself does, so it is not a regression to fix here.
321
+ *
322
+ * Which runtime runs is `driveMode`: `session` (the default) is the SDK,
323
+ * `batch` is the CLI. Hence a WARNING on an instance that is on `batch`, and an
324
+ * informational note otherwise — because a single project can pin `driveMode:
325
+ * batch` for itself and bring the exposure back with it.
326
+ *
327
+ * Paddock cannot close this from here: the fix is upstream (the Claude CLI's
328
+ * `--mcp-config` also accepts a file path, which is not readable from another
329
+ * process's argv). Refusing to attach the server instead would break the feature
330
+ * for the deployments most likely to need it, so what this does is refuse to be
331
+ * silent — the same posture step 5 takes towards the fields the engine's schema
332
+ * cannot carry.
333
+ */
334
+ function argvExposure(names, driveMode) {
335
+ const batch = driveMode === "batch";
336
+ return {
337
+ level: batch ? "warn" : "info",
338
+ message: `${names.join(", ")} ${names.length === 1 ? "declares" : "declare"} \`env\` values or ` +
339
+ `\`headers\`, and ` +
340
+ `under \`driveMode: batch\` the engine passes the whole server definition to \`claude\` ` +
341
+ `as a \`--mcp-config\` COMMAND-LINE argument — where any local process can read it via ` +
342
+ `/proc/<pid>/cmdline. ` +
343
+ (batch
344
+ ? `This instance is on \`driveMode: batch\`, so that applies to every turn. Prefer ` +
345
+ `\`driveMode: session\` (the default) for a server that holds a credential.`
346
+ : `This instance is on \`driveMode: session\`, which passes them in-process instead — ` +
347
+ `but a project that pins \`driveMode: batch\` brings the exposure back.`),
348
+ };
349
+ }
268
350
  /**
269
351
  * What to say at boot about the declared servers — the answer to "what does this
270
352
  * instance actually have?", which should be readable off the log rather than
@@ -294,6 +376,18 @@ export function declaredMcpNotices(opts) {
294
376
  `~/.claude.json (\`claude.mcpServers: host\`); this instance's own declaration wins.`,
295
377
  });
296
378
  }
379
+ // `headers` joined `env` here in #700: herdctl 5.32.0 carries them, so an
380
+ // `Authorization` bearer now rides in the same `--mcp-config` argv element that
381
+ // #702 found an `env` token in — and a bearer is the likelier long-lived
382
+ // credential of the two. A server declared with headers and no env would
383
+ // otherwise be the one case this warning missed.
384
+ const exposed = names.filter((n) => Object.keys(opts.servers[n].env ?? {}).length > 0 ||
385
+ Object.keys(opts.servers[n].headers ?? {}).length > 0);
386
+ if (exposed.length > 0 && opts.driveMode !== undefined) {
387
+ // The one place a declared credential escapes paddock, and it escapes
388
+ // downstream of every rule this module enforces — see {@link argvExposure}.
389
+ notices.push(argvExposure(exposed, opts.driveMode));
390
+ }
297
391
  if (opts.browserMcp && names.includes("playwright")) {
298
392
  notices.push({
299
393
  level: "warn",
@@ -66,11 +66,79 @@ export declare function isValidRepoUrl(url: string): boolean;
66
66
  */
67
67
  export declare function repoCheckoutName(repo: string): string;
68
68
  /**
69
- * Resolve a project's working directory (keeper cwd) from its metadata dir + repo
70
- * URL: the nested checkout for a repo-backed project, else the metadata dir
71
- * itself for a notebook project (issue #187).
69
+ * Whether `child` is `parent` itself or lives underneath it. Both are resolved
70
+ * first, so this compares real path strings rather than the caller's spelling
71
+ * (it does NOT follow symlinks — callers that care resolve them beforehand).
72
+ *
73
+ * The `+ path.sep` is load-bearing: a plain `startsWith(parent)` would call
74
+ * `/data/projects-old` a child of `/data/projects`.
75
+ */
76
+ export declare function isPathInside(child: string, parent: string): boolean;
77
+ /**
78
+ * Resolve a project's working directory (keeper cwd) from its metadata dir plus
79
+ * whichever backing field is set:
80
+ *
81
+ * - **`linkedPath`** (issue #206) — the cwd IS that directory, returned
82
+ * verbatim, used in place with no copy. Applies on BOTH sides of the
83
+ * managed/unmanaged axis: an unmanaged project links a code checkout you
84
+ * already have, a managed one nominates where its notes should live.
85
+ * - **`repo`** (issue #187) — the nested checkout Paddock cloned.
86
+ * - **neither** — the metadata dir itself.
87
+ *
88
+ * `linkedPath` WINS over `repo`, which is why the two are not mutually exclusive:
89
+ * `repo` alongside a path is an ACQUISITION hint (clone here if the path is
90
+ * missing) plus a remote-match for adoption (`adoptable.ts`, #659) and a re-clone
91
+ * hint for disaster recovery. It never moves the cwd off the nominated path.
92
+ */
93
+ export declare function workingDirFor(dir: string, repo?: string, linkedPath?: string): string;
94
+ /** The subset of `project.yaml` that decides a project's shape. */
95
+ export interface ProjectBacking {
96
+ managed?: boolean;
97
+ repo?: string;
98
+ path?: string;
99
+ }
100
+ /**
101
+ * Whether Paddock looks after this project's own files — the real axis, and the
102
+ * one that replaced the overloaded `repoBacked` boolean (issue #206).
103
+ *
104
+ * **managed** — Paddock curates `CLAUDE.md` / `OVERVIEW.md` / `CHANGELOG.md`
105
+ * here. This is what "notebook" used to mean.
106
+ * **unmanaged** — the content is code that you or your agents source-control
107
+ * OUTSIDE Paddock. Paddock never writes project files into it.
108
+ *
109
+ * Whether a git repo sits behind it is an INDEPENDENT question (just which of
110
+ * `path`/`repo` are set), not a type. See `docs/DESIGN-backing-store.md` §2 goal
111
+ * 4: the base store is a plain directory and git is an optional layer.
112
+ *
113
+ * ## The default is derived, never constant
114
+ *
115
+ * `managed ?? !(repo || path)` — an absent key means "whatever this project's
116
+ * backing implies". That is load-bearing for the upgrade path: every existing
117
+ * `project.yaml` predates the key, and if absent meant `true`, every existing
118
+ * repo-backed project would flip to managed on upgrade and the sweeper would
119
+ * start writing `CLAUDE.md` into somebody's checkout. New projects write the key
120
+ * explicitly, so the derivation only ever has to serve legacy files.
121
+ */
122
+ export declare function isManaged(backing: ProjectBacking): boolean;
123
+ /**
124
+ * Where a project's CURATED CONTENT lives — `OVERVIEW.md`, `CHANGELOG.md` and
125
+ * `CLAUDE.md`, the three files the sweeper writes.
126
+ *
127
+ * Content follows the working directory; application state does not. So:
128
+ *
129
+ * - **managed** ⇒ the working dir. For a classic notebook that IS the metadata
130
+ * dir (unchanged). For a managed project with an external `path`, the curated
131
+ * files live out there with the content — an accepted consequence being that
132
+ * they then do NOT live in the Paddock data dir.
133
+ * - **unmanaged** ⇒ the metadata dir, always. The working dir is somebody
134
+ * else's tree and Paddock does not write project files into it; `OVERVIEW.md`
135
+ * and `CHANGELOG.md` are still curated, just sidecarred (issue #187), and
136
+ * `CLAUDE.md` is not curated at all (the checkout owns its own).
137
+ *
138
+ * `project.yaml` and `.chats/` are NOT content and never move — see
139
+ * {@link import("./projects.js").ProjectStore.dirFor}.
72
140
  */
73
- export declare function workingDirFor(dir: string, repo?: string): string;
141
+ export declare function contentDirFor(dir: string, backing: ProjectBacking): string;
74
142
  export declare class ProjectError extends Error {
75
143
  readonly code: "not_found" | "exists" | "invalid" | "not_directory";
76
144
  constructor(message: string, code: "not_found" | "exists" | "invalid" | "not_directory");
@@ -74,13 +74,85 @@ export function repoCheckoutName(repo) {
74
74
  return safe || "repo";
75
75
  }
76
76
  /**
77
- * Resolve a project's working directory (keeper cwd) from its metadata dir + repo
78
- * URL: the nested checkout for a repo-backed project, else the metadata dir
79
- * itself for a notebook project (issue #187).
77
+ * Whether `child` is `parent` itself or lives underneath it. Both are resolved
78
+ * first, so this compares real path strings rather than the caller's spelling
79
+ * (it does NOT follow symlinks — callers that care resolve them beforehand).
80
+ *
81
+ * The `+ path.sep` is load-bearing: a plain `startsWith(parent)` would call
82
+ * `/data/projects-old` a child of `/data/projects`.
83
+ */
84
+ export function isPathInside(child, parent) {
85
+ const c = path.resolve(child);
86
+ const p = path.resolve(parent);
87
+ return c === p || c.startsWith(p + path.sep);
88
+ }
89
+ /**
90
+ * Resolve a project's working directory (keeper cwd) from its metadata dir plus
91
+ * whichever backing field is set:
92
+ *
93
+ * - **`linkedPath`** (issue #206) — the cwd IS that directory, returned
94
+ * verbatim, used in place with no copy. Applies on BOTH sides of the
95
+ * managed/unmanaged axis: an unmanaged project links a code checkout you
96
+ * already have, a managed one nominates where its notes should live.
97
+ * - **`repo`** (issue #187) — the nested checkout Paddock cloned.
98
+ * - **neither** — the metadata dir itself.
99
+ *
100
+ * `linkedPath` WINS over `repo`, which is why the two are not mutually exclusive:
101
+ * `repo` alongside a path is an ACQUISITION hint (clone here if the path is
102
+ * missing) plus a remote-match for adoption (`adoptable.ts`, #659) and a re-clone
103
+ * hint for disaster recovery. It never moves the cwd off the nominated path.
80
104
  */
81
- export function workingDirFor(dir, repo) {
105
+ export function workingDirFor(dir, repo, linkedPath) {
106
+ if (linkedPath)
107
+ return linkedPath;
82
108
  return repo ? path.join(dir, repoCheckoutName(repo)) : dir;
83
109
  }
110
+ /**
111
+ * Whether Paddock looks after this project's own files — the real axis, and the
112
+ * one that replaced the overloaded `repoBacked` boolean (issue #206).
113
+ *
114
+ * **managed** — Paddock curates `CLAUDE.md` / `OVERVIEW.md` / `CHANGELOG.md`
115
+ * here. This is what "notebook" used to mean.
116
+ * **unmanaged** — the content is code that you or your agents source-control
117
+ * OUTSIDE Paddock. Paddock never writes project files into it.
118
+ *
119
+ * Whether a git repo sits behind it is an INDEPENDENT question (just which of
120
+ * `path`/`repo` are set), not a type. See `docs/DESIGN-backing-store.md` §2 goal
121
+ * 4: the base store is a plain directory and git is an optional layer.
122
+ *
123
+ * ## The default is derived, never constant
124
+ *
125
+ * `managed ?? !(repo || path)` — an absent key means "whatever this project's
126
+ * backing implies". That is load-bearing for the upgrade path: every existing
127
+ * `project.yaml` predates the key, and if absent meant `true`, every existing
128
+ * repo-backed project would flip to managed on upgrade and the sweeper would
129
+ * start writing `CLAUDE.md` into somebody's checkout. New projects write the key
130
+ * explicitly, so the derivation only ever has to serve legacy files.
131
+ */
132
+ export function isManaged(backing) {
133
+ return backing.managed ?? !(backing.repo || backing.path);
134
+ }
135
+ /**
136
+ * Where a project's CURATED CONTENT lives — `OVERVIEW.md`, `CHANGELOG.md` and
137
+ * `CLAUDE.md`, the three files the sweeper writes.
138
+ *
139
+ * Content follows the working directory; application state does not. So:
140
+ *
141
+ * - **managed** ⇒ the working dir. For a classic notebook that IS the metadata
142
+ * dir (unchanged). For a managed project with an external `path`, the curated
143
+ * files live out there with the content — an accepted consequence being that
144
+ * they then do NOT live in the Paddock data dir.
145
+ * - **unmanaged** ⇒ the metadata dir, always. The working dir is somebody
146
+ * else's tree and Paddock does not write project files into it; `OVERVIEW.md`
147
+ * and `CHANGELOG.md` are still curated, just sidecarred (issue #187), and
148
+ * `CLAUDE.md` is not curated at all (the checkout owns its own).
149
+ *
150
+ * `project.yaml` and `.chats/` are NOT content and never move — see
151
+ * {@link import("./projects.js").ProjectStore.dirFor}.
152
+ */
153
+ export function contentDirFor(dir, backing) {
154
+ return isManaged(backing) ? workingDirFor(dir, backing.repo, backing.path) : dir;
155
+ }
84
156
  export class ProjectError extends Error {
85
157
  code;
86
158
  constructor(message, code) {