@poncho-ai/harness 0.24.0 → 0.25.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/src/harness.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
2
4
  import type {
3
5
  AgentEvent,
4
6
  ContentPart,
@@ -13,7 +15,7 @@ import type {
13
15
  import { getTextContent } from "@poncho-ai/sdk";
14
16
  import type { UploadStore } from "./upload-store.js";
15
17
  import { PONCHO_UPLOAD_SCHEME, deriveUploadKey } from "./upload-store.js";
16
- import { parseAgentFile, renderAgentPrompt, type ParsedAgent, type AgentFrontmatter } from "./agent-parser.js";
18
+ import { parseAgentFile, parseAgentMarkdown, renderAgentPrompt, type ParsedAgent, type AgentFrontmatter } from "./agent-parser.js";
17
19
  import { loadPonchoConfig, resolveMemoryConfig, type PonchoConfig, type ToolAccess, type BuiltInToolToggles } from "./config.js";
18
20
  import { createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createWriteTool, ponchoDocsTool } from "./default-tools.js";
19
21
  import {
@@ -563,6 +565,7 @@ export class AgentHarness {
563
565
  };
564
566
 
565
567
  private parsedAgent?: ParsedAgent;
568
+ private agentFileFingerprint = "";
566
569
  private mcpBridge?: LocalMcpBridge;
567
570
  private subagentManager?: SubagentManager;
568
571
 
@@ -696,20 +699,17 @@ export class AgentHarness {
696
699
  }
697
700
 
698
701
  private getRequestedMcpPatterns(): string[] {
699
- const skillPatterns = new Set<string>();
702
+ const patterns = new Set<string>(this.getAgentMcpIntent());
700
703
  for (const skillName of this.activeSkillNames) {
701
704
  const skill = this.loadedSkills.find((entry) => entry.name === skillName);
702
705
  if (!skill) {
703
706
  continue;
704
707
  }
705
708
  for (const pattern of skill.allowedTools.mcp) {
706
- skillPatterns.add(pattern);
709
+ patterns.add(pattern);
707
710
  }
708
711
  }
709
- if (skillPatterns.size > 0) {
710
- return [...skillPatterns];
711
- }
712
- return this.getAgentMcpIntent();
712
+ return [...patterns];
713
713
  }
714
714
 
715
715
  private getRequestedScriptPatterns(): string[] {
@@ -727,20 +727,17 @@ export class AgentHarness {
727
727
  }
728
728
 
729
729
  private getRequestedMcpApprovalPatterns(): string[] {
730
- const skillPatterns = new Set<string>();
730
+ const patterns = new Set<string>(this.getAgentMcpApprovalPatterns());
731
731
  for (const skillName of this.activeSkillNames) {
732
732
  const skill = this.loadedSkills.find((entry) => entry.name === skillName);
733
733
  if (!skill) {
734
734
  continue;
735
735
  }
736
736
  for (const pattern of skill.approvalRequired.mcp) {
737
- skillPatterns.add(pattern);
737
+ patterns.add(pattern);
738
738
  }
739
739
  }
740
- if (skillPatterns.size > 0) {
741
- return [...skillPatterns];
742
- }
743
- return this.getAgentMcpApprovalPatterns();
740
+ return [...patterns];
744
741
  }
745
742
 
746
743
  private getRequestedScriptApprovalPatterns(): string[] {
@@ -891,13 +888,59 @@ export class AgentHarness {
891
888
 
892
889
  private static readonly SKILL_REFRESH_DEBOUNCE_MS = 3000;
893
890
 
894
- private async refreshSkillsIfChanged(): Promise<void> {
891
+ /**
892
+ * Re-read AGENT.md and update the parsed agent when the file has changed
893
+ * on disk. Returns `true` when the agent was actually re-parsed.
894
+ *
895
+ * Preserves the agent identity (id) across reloads so conversation
896
+ * continuity isn't broken.
897
+ */
898
+ private async refreshAgentIfChanged(): Promise<boolean> {
895
899
  if (this.environment !== "development") {
896
- return;
900
+ return false;
897
901
  }
898
- const elapsed = Date.now() - this.lastSkillRefreshAt;
899
- if (this.lastSkillRefreshAt > 0 && elapsed < AgentHarness.SKILL_REFRESH_DEBOUNCE_MS) {
900
- return;
902
+ try {
903
+ const agentFilePath = resolve(this.workingDir, "AGENT.md");
904
+ const rawContent = await readFile(agentFilePath, "utf8");
905
+ if (rawContent === this.agentFileFingerprint) {
906
+ return false;
907
+ }
908
+ const parsed = parseAgentMarkdown(rawContent);
909
+ // Preserve the resolved agent identity so existing conversations
910
+ // keep working after an AGENT.md edit.
911
+ if (!parsed.frontmatter.id && this.parsedAgent?.frontmatter.id) {
912
+ parsed.frontmatter.id = this.parsedAgent.frontmatter.id;
913
+ }
914
+ this.parsedAgent = parsed;
915
+ this.agentFileFingerprint = rawContent;
916
+ return true;
917
+ } catch (error) {
918
+ console.warn(
919
+ `[poncho][agent] Failed to refresh AGENT.md in development mode: ${
920
+ error instanceof Error ? error.message : String(error)
921
+ }`,
922
+ );
923
+ return false;
924
+ }
925
+ }
926
+
927
+ /**
928
+ * Re-scan skill directories and update metadata, tools, and context window
929
+ * when skills have changed on disk. Returns `true` when the skill set was
930
+ * actually updated.
931
+ *
932
+ * @param force - bypass the time-based debounce (used for mid-run refreshes
933
+ * after the agent may have written new skill files).
934
+ */
935
+ private async refreshSkillsIfChanged(force = false): Promise<boolean> {
936
+ if (this.environment !== "development") {
937
+ return false;
938
+ }
939
+ if (!force) {
940
+ const elapsed = Date.now() - this.lastSkillRefreshAt;
941
+ if (this.lastSkillRefreshAt > 0 && elapsed < AgentHarness.SKILL_REFRESH_DEBOUNCE_MS) {
942
+ return false;
943
+ }
901
944
  }
902
945
  this.lastSkillRefreshAt = Date.now();
903
946
  try {
@@ -907,27 +950,44 @@ export class AgentHarness {
907
950
  );
908
951
  const nextFingerprint = this.buildSkillFingerprint(latestSkills);
909
952
  if (nextFingerprint === this.skillFingerprint) {
910
- return;
953
+ return false;
911
954
  }
912
955
  this.loadedSkills = latestSkills;
913
956
  this.skillContextWindow = buildSkillContextWindow(latestSkills);
914
957
  this.skillFingerprint = nextFingerprint;
915
958
  this.registerSkillTools(latestSkills);
916
- // Skill metadata or layout changed; force re-activation to avoid stale
917
- // instructions/tooling when files are renamed or moved during development.
918
- this.activeSkillNames.clear();
959
+ // Prune active skills that no longer exist in the updated metadata,
960
+ // but preserve ones that were merely updated (same name). This keeps
961
+ // MCP tools from active skills registered when their allowed-tools
962
+ // list changes, instead of forcing the agent to re-activate.
963
+ const latestSkillNames = new Set(latestSkills.map(s => s.name));
964
+ for (const name of this.activeSkillNames) {
965
+ if (!latestSkillNames.has(name)) {
966
+ this.activeSkillNames.delete(name);
967
+ }
968
+ }
969
+ // Re-discover MCP server catalogs so newly advertised tools are visible,
970
+ // then refresh the registered tool set with updated skill patterns.
971
+ if (this.mcpBridge) {
972
+ await this.mcpBridge.discoverTools();
973
+ }
919
974
  await this.refreshMcpTools("skills:changed");
975
+ return true;
920
976
  } catch (error) {
921
977
  console.warn(
922
978
  `[poncho][skills] Failed to refresh skills in development mode: ${
923
979
  error instanceof Error ? error.message : String(error)
924
980
  }`,
925
981
  );
982
+ return false;
926
983
  }
927
984
  }
928
985
 
929
986
  async initialize(): Promise<void> {
930
- this.parsedAgent = await parseAgentFile(this.workingDir);
987
+ const agentFilePath = resolve(this.workingDir, "AGENT.md");
988
+ const agentRawContent = await readFile(agentFilePath, "utf8");
989
+ this.parsedAgent = parseAgentMarkdown(agentRawContent);
990
+ this.agentFileFingerprint = agentRawContent;
931
991
  const identity = await ensureAgentIdentity(this.workingDir);
932
992
  if (!this.parsedAgent.frontmatter.id) {
933
993
  this.parsedAgent.frontmatter.id = identity.id;
@@ -1286,10 +1346,11 @@ export class AgentHarness {
1286
1346
  if (!this.parsedAgent) {
1287
1347
  await this.initialize();
1288
1348
  }
1289
- // Start memory fetch early so it overlaps with skill refresh I/O
1349
+ // Start memory fetch early so it overlaps with refresh I/O
1290
1350
  const memoryPromise = this.memoryStore
1291
1351
  ? this.memoryStore.getMainMemory()
1292
1352
  : undefined;
1353
+ await this.refreshAgentIfChanged();
1293
1354
  await this.refreshSkillsIfChanged();
1294
1355
 
1295
1356
  // Track which conversation/owner this run belongs to so browser & subagent tools resolve correctly
@@ -1299,7 +1360,7 @@ export class AgentHarness {
1299
1360
  this._currentRunOwnerId = ownerParam;
1300
1361
  }
1301
1362
 
1302
- const agent = this.parsedAgent as ParsedAgent;
1363
+ let agent = this.parsedAgent as ParsedAgent;
1303
1364
  const runId = `run_${randomUUID()}`;
1304
1365
  const start = now();
1305
1366
  const maxSteps = agent.frontmatter.limits?.maxSteps ?? 50;
@@ -1315,15 +1376,16 @@ export class AgentHarness {
1315
1376
  const inputMessageCount = messages.length;
1316
1377
  const events: AgentEvent[] = [];
1317
1378
 
1318
- const systemPrompt = renderAgentPrompt(agent, {
1319
- parameters: input.parameters,
1320
- runtime: {
1321
- runId,
1322
- agentId: agent.frontmatter.id ?? agent.frontmatter.name,
1323
- environment: this.environment,
1324
- workingDir: this.workingDir,
1325
- },
1326
- });
1379
+ const renderCurrentAgentPrompt = (): string =>
1380
+ renderAgentPrompt(this.parsedAgent!, {
1381
+ parameters: input.parameters,
1382
+ runtime: {
1383
+ runId,
1384
+ agentId: this.parsedAgent!.frontmatter.id ?? this.parsedAgent!.frontmatter.name,
1385
+ environment: this.environment,
1386
+ workingDir: this.workingDir,
1387
+ },
1388
+ });
1327
1389
  const developmentContext =
1328
1390
  this.environment === "development" ? `\n\n${DEVELOPMENT_MODE_CONTEXT}` : "";
1329
1391
  const browserContext = this._browserSession
@@ -1346,9 +1408,6 @@ Browser sessions (cookies, localStorage, login state) are automatically saved an
1346
1408
  ### Tabs and resources
1347
1409
  Each conversation gets its own browser tab sharing a single browser instance. Call \`browser_close\` when done to free the tab. If you don't close it, the tab stays open and the user can continue interacting with it.`
1348
1410
  : "";
1349
- const promptWithSkills = this.skillContextWindow
1350
- ? `${systemPrompt}${developmentContext}\n\n${this.skillContextWindow}${browserContext}`
1351
- : `${systemPrompt}${developmentContext}${browserContext}`;
1352
1411
  const mainMemory = await memoryPromise;
1353
1412
  const boundedMainMemory =
1354
1413
  mainMemory && mainMemory.content.length > 4000
@@ -1361,7 +1420,13 @@ Each conversation gets its own browser tab sharing a single browser instance. Ca
1361
1420
 
1362
1421
  ${boundedMainMemory.trim()}`
1363
1422
  : "";
1364
- const integrityPrompt = `${promptWithSkills}${memoryContext}
1423
+
1424
+ const buildSystemPrompt = (): string => {
1425
+ const agentPrompt = renderCurrentAgentPrompt();
1426
+ const promptWithSkills = this.skillContextWindow
1427
+ ? `${agentPrompt}${developmentContext}\n\n${this.skillContextWindow}${browserContext}`
1428
+ : `${agentPrompt}${developmentContext}${browserContext}`;
1429
+ return `${promptWithSkills}${memoryContext}
1365
1430
 
1366
1431
  ## Execution Integrity
1367
1432
 
@@ -1369,6 +1434,9 @@ ${boundedMainMemory.trim()}`
1369
1434
  - Do not fabricate "Tool Used" or "Tool Result" logs as plain text.
1370
1435
  - Never output faux execution transcripts, markdown tool logs, or "Tool Used/Result" sections.
1371
1436
  - If no suitable tool is available, explicitly say that and ask for guidance.`;
1437
+ };
1438
+ let integrityPrompt = buildSystemPrompt();
1439
+ let lastPromptFingerprint = `${this.agentFileFingerprint}\n${this.skillFingerprint}`;
1372
1440
 
1373
1441
  const pushEvent = (event: AgentEvent): AgentEvent => {
1374
1442
  events.push(event);
@@ -2260,6 +2328,22 @@ ${boundedMainMemory.trim()}`
2260
2328
  metadata: toolMsgMeta as Message["metadata"],
2261
2329
  });
2262
2330
 
2331
+ // In development, re-read AGENT.md and re-scan skills after tool
2332
+ // execution so changes are available on the next step without
2333
+ // requiring a server restart.
2334
+ if (this.environment === "development") {
2335
+ const agentChanged = await this.refreshAgentIfChanged();
2336
+ const skillsChanged = await this.refreshSkillsIfChanged(true);
2337
+ if (agentChanged || skillsChanged) {
2338
+ agent = this.parsedAgent as ParsedAgent;
2339
+ const currentFingerprint = `${this.agentFileFingerprint}\n${this.skillFingerprint}`;
2340
+ if (currentFingerprint !== lastPromptFingerprint) {
2341
+ integrityPrompt = buildSystemPrompt();
2342
+ lastPromptFingerprint = currentFingerprint;
2343
+ }
2344
+ }
2345
+ }
2346
+
2263
2347
  yield pushEvent({
2264
2348
  type: "step:completed",
2265
2349
  step,
package/src/memory.ts CHANGED
@@ -27,7 +27,7 @@ export interface MemoryConfig {
27
27
 
28
28
  export interface MemoryStore {
29
29
  getMainMemory(): Promise<MainMemory>;
30
- updateMainMemory(input: { content: string; mode?: "replace" | "append" }): Promise<MainMemory>;
30
+ updateMainMemory(input: { content: string }): Promise<MainMemory>;
31
31
  }
32
32
 
33
33
  type MainMemoryPayload = {
@@ -89,19 +89,10 @@ class InMemoryMemoryStore implements MemoryStore {
89
89
  return this.mainMemory;
90
90
  }
91
91
 
92
- async updateMainMemory(input: {
93
- content: string;
94
- mode?: "replace" | "append";
95
- }): Promise<MainMemory> {
96
- const now = Date.now();
97
- const existing = await this.getMainMemory();
98
- const nextContent =
99
- input.mode === "append" && existing.content
100
- ? `${existing.content}\n\n${input.content}`.trim()
101
- : input.content;
92
+ async updateMainMemory(input: { content: string }): Promise<MainMemory> {
102
93
  this.mainMemory = {
103
- content: nextContent.trim(),
104
- updatedAt: now,
94
+ content: input.content.trim(),
95
+ updatedAt: Date.now(),
105
96
  };
106
97
  return this.mainMemory;
107
98
  }
@@ -166,18 +157,10 @@ class FileMainMemoryStore implements MemoryStore {
166
157
  return this.mainMemory;
167
158
  }
168
159
 
169
- async updateMainMemory(input: {
170
- content: string;
171
- mode?: "replace" | "append";
172
- }): Promise<MainMemory> {
160
+ async updateMainMemory(input: { content: string }): Promise<MainMemory> {
173
161
  await this.ensureLoaded();
174
- const existing = await this.getMainMemory();
175
- const nextContent =
176
- input.mode === "append" && existing.content
177
- ? `${existing.content}\n\n${input.content}`.trim()
178
- : input.content;
179
162
  this.mainMemory = {
180
- content: nextContent.trim(),
163
+ content: input.content.trim(),
181
164
  updatedAt: Date.now(),
182
165
  };
183
166
  await this.persist();
@@ -225,7 +208,6 @@ abstract class KeyValueMainMemoryStoreBase implements MemoryStore {
225
208
  } catch {
226
209
  await this.memoryFallback.updateMainMemory({
227
210
  content: payload.main.content,
228
- mode: "replace",
229
211
  });
230
212
  }
231
213
  }
@@ -235,18 +217,11 @@ abstract class KeyValueMainMemoryStoreBase implements MemoryStore {
235
217
  return payload.main;
236
218
  }
237
219
 
238
- async updateMainMemory(input: {
239
- content: string;
240
- mode?: "replace" | "append";
241
- }): Promise<MainMemory> {
220
+ async updateMainMemory(input: { content: string }): Promise<MainMemory> {
242
221
  const key = this.key();
243
222
  const payload = await this.readPayload(key);
244
- const nextContent =
245
- input.mode === "append" && payload.main.content
246
- ? `${payload.main.content}\n\n${input.content}`.trim()
247
- : input.content;
248
223
  payload.main = {
249
- content: nextContent.trim(),
224
+ content: input.content.trim(),
250
225
  updatedAt: Date.now(),
251
226
  };
252
227
  await this.writePayload(key, payload);
@@ -590,20 +565,17 @@ export const createMemoryTools = (
590
565
  },
591
566
  }),
592
567
  defineTool({
593
- name: "memory_main_update",
568
+ name: "memory_main_write",
594
569
  description:
595
- "Update persistent main memory when new stable preferences, long-term goals, or durable facts appear. Proactively evaluate every turn whether memory should be updated, and avoid storing ephemeral details.",
570
+ "Overwrite the entire persistent main memory document. " +
571
+ "Use for initial writes or full rewrites. " +
572
+ "Prefer memory_main_edit for targeted changes to existing memory.",
596
573
  inputSchema: {
597
574
  type: "object",
598
575
  properties: {
599
- mode: {
600
- type: "string",
601
- enum: ["replace", "append"],
602
- description: "replace overwrites memory; append adds content to the end",
603
- },
604
576
  content: {
605
577
  type: "string",
606
- description: "The memory content to write",
578
+ description: "The full memory content to write",
607
579
  },
608
580
  },
609
581
  required: ["content"],
@@ -614,11 +586,56 @@ export const createMemoryTools = (
614
586
  if (!content) {
615
587
  throw new Error("content is required");
616
588
  }
617
- const mode =
618
- input.mode === "append" || input.mode === "replace"
619
- ? input.mode
620
- : "replace";
621
- const memory = await store.updateMainMemory({ content, mode });
589
+ const memory = await store.updateMainMemory({ content });
590
+ return { ok: true, memory };
591
+ },
592
+ }),
593
+ defineTool({
594
+ name: "memory_main_edit",
595
+ description:
596
+ "Edit persistent main memory by replacing an exact string match with new content. " +
597
+ "The old_str must match exactly one location in memory. " +
598
+ "Use an empty new_str to delete matched content. " +
599
+ "Proactively evaluate every turn whether memory should be updated.",
600
+ inputSchema: {
601
+ type: "object",
602
+ properties: {
603
+ old_str: {
604
+ type: "string",
605
+ description:
606
+ "The exact text to find and replace (must be unique in memory). " +
607
+ "Include surrounding context if needed to ensure uniqueness.",
608
+ },
609
+ new_str: {
610
+ type: "string",
611
+ description: "The replacement text (use empty string to delete the matched content)",
612
+ },
613
+ },
614
+ required: ["old_str", "new_str"],
615
+ additionalProperties: false,
616
+ },
617
+ handler: async (input) => {
618
+ const oldStr = typeof input.old_str === "string" ? input.old_str : "";
619
+ const newStr = typeof input.new_str === "string" ? input.new_str : "";
620
+ if (!oldStr) {
621
+ throw new Error("old_str must not be empty.");
622
+ }
623
+ const current = await store.getMainMemory();
624
+ const content = current.content;
625
+ const first = content.indexOf(oldStr);
626
+ if (first === -1) {
627
+ throw new Error(
628
+ "old_str not found in memory. Make sure it matches exactly, including whitespace and line breaks.",
629
+ );
630
+ }
631
+ const last = content.lastIndexOf(oldStr);
632
+ if (first !== last) {
633
+ throw new Error(
634
+ "old_str appears multiple times in memory. Please provide more context to ensure a unique match.",
635
+ );
636
+ }
637
+ const newContent = content.slice(0, first) + newStr + content.slice(first + oldStr.length);
638
+ const memory = await store.updateMainMemory({ content: newContent });
622
639
  return { ok: true, memory };
623
640
  },
624
641
  }),
@@ -367,7 +367,7 @@ description: Beta skill
367
367
  });
368
368
  });
369
369
 
370
- it("clears active skills when skill metadata changes in development mode", async () => {
370
+ it("preserves active skills when skill metadata changes in development mode", async () => {
371
371
  const dir = await mkdtemp(join(tmpdir(), "poncho-harness-skill-refresh-clear-active-"));
372
372
  await writeFile(
373
373
  join(dir, "AGENT.md"),
@@ -405,6 +405,7 @@ description: Alpha skill
405
405
  await activate!.handler({ name: "alpha" }, {} as any);
406
406
  expect(await listActive!.handler({}, {} as any)).toEqual({ activeSkills: ["alpha"] });
407
407
 
408
+ // Update the skill metadata — the skill keeps the same name so it stays active
408
409
  await writeFile(
409
410
  join(dir, "skills", "alpha", "SKILL.md"),
410
411
  `---
@@ -417,7 +418,7 @@ description: Alpha skill updated
417
418
  "utf8",
418
419
  );
419
420
  await (harness as any).refreshSkillsIfChanged();
420
- expect(await listActive!.handler({}, {} as any)).toEqual({ activeSkills: [] });
421
+ expect(await listActive!.handler({}, {} as any)).toEqual({ activeSkills: ["alpha"] });
421
422
  });
422
423
 
423
424
  it("lists skill scripts through list_skill_scripts", async () => {
@@ -910,6 +911,132 @@ allowed-tools:
910
911
  await new Promise<void>((resolveClose) => mcpServer.close(() => resolveClose()));
911
912
  });
912
913
 
914
+ it("agent-level MCP tools persist when a skill is activated (additive)", async () => {
915
+ process.env.LINEAR_TOKEN = "token-123";
916
+ const mcpServer = createServer(async (req, res) => {
917
+ if (req.method === "DELETE") {
918
+ res.statusCode = 200;
919
+ res.end();
920
+ return;
921
+ }
922
+ const chunks: Buffer[] = [];
923
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
924
+ const body = Buffer.concat(chunks).toString("utf8");
925
+ const payload = body.trim().length > 0 ? (JSON.parse(body) as any) : {};
926
+ if (payload.method === "initialize") {
927
+ res.setHeader("Content-Type", "application/json");
928
+ res.setHeader("Mcp-Session-Id", "sess");
929
+ res.end(
930
+ JSON.stringify({
931
+ jsonrpc: "2.0",
932
+ id: payload.id,
933
+ result: {
934
+ protocolVersion: "2025-03-26",
935
+ capabilities: { tools: { listChanged: true } },
936
+ serverInfo: { name: "remote", version: "1.0.0" },
937
+ },
938
+ }),
939
+ );
940
+ return;
941
+ }
942
+ if (payload.method === "notifications/initialized") {
943
+ res.statusCode = 202;
944
+ res.end();
945
+ return;
946
+ }
947
+ if (payload.method === "tools/list") {
948
+ res.setHeader("Content-Type", "application/json");
949
+ res.end(
950
+ JSON.stringify({
951
+ jsonrpc: "2.0",
952
+ id: payload.id,
953
+ result: {
954
+ tools: [
955
+ { name: "a", inputSchema: { type: "object", properties: {} } },
956
+ { name: "b", inputSchema: { type: "object", properties: {} } },
957
+ ],
958
+ },
959
+ }),
960
+ );
961
+ return;
962
+ }
963
+ if (payload.method === "tools/call") {
964
+ res.setHeader("Content-Type", "application/json");
965
+ res.end(
966
+ JSON.stringify({
967
+ jsonrpc: "2.0",
968
+ id: payload.id,
969
+ result: { result: { ok: true } },
970
+ }),
971
+ );
972
+ return;
973
+ }
974
+ res.statusCode = 404;
975
+ res.end();
976
+ });
977
+ await new Promise<void>((resolveOpen) => mcpServer.listen(0, () => resolveOpen()));
978
+ const address = mcpServer.address();
979
+ if (!address || typeof address === "string") throw new Error("Unexpected address");
980
+ const dir = await mkdtemp(join(tmpdir(), "poncho-harness-additive-mcp-"));
981
+ await writeFile(
982
+ join(dir, "AGENT.md"),
983
+ `---
984
+ name: additive-agent
985
+ model:
986
+ provider: anthropic
987
+ name: claude-opus-4-5
988
+ allowed-tools:
989
+ - mcp:remote/a
990
+ ---
991
+
992
+ # Additive Agent
993
+ `,
994
+ "utf8",
995
+ );
996
+ await writeFile(
997
+ join(dir, "poncho.config.js"),
998
+ `export default {
999
+ mcp: [
1000
+ {
1001
+ name: "remote",
1002
+ url: "http://127.0.0.1:${address.port}/mcp",
1003
+ auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" }
1004
+ }
1005
+ ]
1006
+ };
1007
+ `,
1008
+ "utf8",
1009
+ );
1010
+ await mkdir(join(dir, "skills", "skill-b"), { recursive: true });
1011
+ await writeFile(
1012
+ join(dir, "skills", "skill-b", "SKILL.md"),
1013
+ `---
1014
+ name: skill-b
1015
+ description: B
1016
+ allowed-tools:
1017
+ - mcp:remote/b
1018
+ ---
1019
+ # B
1020
+ `,
1021
+ "utf8",
1022
+ );
1023
+ const harness = new AgentHarness({ workingDir: dir });
1024
+ await harness.initialize();
1025
+ const toolNames = () => harness.listTools().map((t) => t.name);
1026
+ expect(toolNames()).toContain("remote/a");
1027
+ expect(toolNames()).not.toContain("remote/b");
1028
+ const activate = harness.listTools().find((t) => t.name === "activate_skill")!;
1029
+ const deactivate = harness.listTools().find((t) => t.name === "deactivate_skill")!;
1030
+ await activate.handler({ name: "skill-b" }, {} as any);
1031
+ expect(toolNames()).toContain("remote/a");
1032
+ expect(toolNames()).toContain("remote/b");
1033
+ await deactivate.handler({ name: "skill-b" }, {} as any);
1034
+ expect(toolNames()).toContain("remote/a");
1035
+ expect(toolNames()).not.toContain("remote/b");
1036
+ await harness.shutdown();
1037
+ await new Promise<void>((resolveClose) => mcpServer.close(() => resolveClose()));
1038
+ });
1039
+
913
1040
  it("supports flat tool access config format", async () => {
914
1041
  const dir = await mkdtemp(join(tmpdir(), "poncho-harness-flat-tool-access-"));
915
1042
  await writeFile(