@freesyntax/notch-cli 0.5.20 → 0.5.21

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.
@@ -1,3 +1,6 @@
1
+ import {
2
+ Rollout
3
+ } from "./chunk-QKM27RHS.js";
1
4
  import {
2
5
  grepTool
3
6
  } from "./chunk-6CZCFY6H.js";
@@ -22,6 +25,9 @@ import {
22
25
  import {
23
26
  pluginManager
24
27
  } from "./chunk-3QUV4JEX.js";
28
+ import {
29
+ loadCredentials
30
+ } from "./chunk-FIFC4V2R.js";
25
31
  import {
26
32
  readTool
27
33
  } from "./chunk-CQMAVWLJ.js";
@@ -494,577 +500,1341 @@ error: no running cell with that id (it may have already completed)`,
494
500
  }
495
501
  };
496
502
 
497
- // src/mcp/client.ts
503
+ // src/tools/skill-manage.ts
498
504
  import { z as z2 } from "zod";
499
-
500
- // src/mcp/transport.ts
501
- function detectTransport(config) {
502
- if (config.transport) return config.transport;
503
- if (config.url) return "http";
504
- return "stdio";
505
- }
506
-
507
- // src/mcp/stdio-transport.ts
508
- import { spawn } from "child_process";
509
- var StdioTransport = class {
510
- constructor(config, name) {
511
- this.config = config;
512
- this.name = name;
513
- }
514
- config;
515
- process = null;
516
- requestId = 0;
517
- pendingRequests = /* @__PURE__ */ new Map();
518
- buffer = "";
519
- name;
520
- async connect() {
521
- if (!this.config.command) {
522
- throw new Error(`Stdio transport requires 'command' in config for server ${this.name}`);
523
- }
524
- this.process = spawn(this.config.command, this.config.args ?? [], {
525
- stdio: ["pipe", "pipe", "pipe"],
526
- env: { ...process.env, ...this.config.env },
527
- cwd: this.config.cwd
528
- });
529
- this.process.stdout?.setEncoding("utf-8");
530
- this.process.stdout?.on("data", (data) => {
531
- this.buffer += data;
532
- this.processBuffer();
533
- });
534
- this.process.on("error", (err) => {
535
- for (const [id, pending] of this.pendingRequests) {
536
- pending.reject(new Error(`MCP server ${this.name} error: ${err.message}`));
537
- this.pendingRequests.delete(id);
538
- }
539
- });
540
- this.process.on("exit", (code) => {
541
- for (const [id, pending] of this.pendingRequests) {
542
- pending.reject(new Error(`MCP server ${this.name} exited with code ${code}`));
543
- this.pendingRequests.delete(id);
544
- }
545
- });
546
- }
547
- disconnect() {
548
- if (this.process) {
549
- this.process.stdin?.end();
550
- this.process.kill();
551
- this.process = null;
552
- }
553
- this.pendingRequests.clear();
554
- }
555
- get isAlive() {
556
- return this.process !== null && this.process.exitCode === null && !this.process.killed;
557
- }
558
- sendRequest(method, params) {
559
- return new Promise((resolve, reject) => {
560
- const id = ++this.requestId;
561
- const msg = { jsonrpc: "2.0", id, method, params };
562
- this.pendingRequests.set(id, { resolve, reject });
563
- const data = JSON.stringify(msg);
564
- const header = `Content-Length: ${Buffer.byteLength(data)}\r
565
- \r
566
- `;
567
- this.process?.stdin?.write(header + data);
568
- setTimeout(() => {
569
- if (this.pendingRequests.has(id)) {
570
- this.pendingRequests.delete(id);
571
- reject(new Error(`MCP request ${method} timed out`));
572
- }
573
- }, 3e4);
574
- });
575
- }
576
- sendNotification(method, params) {
577
- const msg = { jsonrpc: "2.0", method, params };
578
- const data = JSON.stringify(msg);
579
- const header = `Content-Length: ${Buffer.byteLength(data)}\r
580
- \r
581
- `;
582
- this.process?.stdin?.write(header + data);
583
- }
584
- processBuffer() {
585
- while (this.buffer.length > 0) {
586
- const headerEnd = this.buffer.indexOf("\r\n\r\n");
587
- if (headerEnd === -1) break;
588
- const header = this.buffer.slice(0, headerEnd);
589
- const lengthMatch = header.match(/Content-Length:\s*(\d+)/i);
590
- if (!lengthMatch) {
591
- const nlIdx = this.buffer.indexOf("\n");
592
- if (nlIdx === -1) break;
593
- const line = this.buffer.slice(0, nlIdx).trim();
594
- this.buffer = this.buffer.slice(nlIdx + 1);
595
- if (line) this.handleMessage(line);
596
- continue;
597
- }
598
- const contentLength = parseInt(lengthMatch[1], 10);
599
- const messageStart = headerEnd + 4;
600
- if (this.buffer.length < messageStart + contentLength) break;
601
- const body = this.buffer.slice(messageStart, messageStart + contentLength);
602
- this.buffer = this.buffer.slice(messageStart + contentLength);
603
- this.handleMessage(body);
505
+ var API_BASE_ENV = "NOTCH_API_URL";
506
+ var DEFAULT_API_BASE = "https://freesyntax.dev";
507
+ var AGENT_ID_ENV = "NOTCH_AGENT_ID";
508
+ var ParamsSchema = z2.object({
509
+ action: z2.enum(["create", "edit", "patch", "delete", "toggle", "list"]),
510
+ /**
511
+ * Target agent id. If omitted, falls back to the `NOTCH_AGENT_ID` env var.
512
+ * In Notch Cloud the task-runner sets this env var when the task was
513
+ * created from an agent. For standalone CLI use, pass it explicitly.
514
+ */
515
+ agent_id: z2.string().uuid().optional(),
516
+ /** Required for action=create | edit (optional for edit, keeps existing). */
517
+ name: z2.string().min(1).max(40).optional(),
518
+ label: z2.string().min(1).max(80).optional(),
519
+ category: z2.string().max(40).optional(),
520
+ /** Body of the SKILL.md. Required for create/edit; ignored otherwise. */
521
+ content: z2.string().min(1).max(16 * 1024).optional(),
522
+ /** For patch: appended to the existing body with a `---` separator. */
523
+ appendContent: z2.string().min(1).max(16 * 1024).optional(),
524
+ /** For edit/create/toggle. */
525
+ enabled: z2.boolean().optional(),
526
+ /** For edit/patch/delete/toggle: which existing skill to operate on. */
527
+ skill_id: z2.string().uuid().optional()
528
+ });
529
+ var DESCRIPTION = `Create, edit, patch, delete, toggle, or list durable "skills" (procedural memory) attached to the current agent. Use this when a non-trivial workflow, error-recovery pattern, or learned convention is worth persisting so future sessions inherit it. All writes are scanned server-side; prompt-override/secret/exfil patterns are rejected.
530
+
531
+ Actions:
532
+ - create: new skill from scratch. Requires name, label, content. Optional category.
533
+ - edit: full replace. Requires skill_id + content. Other fields optional.
534
+ - patch: append to existing. Requires skill_id + appendContent.
535
+ - delete: remove. Requires skill_id.
536
+ - toggle: enable/disable without rewriting. Requires skill_id + enabled.
537
+ - list: enumerate the agent's current skills.
538
+
539
+ SKILL.md body should be terse, imperative, and action-oriented. Include triggers ("when X") and the procedure ("then Y"). Avoid prose.`;
540
+ var skillManageTool = {
541
+ name: "skill_manage",
542
+ description: DESCRIPTION,
543
+ parameters: ParamsSchema,
544
+ execute: async (params, ctx) => {
545
+ const creds = await loadCredentials();
546
+ if (!creds) {
547
+ return {
548
+ isError: true,
549
+ content: "Not authenticated. Run `notch login` first so skills can be persisted to your workspace."
550
+ };
604
551
  }
605
- }
606
- handleMessage(raw) {
607
- try {
608
- const msg = JSON.parse(raw);
609
- if (msg.id !== void 0 && this.pendingRequests.has(msg.id)) {
610
- const pending = this.pendingRequests.get(msg.id);
611
- this.pendingRequests.delete(msg.id);
612
- if (msg.error) {
613
- pending.reject(new Error(`MCP error: ${msg.error.message}`));
614
- } else {
615
- pending.resolve(msg.result);
616
- }
617
- }
618
- } catch {
552
+ const agentId = params.agent_id ?? process.env[AGENT_ID_ENV];
553
+ if (!agentId) {
554
+ return {
555
+ isError: true,
556
+ content: `No agent id available. Pass agent_id explicitly, or ensure the task-runner set ${AGENT_ID_ENV}. For standalone CLI use there is no agent to attach skills to \u2014 use a ~/.notch/skills/ local file instead.`
557
+ };
619
558
  }
620
- }
621
- };
622
-
623
- // src/mcp/http-transport.ts
624
- var HttpTransport = class {
625
- requestId = 0;
626
- connected = false;
627
- name;
628
- baseUrl;
629
- headers;
630
- constructor(config, name) {
631
- this.name = name;
632
- if (!config.url) {
633
- throw new Error(`HTTP transport requires 'url' in config for server ${name}`);
559
+ const base = process.env[API_BASE_ENV] ?? DEFAULT_API_BASE;
560
+ const url = `${base.replace(/\/+$/, "")}/api/agents/${agentId}/skills/manage`;
561
+ const body = buildRequestBody(params);
562
+ if (!body) {
563
+ return {
564
+ isError: true,
565
+ content: `Action "${params.action}" is missing required fields. Check the parameter docs.`
566
+ };
634
567
  }
635
- this.baseUrl = config.url.replace(/\/$/, "");
636
- this.headers = {
637
- "Content-Type": "application/json",
638
- ...config.headers
639
- };
640
- }
641
- async connect() {
642
568
  try {
643
- const response = await fetch(`${this.baseUrl}`, {
569
+ const res = await fetch(url, {
644
570
  method: "POST",
645
- headers: this.headers,
646
- body: JSON.stringify({
647
- jsonrpc: "2.0",
648
- id: ++this.requestId,
649
- method: "initialize",
650
- params: {
651
- protocolVersion: "2024-11-05",
652
- capabilities: {},
653
- clientInfo: { name: "notch-cli", version: "0.4.8" }
654
- }
655
- }),
656
- signal: AbortSignal.timeout(15e3)
571
+ headers: {
572
+ Authorization: `Bearer ${creds.token}`,
573
+ "Content-Type": "application/json",
574
+ "User-Agent": "notch-cli/skill_manage"
575
+ },
576
+ body: JSON.stringify(body)
657
577
  });
658
- if (!response.ok) {
659
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
578
+ const text = await res.text();
579
+ let parsed = {};
580
+ try {
581
+ parsed = JSON.parse(text);
582
+ } catch {
660
583
  }
661
- this.sendNotification("notifications/initialized", {});
662
- this.connected = true;
584
+ if (!res.ok) {
585
+ const summary = parsed["summary"] ?? String(parsed["error"] ?? res.statusText);
586
+ ctx.log?.(`[skill_manage] ${params.action} \u2192 HTTP ${res.status}: ${summary}`);
587
+ const findings = Array.isArray(parsed["findings"]) ? parsed["findings"] : [];
588
+ return {
589
+ isError: true,
590
+ content: JSON.stringify({
591
+ ok: false,
592
+ status: res.status,
593
+ action: params.action,
594
+ summary,
595
+ findings
596
+ }, null, 2)
597
+ };
598
+ }
599
+ ctx.log?.(`[skill_manage] ${params.action} ok`);
600
+ return {
601
+ content: typeof text === "string" && text.length > 0 ? text : JSON.stringify({ ok: true })
602
+ };
663
603
  } catch (err) {
664
- throw new Error(`MCP HTTP server ${this.name} unreachable: ${err.message}`);
604
+ const message = err instanceof Error ? err.message : String(err);
605
+ return {
606
+ isError: true,
607
+ content: `Network error calling skill_manage: ${message}`
608
+ };
665
609
  }
666
610
  }
667
- disconnect() {
668
- this.connected = false;
669
- }
670
- get isAlive() {
671
- return this.connected;
672
- }
673
- async sendRequest(method, params) {
674
- const id = ++this.requestId;
675
- const body = { jsonrpc: "2.0", id, method, params };
676
- const response = await fetch(this.baseUrl, {
677
- method: "POST",
678
- headers: this.headers,
679
- body: JSON.stringify(body),
680
- signal: AbortSignal.timeout(3e4)
681
- });
682
- if (!response.ok) {
683
- throw new Error(`MCP HTTP error (${this.name}): ${response.status} ${response.statusText}`);
611
+ };
612
+ function buildRequestBody(p) {
613
+ switch (p.action) {
614
+ case "create": {
615
+ if (!p.name || !p.label || !p.content) return null;
616
+ return {
617
+ action: "create",
618
+ name: p.name,
619
+ label: p.label,
620
+ category: p.category ?? null,
621
+ content: p.content,
622
+ enabled: p.enabled ?? true
623
+ };
684
624
  }
685
- const json = await response.json();
686
- if (json.error) {
687
- throw new Error(`MCP error (${this.name}): ${json.error.message}`);
625
+ case "edit": {
626
+ if (!p.skill_id || !p.content) return null;
627
+ return {
628
+ action: "edit",
629
+ skillId: p.skill_id,
630
+ name: p.name,
631
+ label: p.label,
632
+ category: p.category ?? null,
633
+ content: p.content,
634
+ enabled: p.enabled
635
+ };
688
636
  }
689
- return json.result;
690
- }
691
- sendNotification(method, params) {
692
- void fetch(this.baseUrl, {
693
- method: "POST",
694
- headers: this.headers,
695
- body: JSON.stringify({ jsonrpc: "2.0", method, params }),
696
- signal: AbortSignal.timeout(1e4)
697
- }).catch(() => {
698
- });
699
- }
700
- };
701
-
702
- // src/mcp/sse-transport.ts
703
- var SSETransport = class {
704
- requestId = 0;
705
- pendingRequests = /* @__PURE__ */ new Map();
706
- abortController = null;
707
- connected = false;
708
- name;
709
- baseUrl;
710
- messageEndpoint;
711
- sseEndpoint;
712
- headers;
713
- constructor(config, name) {
714
- this.name = name;
715
- if (!config.url) {
716
- throw new Error(`SSE transport requires 'url' in config for server ${name}`);
637
+ case "patch": {
638
+ if (!p.skill_id || !p.appendContent) return null;
639
+ return {
640
+ action: "patch",
641
+ skillId: p.skill_id,
642
+ appendContent: p.appendContent
643
+ };
717
644
  }
718
- this.baseUrl = config.url.replace(/\/$/, "");
719
- this.sseEndpoint = `${this.baseUrl}/sse`;
720
- this.messageEndpoint = `${this.baseUrl}/message`;
721
- this.headers = {
722
- "Content-Type": "application/json",
723
- ...config.headers
724
- };
725
- }
726
- async connect() {
727
- this.abortController = new AbortController();
728
- const ssePromise = this.startSSEListener();
729
- await new Promise((resolve) => setTimeout(resolve, 500));
730
- const initResult = await this.sendRequest("initialize", {
731
- protocolVersion: "2024-11-05",
732
- capabilities: {},
733
- clientInfo: { name: "notch-cli", version: "0.4.8" }
734
- });
735
- if (!initResult) {
736
- throw new Error(`MCP SSE server ${this.name} failed to initialize`);
645
+ case "delete": {
646
+ if (!p.skill_id) return null;
647
+ return { action: "delete", skillId: p.skill_id };
737
648
  }
738
- this.sendNotification("notifications/initialized", {});
739
- this.connected = true;
740
- ssePromise.catch(() => {
741
- this.connected = false;
742
- });
743
- }
744
- disconnect() {
745
- this.abortController?.abort();
746
- this.abortController = null;
747
- this.connected = false;
748
- for (const [id, pending] of this.pendingRequests) {
749
- pending.reject(new Error(`MCP SSE transport disconnected`));
750
- this.pendingRequests.delete(id);
649
+ case "toggle": {
650
+ if (!p.skill_id || typeof p.enabled !== "boolean") return null;
651
+ return { action: "toggle", skillId: p.skill_id, enabled: p.enabled };
751
652
  }
653
+ case "list":
654
+ return { action: "list" };
752
655
  }
753
- get isAlive() {
754
- return this.connected;
755
- }
756
- async sendRequest(method, params) {
757
- const id = ++this.requestId;
758
- const body = { jsonrpc: "2.0", id, method, params };
759
- const resultPromise = new Promise((resolve, reject) => {
760
- this.pendingRequests.set(id, { resolve, reject });
761
- setTimeout(() => {
762
- if (this.pendingRequests.has(id)) {
763
- this.pendingRequests.delete(id);
764
- reject(new Error(`MCP SSE request ${method} timed out`));
765
- }
766
- }, 3e4);
767
- });
768
- const response = await fetch(this.messageEndpoint, {
769
- method: "POST",
770
- headers: this.headers,
771
- body: JSON.stringify(body),
772
- signal: AbortSignal.timeout(1e4)
773
- });
774
- if (!response.ok) {
775
- this.pendingRequests.delete(id);
776
- throw new Error(`MCP SSE POST error (${this.name}): ${response.status} ${response.statusText}`);
656
+ }
657
+
658
+ // src/tools/events.ts
659
+ import { z as z3 } from "zod";
660
+
661
+ // src/permissions/handlers/bash-classifier.ts
662
+ var DESTRUCTIVE_PATTERNS = [
663
+ /\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*|--recursive|--force)/i,
664
+ /\brm\s+-rf?\s+\//i,
665
+ /\bsudo\s+rm\b/i,
666
+ /\bdd\s+if=/i,
667
+ /\bmkfs\./i,
668
+ /\bshred\b/i,
669
+ /\bchmod\s+-R\s+/i,
670
+ /\bchown\s+-R\s+/i,
671
+ /:\(\)\s*\{\s*:\|:&\s*\};:/,
672
+ // fork bomb
673
+ /\b>\s*\/dev\/sd[a-z]/i,
674
+ /\bgit\s+push\s+(?:.*\s)?(?:-f|--force|--force-with-lease)\b/i,
675
+ /\bgit\s+reset\s+--hard\b/i,
676
+ /\bgit\s+clean\s+-[a-zA-Z]*[fd]/i,
677
+ /\bgit\s+checkout\s+--\s+\./,
678
+ /\bnpm\s+publish\b/i,
679
+ /\byarn\s+publish\b/i,
680
+ /\bpnpm\s+publish\b/i,
681
+ /\bdocker\s+(?:rm|rmi|system\s+prune|volume\s+rm)\b/i,
682
+ /\bkubectl\s+delete\b/i,
683
+ /\bterraform\s+destroy\b/i,
684
+ /\bmv\s+\/\s+/i,
685
+ /\bfind\s+.*\s-delete\b/i,
686
+ /\bfind\s+.*-exec\s+rm\b/i,
687
+ /\btruncate\s+-s\s+0/i,
688
+ /\b(poweroff|shutdown|reboot|halt)\b/i,
689
+ /\bkillall?\s+-9/i
690
+ ];
691
+ var NETWORK_PATTERNS = [
692
+ /\bcurl\b/i,
693
+ /\bwget\b/i,
694
+ /\bhttpie?\b/i,
695
+ /\bnc\b/i,
696
+ /\bnetcat\b/i,
697
+ /\bssh\b/i,
698
+ /\bscp\b/i,
699
+ /\brsync\s+.*::?/i,
700
+ /\bftp\b/i,
701
+ /\bsftp\b/i,
702
+ /\btelnet\b/i,
703
+ /\bnmap\b/i,
704
+ /\bping\b/i,
705
+ /\bdig\b/i,
706
+ /\bnslookup\b/i,
707
+ /\bhost\s+[a-z0-9.-]+\.[a-z]+/i,
708
+ /\bgit\s+(?:clone|fetch|pull|push)\b/i,
709
+ /\bnpm\s+(?:install|i|update|audit|fund)\b/i,
710
+ /\bpip\s+install\b/i,
711
+ /\bapt(?:-get)?\s+(?:install|update|upgrade)\b/i,
712
+ /\bbrew\s+(?:install|update|upgrade)\b/i,
713
+ /\b(?:python|python3|node|bun)\s+-c\s+.*(?:urllib|requests|fetch|http)/i,
714
+ /\bcurl.*\|\s*(?:sh|bash|zsh|fish)\b/i,
715
+ // explicit "pipe to shell"
716
+ /\bwget.*\|\s*(?:sh|bash|zsh|fish)\b/i
717
+ ];
718
+ var SAFE_PATTERNS = [
719
+ /^\s*ls(\s|$)/i,
720
+ /^\s*pwd(\s|$)/,
721
+ /^\s*whoami(\s|$)/,
722
+ /^\s*id(\s|$)/,
723
+ /^\s*echo\s/i,
724
+ /^\s*printf\s/i,
725
+ /^\s*cat\s/i,
726
+ /^\s*head\s/i,
727
+ /^\s*tail\s/i,
728
+ /^\s*wc\s/i,
729
+ /^\s*file\s/i,
730
+ /^\s*stat\s/i,
731
+ /^\s*du\s/i,
732
+ /^\s*df\s/i,
733
+ /^\s*which\s/i,
734
+ /^\s*type\s/i,
735
+ /^\s*env(\s|$)/i,
736
+ /^\s*date(\s|$)/i,
737
+ /^\s*uname/i,
738
+ /^\s*hostname(\s|$)/i,
739
+ /^\s*uptime(\s|$)/i,
740
+ /^\s*history(\s|$)/i,
741
+ /^\s*git\s+(status|log|diff|show|branch|blame|config\s+--get|remote\s+-v|stash\s+list|tag\s+-l|ls-files)\b/i,
742
+ /^\s*node\s+--version/i,
743
+ /^\s*npm\s+(ls|list|outdated|view|config\s+get|--version|-v|root)\b/i,
744
+ /^\s*yarn\s+(list|--version)\b/i,
745
+ /^\s*(python|python3)\s+--version/i,
746
+ /^\s*pip\s+(list|show|--version)\b/i,
747
+ /^\s*(rg|ripgrep)\s/i,
748
+ /^\s*grep\s/i,
749
+ /^\s*find\s+[^|;&]*(?<!-delete)\s*$/i,
750
+ // find without -delete/-exec rm
751
+ /^\s*tree(\s|$)/i,
752
+ /^\s*jq\s/i,
753
+ /^\s*awk\s/i,
754
+ /^\s*sed\s+-n\s/i,
755
+ // sed in print-only mode
756
+ /^\s*(make|cargo|go|npm|bun|pnpm|yarn)\s+(test|check|vet|fmt\s+--check|build\s+--dry-run)\b/i,
757
+ /^\s*(cargo|rustc)\s+--version/i,
758
+ /^\s*docker\s+(ps|images|logs|inspect|version)\b/i,
759
+ /^\s*kubectl\s+(get|describe|logs|version|config\s+current-context)\b/i
760
+ ];
761
+ function classifyBashCommand(command) {
762
+ const cmd = command.trim();
763
+ if (!cmd) return { category: "safe" };
764
+ const head = cmd.split(/[\s;|&]/)[0] ?? "";
765
+ for (const p of DESTRUCTIVE_PATTERNS) {
766
+ if (p.test(cmd)) {
767
+ return { category: "destructive", matchedPattern: p.source, head };
777
768
  }
778
- return resultPromise;
779
- }
780
- sendNotification(method, params) {
781
- const body = { jsonrpc: "2.0", method, params };
782
- void fetch(this.messageEndpoint, {
783
- method: "POST",
784
- headers: this.headers,
785
- body: JSON.stringify(body),
786
- signal: AbortSignal.timeout(1e4)
787
- }).catch(() => {
788
- });
789
769
  }
790
- /**
791
- * Start listening for Server-Sent Events.
792
- * Parses the SSE stream and resolves pending requests.
793
- */
794
- async startSSEListener() {
795
- const response = await fetch(this.sseEndpoint, {
796
- headers: {
797
- Accept: "text/event-stream",
798
- ...this.headers
799
- },
800
- signal: this.abortController?.signal
801
- });
802
- if (!response.ok || !response.body) {
803
- throw new Error(`SSE connection failed: ${response.status}`);
804
- }
805
- const reader = response.body.getReader();
806
- const decoder = new TextDecoder();
807
- let buffer = "";
808
- try {
809
- while (true) {
810
- const { done, value } = await reader.read();
811
- if (done) break;
812
- buffer += decoder.decode(value, { stream: true });
813
- const events = buffer.split("\n\n");
814
- buffer = events.pop() ?? "";
815
- for (const event of events) {
816
- const lines = event.split("\n");
817
- let data = "";
818
- for (const line of lines) {
819
- if (line.startsWith("data: ")) {
820
- data += line.slice(6);
821
- }
822
- }
823
- if (data) {
824
- this.handleSSEMessage(data);
825
- }
826
- }
827
- }
828
- } catch (err) {
829
- if (err.name !== "AbortError") {
830
- this.connected = false;
831
- }
770
+ for (const p of NETWORK_PATTERNS) {
771
+ if (p.test(cmd)) {
772
+ return { category: "network", matchedPattern: p.source, head };
832
773
  }
833
774
  }
834
- handleSSEMessage(raw) {
835
- try {
836
- const msg = JSON.parse(raw);
837
- if (msg.id !== void 0 && this.pendingRequests.has(msg.id)) {
838
- const pending = this.pendingRequests.get(msg.id);
839
- this.pendingRequests.delete(msg.id);
840
- if (msg.error) {
841
- pending.reject(new Error(`MCP SSE error: ${msg.error.message}`));
842
- } else {
843
- pending.resolve(msg.result);
844
- }
845
- }
846
- } catch {
775
+ for (const p of SAFE_PATTERNS) {
776
+ if (p.test(cmd)) {
777
+ return { category: "safe", matchedPattern: p.source, head };
847
778
  }
848
779
  }
849
- };
780
+ return { category: "unknown", head };
781
+ }
850
782
 
851
- // src/mcp/client.ts
852
- function createTransport(config, name) {
853
- const type = detectTransport(config);
854
- switch (type) {
855
- case "http":
856
- return new HttpTransport(config, name);
857
- case "sse":
858
- return new SSETransport(config, name);
859
- case "stdio":
860
- default:
861
- return new StdioTransport(config, name);
783
+ // src/permissions/handlers/interactive.ts
784
+ var SESSION_RECENT_MS = 3e4;
785
+ var recentApprovals = /* @__PURE__ */ new Map();
786
+ function fingerprintArgs(args) {
787
+ const keys = Object.keys(args).sort();
788
+ const parts = [];
789
+ for (const k of keys) {
790
+ const v = args[k];
791
+ const s = typeof v === "string" ? v : JSON.stringify(v);
792
+ parts.push(`${k}=${s.slice(0, 200)}`);
862
793
  }
794
+ return parts.join("|");
863
795
  }
864
- var MCPClient = class {
865
- transport;
866
- serverName;
867
- _tools = [];
868
- constructor(config, serverName) {
869
- this.serverName = serverName;
870
- this.transport = createTransport(config, serverName);
871
- }
872
- /**
873
- * Start the MCP server and initialize the connection.
874
- */
875
- async connect() {
876
- await this.transport.connect();
877
- await this.transport.sendRequest("initialize", {
878
- protocolVersion: "2024-11-05",
879
- capabilities: {},
880
- clientInfo: { name: "notch-cli", version: "0.4.8" }
881
- });
882
- this.transport.sendNotification("notifications/initialized", {});
883
- const result = await this.transport.sendRequest("tools/list", {});
884
- this._tools = result.tools ?? [];
885
- }
886
- /**
887
- * Get discovered tools from this server.
888
- */
889
- get tools() {
890
- return this._tools;
891
- }
892
- /**
893
- * Check if the MCP server is still alive.
894
- */
895
- get isAlive() {
896
- return this.transport.isAlive;
796
+ function makeKey(sessionId, toolName, args) {
797
+ return `${sessionId}\0${toolName}\0${fingerprintArgs(args)}`;
798
+ }
799
+ function recordInteractiveApproval(sessionId, toolName, args) {
800
+ const key = makeKey(sessionId, toolName, args);
801
+ recentApprovals.set(key, { key, expires: Date.now() + SESSION_RECENT_MS });
802
+ }
803
+ function wasRecentlyApproved(sessionId, toolName, args) {
804
+ const key = makeKey(sessionId, toolName, args);
805
+ const hit = recentApprovals.get(key);
806
+ if (!hit) return false;
807
+ if (hit.expires < Date.now()) {
808
+ recentApprovals.delete(key);
809
+ return false;
897
810
  }
898
- /**
899
- * Call a tool on the MCP server. Auto-reconnects if the server has crashed.
900
- */
901
- async callTool(name, args) {
902
- if (!this.isAlive) {
903
- try {
904
- await this.transport.connect();
905
- } catch (err) {
906
- throw new Error(`MCP server ${this.serverName} is down and could not reconnect: ${err.message}`);
811
+ return true;
812
+ }
813
+ var interactiveHandler = {
814
+ surface: "interactive",
815
+ check: async (toolName, args, baseDecision, ctx) => {
816
+ if (baseDecision === "allow") {
817
+ if (toolName === "shell" && typeof args.command === "string") {
818
+ const pending = async () => {
819
+ const cls = classifyBashCommand(args.command);
820
+ if (cls.category === "destructive") {
821
+ return {
822
+ outcome: "prompt",
823
+ reason: `classifier flagged destructive command: ${cls.matchedPattern}`
824
+ };
825
+ }
826
+ return { outcome: "allow" };
827
+ };
828
+ return { outcome: "allow", pendingClassifierCheck: pending };
907
829
  }
830
+ return { outcome: "allow" };
908
831
  }
909
- return this.transport.sendRequest("tools/call", { name, arguments: args });
910
- }
911
- /**
912
- * Disconnect from the MCP server.
913
- */
914
- disconnect() {
915
- this.transport.disconnect();
832
+ if (baseDecision === "deny") {
833
+ return { outcome: "deny", reason: "denied by permission config" };
834
+ }
835
+ if (wasRecentlyApproved(ctx.sessionId, toolName, args)) {
836
+ return {
837
+ outcome: "allow",
838
+ silent: true,
839
+ reason: "session-recent approval (within 30s)"
840
+ };
841
+ }
842
+ return { outcome: "prompt" };
916
843
  }
917
844
  };
918
- function mcpToolsToNotch(client, serverName) {
919
- return client.tools.map((toolDef) => {
920
- const params = z2.record(z2.unknown()).describe(
921
- toolDef.description || `MCP tool from ${serverName}`
922
- );
923
- const notchTool = {
924
- name: `mcp_${serverName}_${toolDef.name}`,
925
- description: `[MCP/${serverName}] ${toolDef.description}`,
926
- parameters: params,
927
- async execute(args, _ctx) {
928
- try {
929
- const result = await client.callTool(toolDef.name, args);
930
- const mcpResult = result;
931
- if (mcpResult?.content && Array.isArray(mcpResult.content)) {
932
- const text = mcpResult.content.filter((c) => c.type === "text").map((c) => c.text).join("\n");
933
- return { content: text || JSON.stringify(result) };
934
- }
935
- return { content: typeof result === "string" ? result : JSON.stringify(result, null, 2) };
936
- } catch (err) {
937
- return { content: `MCP error (${serverName}/${toolDef.name}): ${err.message}`, isError: true };
938
- }
939
- }
940
- };
941
- return notchTool;
942
- });
943
- }
944
- function parseMCPConfig(config) {
945
- const servers = config?.mcpServers;
946
- if (!servers || typeof servers !== "object") return {};
947
- const result = {};
948
- for (const [name, cfg] of Object.entries(servers)) {
949
- const c = cfg;
950
- if (c?.command || c?.url) {
951
- result[name] = {
952
- command: c.command,
953
- args: c.args,
954
- env: c.env,
955
- cwd: c.cwd,
956
- transport: c.transport,
957
- url: c.url,
958
- headers: c.headers
845
+
846
+ // src/permissions/handlers/one-shot.ts
847
+ var oneShotHandler = {
848
+ surface: "one-shot",
849
+ check: async (toolName, _args, baseDecision, ctx) => {
850
+ if (baseDecision === "allow") return { outcome: "allow" };
851
+ if (baseDecision === "deny") {
852
+ return { outcome: "deny", reason: "denied by permission config" };
853
+ }
854
+ if (ctx.autoConfirm) {
855
+ return {
856
+ outcome: "allow",
857
+ silent: true,
858
+ reason: "--yes flag set; auto-confirming one-shot"
959
859
  };
960
860
  }
861
+ const count = (ctx.denialCounter.get(toolName) ?? 0) + 1;
862
+ ctx.denialCounter.set(toolName, count);
863
+ return {
864
+ outcome: "deny",
865
+ silent: true,
866
+ reason: "one-shot mode denies prompt-level tools without --yes",
867
+ tailNotification: `one-shot: denied ${toolName} (pass --yes to auto-approve)`
868
+ };
961
869
  }
962
- return result;
963
- }
964
-
965
- // src/safety/guardian.ts
966
- import fs from "fs/promises";
967
- import { fileURLToPath } from "url";
968
- import path from "path";
969
- import { generateText } from "ai";
970
- import { z as z3 } from "zod";
971
-
972
- // src/safety/guardian-policy.ts
973
- var GUARDIAN_POLICY_EMBEDDED = `# Notch Guardian Risk Policy
974
-
975
- You are the Notch Guardian \u2014 an independent second-opinion safety reviewer. You do
976
- not write code, answer the user's question, or plan the task. Your only job is to
977
- score a proposed tool call against the taxonomy below and return a JSON verdict.
978
-
979
- Assume the caller is a trusted developer working on their own machine. Do not
980
- inflate severity because the action is "large", "long-running", or touches paths
981
- outside a writable workspace root \u2014 those facts alone are not risk signals. Focus
982
- on evidence of **actual** data exfiltration, credential theft, persistent security
983
- weakening, or irreversible damage.
984
-
985
- ## Environment Assumptions
986
-
987
- - Single-developer laptop or CI runner. No multi-tenant blast radius.
988
- - The user's own project files, \`.git\`, \`node_modules\`, build outputs, and
989
- \`~/.notch\` are **local** and non-sensitive by default.
990
- - Trusted network destinations: \`github.com\`, \`gitlab.com\`, \`npmjs.com\`,
991
- \`pypi.org\`, \`crates.io\`, \`modal.run\`, the active Notch model endpoints, any
992
- host mentioned in \`package.json\` / \`.gitmodules\` / committed config, and
993
- \`localhost\` / \`127.0.0.1\` / \`0.0.0.0\` on any port.
994
- - Untrusted by default: pastebins, webhook catchers, URL shorteners,
995
- transfer.sh, termbin, \`0x0.st\`, \`ix.io\`, arbitrary raw IPs, \`*.onion\`, and
996
- any domain whose sole purpose is receiving posted data.
997
- - \`git push\` to a remote already listed in \`git remote -v\` is trusted. A push
998
- to a brand-new remote URL introduced in the same command is not.
999
-
1000
- ## Risk Taxonomy
1001
-
1002
- ### 1. Data Exfiltration
870
+ };
1003
871
 
1004
- Private workspace content, environment variables, secrets, or org data being
1005
- sent to an untrusted destination.
872
+ // src/permissions/handlers/coordinator.ts
873
+ var COORDINATOR_ALLOWED = /* @__PURE__ */ new Set([
874
+ "agent_spawn",
875
+ "agent_send_message",
876
+ "agent_stop",
877
+ "read",
878
+ "grep",
879
+ "glob"
880
+ ]);
881
+ var coordinatorHandler = {
882
+ surface: "coordinator",
883
+ check: async (toolName, _args, baseDecision, _ctx) => {
884
+ if (COORDINATOR_ALLOWED.has(toolName)) {
885
+ if (baseDecision === "deny") {
886
+ return { outcome: "deny", reason: "coordinator tool explicitly denied by config" };
887
+ }
888
+ return { outcome: "allow", silent: true };
889
+ }
890
+ if (baseDecision === "deny") {
891
+ return { outcome: "deny", reason: "denied by permission config" };
892
+ }
893
+ return {
894
+ outcome: "deny",
895
+ silent: true,
896
+ reason: "Coordinator must delegate write operations to a worker.",
897
+ tailNotification: `coordinator: blocked ${toolName} (delegate to worker)`
898
+ };
899
+ }
900
+ };
1006
901
 
1007
- - **low** \u2014 reading public docs, fetching a package from a package registry,
1008
- curl to a documented API that the user's code already talks to, uploading an
1009
- artifact to the user's own cloud bucket named in committed config, \`gh api\`
1010
- calls against the user's own repos, \`git push\` to an existing tracked
1011
- remote, telemetry the user explicitly opted into.
1012
- - **medium** \u2014 uploading a build artifact to a first-party storage service not
1013
- yet in committed config, opening a tunnel (\`ngrok\`, \`cloudflared\`) that only
1014
- exposes a specific local port the user asked about, \`rsync\` to a host listed
1015
- in \`~/.ssh/config\`.
1016
- - **high** \u2014 \`curl -X POST\` with \`@file\` from inside the repo to a domain not
1017
- in the trusted list, piping \`cat ~/.aws/credentials\` or \`.env\` through any
1018
- network command, \`scp\`/\`rsync\` of the whole repo to a fresh remote, posting
1019
- stdout/stderr containing private paths to a pastebin, base64-encoding a
1020
- secret and shipping it as a query string.
1021
- - **critical** \u2014 exfiltrating \`.ssh/id_*\`, \`.aws/credentials\`, \`.npmrc\` with
1022
- \`_authToken\`, \`.pypirc\`, browser cookie databases, keychains, or any
1023
- SSO/OAuth refresh token to **any** external destination, including "trusted"
1024
- ones. Shipping \`.env*\` files to pastebins or attacker-controlled webhooks.
1025
- Mass-uploading \`**/*.pem\`, \`**/*.key\`, \`**/id_rsa*\` anywhere off-host.
902
+ // src/permissions/handlers/subagent.ts
903
+ var subagentHandler = {
904
+ surface: "subagent",
905
+ check: async (toolName, _args, baseDecision, ctx) => {
906
+ if (baseDecision === "allow") return { outcome: "allow" };
907
+ if (baseDecision === "deny") {
908
+ return { outcome: "deny", reason: "denied by permission config" };
909
+ }
910
+ const count = (ctx.denialCounter.get(toolName) ?? 0) + 1;
911
+ ctx.denialCounter.set(toolName, count);
912
+ const id = ctx.subagentId ?? "unknown";
913
+ return {
914
+ outcome: "deny",
915
+ silent: true,
916
+ reason: "subagent has no interactive UI to prompt",
917
+ tailNotification: `subagent ${id}: denied ${toolName} (no UI to prompt)`
918
+ };
919
+ }
920
+ };
1026
921
 
1027
- Override: sending secrets to a third-party destination is **critical** even if
1028
- the user authorized the tool call broadly \u2014 demand explicit per-destination
1029
- consent.
922
+ // src/permissions/handlers/auto-mode.ts
923
+ var AUTO_IMPLICIT_WRITE_KEY = "__auto_mode_implicit_writes__";
924
+ var AUTO_WRITE_NOTIFY_THRESHOLD = 10;
925
+ var WRITE_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "apply_patch", "shell", "git"]);
926
+ function hardDenyReason(toolName, args) {
927
+ if (toolName === "git") {
928
+ const op = typeof args.operation === "string" ? args.operation : "";
929
+ const flags = typeof args.flags === "string" ? args.flags : "";
930
+ const argsStr = typeof args.args === "string" ? args.args : "";
931
+ const combined = `${op} ${flags} ${argsStr}`.toLowerCase();
932
+ if (combined.includes("push") && /(-f\b|--force\b|--force-with-lease\b)/.test(combined)) {
933
+ return "force-push is never auto-approved";
934
+ }
935
+ if (combined.includes("reset") && combined.includes("--hard")) {
936
+ return "git reset --hard is never auto-approved";
937
+ }
938
+ }
939
+ if (toolName === "shell" && typeof args.command === "string") {
940
+ const cmd = args.command;
941
+ if (/\bnpm\s+publish\b/i.test(cmd) || /\byarn\s+publish\b/i.test(cmd) || /\bpnpm\s+publish\b/i.test(cmd)) {
942
+ return "package publish is never auto-approved";
943
+ }
944
+ if (/\bgit\s+push\b.*\b(?:-f|--force|--force-with-lease)\b/i.test(cmd)) {
945
+ return "force-push is never auto-approved";
946
+ }
947
+ if (/\b(?:rm\s+-rf?|dd\s+if=|mkfs\.|shred)\b/i.test(cmd)) {
948
+ return "classifier-flagged destructive shell command";
949
+ }
950
+ }
951
+ return null;
952
+ }
953
+ var autoModeHandler = {
954
+ surface: "auto-mode",
955
+ check: async (toolName, args, baseDecision, ctx) => {
956
+ if (baseDecision === "deny") {
957
+ return { outcome: "deny", reason: "denied by permission config" };
958
+ }
959
+ const reason = hardDenyReason(toolName, args);
960
+ if (reason) {
961
+ return {
962
+ outcome: "deny",
963
+ silent: false,
964
+ reason,
965
+ tailNotification: `auto-mode: refused ${toolName} \u2014 ${reason}`
966
+ };
967
+ }
968
+ if (toolName === "shell" && typeof args.command === "string") {
969
+ const pending = async () => {
970
+ const cls = classifyBashCommand(args.command);
971
+ if (cls.category === "destructive") {
972
+ return {
973
+ outcome: "deny",
974
+ silent: false,
975
+ reason: `classifier flagged destructive command: ${cls.matchedPattern}`,
976
+ tailNotification: `auto-mode: refused destructive shell command (${cls.head ?? "shell"})`
977
+ };
978
+ }
979
+ return baseDecision === "allow" ? { outcome: "allow", silent: true } : { outcome: "allow", silent: true, reason: "auto-mode implicit approval" };
980
+ };
981
+ if (baseDecision === "allow") return { outcome: "allow", pendingClassifierCheck: pending };
982
+ return { outcome: "allow", silent: true, pendingClassifierCheck: pending };
983
+ }
984
+ if (baseDecision === "allow") return { outcome: "allow" };
985
+ if (WRITE_TOOLS.has(toolName)) {
986
+ const writes = (ctx.denialCounter.get(AUTO_IMPLICIT_WRITE_KEY) ?? 0) + 1;
987
+ ctx.denialCounter.set(AUTO_IMPLICIT_WRITE_KEY, writes);
988
+ if (writes === AUTO_WRITE_NOTIFY_THRESHOLD) {
989
+ return {
990
+ outcome: "allow",
991
+ silent: true,
992
+ reason: "auto-mode implicit approval",
993
+ tailNotification: `auto-mode: ${writes} unattended writes this session \u2014 consider reviewing`
994
+ };
995
+ }
996
+ if (writes > AUTO_WRITE_NOTIFY_THRESHOLD && writes % 10 === 0) {
997
+ return {
998
+ outcome: "allow",
999
+ silent: true,
1000
+ reason: "auto-mode implicit approval",
1001
+ tailNotification: `auto-mode: ${writes} unattended writes this session`
1002
+ };
1003
+ }
1004
+ }
1005
+ return { outcome: "allow", silent: true, reason: "auto-mode implicit approval" };
1006
+ }
1007
+ };
1030
1008
 
1031
- ### 2. Credential Probing
1009
+ // src/permissions/handlers/json-mode.ts
1010
+ var jsonModeHandler = {
1011
+ surface: "json-mode",
1012
+ check: async (toolName, _args, baseDecision, ctx) => {
1013
+ if (baseDecision === "allow") return { outcome: "allow" };
1014
+ if (baseDecision === "deny") {
1015
+ return { outcome: "deny", reason: "denied by permission config" };
1016
+ }
1017
+ const count = (ctx.denialCounter.get(toolName) ?? 0) + 1;
1018
+ ctx.denialCounter.set(toolName, count);
1019
+ const payload = JSON.stringify({
1020
+ type: "permission_denied",
1021
+ tool: toolName,
1022
+ reason: "json mode requires explicit allowlist"
1023
+ });
1024
+ return {
1025
+ outcome: "deny",
1026
+ silent: true,
1027
+ reason: "json mode requires explicit allowlist",
1028
+ tailNotification: payload
1029
+ };
1030
+ }
1031
+ };
1032
1032
 
1033
- Harvesting secrets from places they weren't handed to you.
1033
+ // src/permissions/dispatcher.ts
1034
+ var HANDLERS = {
1035
+ interactive: interactiveHandler,
1036
+ "one-shot": oneShotHandler,
1037
+ coordinator: coordinatorHandler,
1038
+ subagent: subagentHandler,
1039
+ "auto-mode": autoModeHandler,
1040
+ "json-mode": jsonModeHandler
1041
+ };
1042
+ function getActiveHandler(surface) {
1043
+ return HANDLERS[surface];
1044
+ }
1045
+ async function runPermissionCheck(toolName, args, baseDecision, surface, ctx) {
1046
+ const handler = getActiveHandler(surface);
1047
+ const initial = await handler.check(toolName, args, baseDecision, ctx);
1048
+ if (initial.tailNotification) {
1049
+ pushTailNotification(initial.tailNotification);
1050
+ }
1051
+ if (!initial.pendingClassifierCheck) return initial;
1052
+ try {
1053
+ const refined = await initial.pendingClassifierCheck();
1054
+ if (refined.tailNotification) pushTailNotification(refined.tailNotification);
1055
+ return {
1056
+ outcome: refined.outcome,
1057
+ reason: refined.reason ?? initial.reason,
1058
+ silent: refined.silent ?? initial.silent,
1059
+ tailNotification: refined.tailNotification ?? initial.tailNotification
1060
+ };
1061
+ } catch {
1062
+ return {
1063
+ outcome: initial.outcome,
1064
+ reason: initial.reason,
1065
+ silent: initial.silent,
1066
+ tailNotification: initial.tailNotification
1067
+ };
1068
+ }
1069
+ }
1070
+ var tailQueue = [];
1071
+ function pushTailNotification(message) {
1072
+ tailQueue.push(message);
1073
+ }
1074
+ function recordAutoModeDenial(decision) {
1075
+ if (decision.outcome !== "deny") return;
1076
+ const msg = decision.tailNotification ?? decision.reason ?? "auto-mode: tool denied";
1077
+ pushTailNotification(msg);
1078
+ }
1079
+ function drainTailNotifications() {
1080
+ const out = tailQueue.splice(0, tailQueue.length);
1081
+ return out;
1082
+ }
1083
+ var currentSurface = "interactive";
1084
+ function setCurrentSurface(surface) {
1085
+ currentSurface = surface;
1086
+ }
1087
+ function createHandlerContext(options) {
1088
+ return {
1089
+ cwd: options.cwd,
1090
+ sessionId: options.sessionId,
1091
+ denialCounter: /* @__PURE__ */ new Map(),
1092
+ log: options.log,
1093
+ autoConfirm: options.autoConfirm,
1094
+ guardianEnabled: options.guardianEnabled,
1095
+ subagentId: options.subagentId
1096
+ };
1097
+ }
1034
1098
 
1035
- - **low** \u2014 reading \`process.env.SOMETHING\` the user's code already references,
1036
- using a token the user passed in via flag/env, calling \`aws sts
1037
- get-caller-identity\` with the ambient profile, \`gh auth status\`.
1038
- - **medium** \u2014 \`env | grep -i\` for patterns like \`TOKEN|KEY|SECRET\` without a
1039
- clear reason, shelling out to \`security find-generic-password\` on macOS
1040
- keychain for a single named entry.
1041
- - **high** \u2014 recursive scan of \`$HOME\` for \`id_rsa\`, \`credentials\`, \`.env\`,
1042
- \`*.pem\`, \`*.keystore\`; dumping browser cookie/login SQLite DBs (Chrome
1043
- \`Login Data\`, Firefox \`logins.json\`, Safari keychain); reading
1044
- \`/etc/shadow\`, \`/etc/sudoers\`; calling \`lsof -i -nP\` followed by file reads
1045
- on anything that looks like a session; enumerating 1Password/Bitwarden
1046
- vault files on disk.
1047
- - **critical** \u2014 active credential dumping tools (\`mimikatz\`, \`lazagne\`,
1048
- \`SharpChrome\`, \`credhistview\`), decrypting DPAPI blobs, extracting saved
1049
- WiFi passwords with \`netsh wlan show profile key=clear\`, scripting against
1050
- the gnome-keyring / kwallet D-Bus APIs to enumerate all entries.
1099
+ // src/events/router.ts
1100
+ var SEVERITY_RANK = {
1101
+ info: 0,
1102
+ success: 1,
1103
+ warn: 2,
1104
+ error: 3
1105
+ };
1106
+ var EventRouter = class {
1107
+ events = [];
1108
+ capacity;
1109
+ tailNotifyMin;
1110
+ maxAgeMs;
1111
+ filter;
1112
+ subscribers = /* @__PURE__ */ new Set();
1113
+ constructor(opts = {}) {
1114
+ this.capacity = opts.capacity ?? 256;
1115
+ this.tailNotifyMin = opts.tailNotifyMin ?? "warn";
1116
+ this.maxAgeMs = opts.maxAgeMs ?? 6 * 60 * 60 * 1e3;
1117
+ this.filter = opts.filter;
1118
+ }
1119
+ /**
1120
+ * Emit an event. Returns the stored event (with normalized id/ts) or
1121
+ * null if it was dropped by filtering.
1122
+ */
1123
+ emit(partial) {
1124
+ const ts = partial.ts ?? Date.now();
1125
+ const id = partial.id ?? `${partial.source}:${partial.type}:${ts}`;
1126
+ const ev = {
1127
+ id,
1128
+ source: partial.source,
1129
+ type: partial.type,
1130
+ ts,
1131
+ severity: partial.severity,
1132
+ title: partial.title,
1133
+ payload: partial.payload,
1134
+ ackRequired: partial.ackRequired,
1135
+ drained: false
1136
+ };
1137
+ if (!this.passesFilter(ev)) return null;
1138
+ this.pruneOld();
1139
+ const existing = this.events.findIndex((e) => e.id === ev.id && !e.drained);
1140
+ if (existing >= 0) {
1141
+ this.events[existing] = ev;
1142
+ } else {
1143
+ this.events.push(ev);
1144
+ if (this.events.length > this.capacity) {
1145
+ this.events.splice(0, this.events.length - this.capacity);
1146
+ }
1147
+ }
1148
+ if (SEVERITY_RANK[ev.severity] >= SEVERITY_RANK[this.tailNotifyMin]) {
1149
+ try {
1150
+ pushTailNotification(`${ev.source}: ${ev.title}`);
1151
+ } catch {
1152
+ }
1153
+ }
1154
+ for (const sub of this.subscribers) {
1155
+ try {
1156
+ sub(ev);
1157
+ } catch {
1158
+ }
1159
+ }
1160
+ return ev;
1161
+ }
1162
+ /**
1163
+ * Subscribe to future emits. Returns an unsubscribe function.
1164
+ */
1165
+ subscribe(fn) {
1166
+ this.subscribers.add(fn);
1167
+ return () => this.subscribers.delete(fn);
1168
+ }
1169
+ /**
1170
+ * Return all pending (un-drained) events, optionally filtered, newest
1171
+ * first. Does NOT mark them drained — use {@link drain} to ack.
1172
+ */
1173
+ pending(filter) {
1174
+ this.pruneOld();
1175
+ const f = filter ?? this.filter;
1176
+ return this.events.filter((e) => !e.drained).filter((e) => this.eventMatchesFilter(e, f)).sort((a, b) => b.ts - a.ts);
1177
+ }
1178
+ /**
1179
+ * Drain (acknowledge) pending events matching an optional filter.
1180
+ * Returns the set drained — useful when an agent explicitly consumes
1181
+ * queued events as part of a step. Events remain in the ring buffer
1182
+ * marked `drained: true` for audit until aged out.
1183
+ */
1184
+ drain(filter) {
1185
+ const matched = this.pending(filter);
1186
+ const now = Date.now();
1187
+ for (const ev of matched) {
1188
+ ev.drained = true;
1189
+ ev.drainedAt = now;
1190
+ }
1191
+ return matched;
1192
+ }
1193
+ /** All events in the ring buffer, including drained ones. */
1194
+ history() {
1195
+ return [...this.events];
1196
+ }
1197
+ /** Clear everything. Primarily useful in tests. */
1198
+ clear() {
1199
+ this.events = [];
1200
+ }
1201
+ /** Count of un-drained events. */
1202
+ pendingCount() {
1203
+ return this.pending().length;
1204
+ }
1205
+ passesFilter(ev) {
1206
+ if (!this.filter) return true;
1207
+ return this.eventMatchesFilter(ev, this.filter);
1208
+ }
1209
+ eventMatchesFilter(ev, f) {
1210
+ if (!f) return true;
1211
+ if (f.sources && !f.sources.includes(ev.source)) return false;
1212
+ if (f.types && !f.types.includes(ev.type)) return false;
1213
+ if (f.minSeverity && SEVERITY_RANK[ev.severity] < SEVERITY_RANK[f.minSeverity]) return false;
1214
+ return true;
1215
+ }
1216
+ pruneOld() {
1217
+ if (!Number.isFinite(this.maxAgeMs)) return;
1218
+ const cutoff = Date.now() - this.maxAgeMs;
1219
+ this.events = this.events.filter((e) => e.ts >= cutoff);
1220
+ }
1221
+ };
1222
+ var globalRouter = null;
1223
+ function getGlobalRouter() {
1224
+ if (!globalRouter) globalRouter = new EventRouter();
1225
+ return globalRouter;
1226
+ }
1051
1227
 
1052
- Override: reading a single, user-named credential file the task needs (e.g.
1053
- \`cat $HOME/.config/gh/hosts.yml\` when the user asked to configure gh) is
1054
- **low**. Shelling into macOS keychain for a single user-specified item is
1055
- **low**.
1228
+ // src/tools/events.ts
1229
+ var parameters = z3.object({
1230
+ mode: z3.enum(["list", "drain"]).describe("`list` peeks at pending events. `drain` acknowledges + returns them."),
1231
+ sources: z3.array(z3.string()).optional().describe('Only include events from these sources (e.g. ["git", "ci"]).'),
1232
+ types: z3.array(z3.string()).optional().describe('Only include events of these types (e.g. ["new-commit"]).'),
1233
+ min_severity: z3.enum(["info", "success", "warn", "error"]).optional().describe("Only include events at or above this severity."),
1234
+ limit: z3.number().int().min(1).max(50).optional().default(20).describe("Max number of events to return (1-50, default 20).")
1235
+ });
1236
+ function formatEvent(ev) {
1237
+ const when = new Date(ev.ts).toISOString();
1238
+ const sev = ev.severity.toUpperCase().padEnd(7);
1239
+ const title = ev.title.replace(/\n/g, " ").slice(0, 200);
1240
+ const body = ev.payload ? ` \u2014 ${JSON.stringify(ev.payload).slice(0, 180)}` : "";
1241
+ return `[${when}] [${sev}] ${ev.source}/${ev.type}: ${title}${body}`;
1242
+ }
1243
+ var eventsTool = {
1244
+ name: "events",
1245
+ description: "Inspect or consume queued out-of-band events (git state, GitHub notifications, CI, custom webhooks). Use `list` to peek without consuming; use `drain` when you are ready to act on them and want them cleared. Events do NOT enter your conversation context automatically \u2014 you must call this tool to see them.",
1246
+ parameters,
1247
+ async execute(params) {
1248
+ const router = getGlobalRouter();
1249
+ const filter = {
1250
+ sources: params.sources,
1251
+ types: params.types,
1252
+ minSeverity: params.min_severity
1253
+ };
1254
+ const events = params.mode === "drain" ? router.drain(filter) : router.pending(filter);
1255
+ const limited = events.slice(0, params.limit);
1256
+ if (limited.length === 0) {
1257
+ const verb = params.mode === "drain" ? "drained" : "pending";
1258
+ return { content: `No ${verb} events matching filter.` };
1259
+ }
1260
+ const header = params.mode === "drain" ? `Drained ${limited.length} event(s) (cleared from pending queue):` : `${limited.length} pending event(s) (NOT yet acknowledged \u2014 call again with mode=drain to clear):`;
1261
+ const body = limited.map(formatEvent).join("\n");
1262
+ return { content: `${header}
1263
+ ${body}` };
1264
+ }
1265
+ };
1056
1266
 
1057
- ### 3. Persistent Security Weakening
1267
+ // src/mcp/client.ts
1268
+ import { z as z4 } from "zod";
1058
1269
 
1059
- Changes that outlive the session and open future attack surface.
1270
+ // src/mcp/transport.ts
1271
+ function detectTransport(config) {
1272
+ if (config.transport) return config.transport;
1273
+ if (config.url) return "http";
1274
+ return "stdio";
1275
+ }
1060
1276
 
1061
- - **low** \u2014 setting a local git hook for the user's own repo, adding \`127.0.0.1
1062
- myapp.test\` to \`/etc/hosts\`, narrowly scoped temporary cert pin override in a
1063
- dev-only config file.
1064
- - **medium** \u2014 disabling TLS verification for one command (\`curl -k\`, \`git -c
1065
- http.sslVerify=false fetch\`), chmod \`644\` on a previously \`600\` config file,
1066
- turning on a single feature-flag that lowers a check for this session only.
1067
- - **high** \u2014 \`chmod -R 777\` on directories, disabling SELinux / AppArmor /
1277
+ // src/mcp/stdio-transport.ts
1278
+ import { spawn } from "child_process";
1279
+ var StdioTransport = class {
1280
+ constructor(config, name) {
1281
+ this.config = config;
1282
+ this.name = name;
1283
+ }
1284
+ config;
1285
+ process = null;
1286
+ requestId = 0;
1287
+ pendingRequests = /* @__PURE__ */ new Map();
1288
+ buffer = "";
1289
+ name;
1290
+ async connect() {
1291
+ if (!this.config.command) {
1292
+ throw new Error(`Stdio transport requires 'command' in config for server ${this.name}`);
1293
+ }
1294
+ this.process = spawn(this.config.command, this.config.args ?? [], {
1295
+ stdio: ["pipe", "pipe", "pipe"],
1296
+ env: { ...process.env, ...this.config.env },
1297
+ cwd: this.config.cwd
1298
+ });
1299
+ this.process.stdout?.setEncoding("utf-8");
1300
+ this.process.stdout?.on("data", (data) => {
1301
+ this.buffer += data;
1302
+ this.processBuffer();
1303
+ });
1304
+ this.process.on("error", (err) => {
1305
+ for (const [id, pending] of this.pendingRequests) {
1306
+ pending.reject(new Error(`MCP server ${this.name} error: ${err.message}`));
1307
+ this.pendingRequests.delete(id);
1308
+ }
1309
+ });
1310
+ this.process.on("exit", (code) => {
1311
+ for (const [id, pending] of this.pendingRequests) {
1312
+ pending.reject(new Error(`MCP server ${this.name} exited with code ${code}`));
1313
+ this.pendingRequests.delete(id);
1314
+ }
1315
+ });
1316
+ }
1317
+ disconnect() {
1318
+ if (this.process) {
1319
+ this.process.stdin?.end();
1320
+ this.process.kill();
1321
+ this.process = null;
1322
+ }
1323
+ this.pendingRequests.clear();
1324
+ }
1325
+ get isAlive() {
1326
+ return this.process !== null && this.process.exitCode === null && !this.process.killed;
1327
+ }
1328
+ sendRequest(method, params) {
1329
+ return new Promise((resolve, reject) => {
1330
+ const id = ++this.requestId;
1331
+ const msg = { jsonrpc: "2.0", id, method, params };
1332
+ this.pendingRequests.set(id, { resolve, reject });
1333
+ const data = JSON.stringify(msg);
1334
+ const header = `Content-Length: ${Buffer.byteLength(data)}\r
1335
+ \r
1336
+ `;
1337
+ this.process?.stdin?.write(header + data);
1338
+ setTimeout(() => {
1339
+ if (this.pendingRequests.has(id)) {
1340
+ this.pendingRequests.delete(id);
1341
+ reject(new Error(`MCP request ${method} timed out`));
1342
+ }
1343
+ }, 3e4);
1344
+ });
1345
+ }
1346
+ sendNotification(method, params) {
1347
+ const msg = { jsonrpc: "2.0", method, params };
1348
+ const data = JSON.stringify(msg);
1349
+ const header = `Content-Length: ${Buffer.byteLength(data)}\r
1350
+ \r
1351
+ `;
1352
+ this.process?.stdin?.write(header + data);
1353
+ }
1354
+ processBuffer() {
1355
+ while (this.buffer.length > 0) {
1356
+ const headerEnd = this.buffer.indexOf("\r\n\r\n");
1357
+ if (headerEnd === -1) break;
1358
+ const header = this.buffer.slice(0, headerEnd);
1359
+ const lengthMatch = header.match(/Content-Length:\s*(\d+)/i);
1360
+ if (!lengthMatch) {
1361
+ const nlIdx = this.buffer.indexOf("\n");
1362
+ if (nlIdx === -1) break;
1363
+ const line = this.buffer.slice(0, nlIdx).trim();
1364
+ this.buffer = this.buffer.slice(nlIdx + 1);
1365
+ if (line) this.handleMessage(line);
1366
+ continue;
1367
+ }
1368
+ const contentLength = parseInt(lengthMatch[1], 10);
1369
+ const messageStart = headerEnd + 4;
1370
+ if (this.buffer.length < messageStart + contentLength) break;
1371
+ const body = this.buffer.slice(messageStart, messageStart + contentLength);
1372
+ this.buffer = this.buffer.slice(messageStart + contentLength);
1373
+ this.handleMessage(body);
1374
+ }
1375
+ }
1376
+ handleMessage(raw) {
1377
+ try {
1378
+ const msg = JSON.parse(raw);
1379
+ if (msg.id !== void 0 && this.pendingRequests.has(msg.id)) {
1380
+ const pending = this.pendingRequests.get(msg.id);
1381
+ this.pendingRequests.delete(msg.id);
1382
+ if (msg.error) {
1383
+ pending.reject(new Error(`MCP error: ${msg.error.message}`));
1384
+ } else {
1385
+ pending.resolve(msg.result);
1386
+ }
1387
+ }
1388
+ } catch {
1389
+ }
1390
+ }
1391
+ };
1392
+
1393
+ // src/mcp/http-transport.ts
1394
+ var HttpTransport = class {
1395
+ requestId = 0;
1396
+ connected = false;
1397
+ name;
1398
+ baseUrl;
1399
+ headers;
1400
+ constructor(config, name) {
1401
+ this.name = name;
1402
+ if (!config.url) {
1403
+ throw new Error(`HTTP transport requires 'url' in config for server ${name}`);
1404
+ }
1405
+ this.baseUrl = config.url.replace(/\/$/, "");
1406
+ this.headers = {
1407
+ "Content-Type": "application/json",
1408
+ ...config.headers
1409
+ };
1410
+ }
1411
+ async connect() {
1412
+ try {
1413
+ const response = await fetch(`${this.baseUrl}`, {
1414
+ method: "POST",
1415
+ headers: this.headers,
1416
+ body: JSON.stringify({
1417
+ jsonrpc: "2.0",
1418
+ id: ++this.requestId,
1419
+ method: "initialize",
1420
+ params: {
1421
+ protocolVersion: "2024-11-05",
1422
+ capabilities: {},
1423
+ clientInfo: { name: "notch-cli", version: "0.4.8" }
1424
+ }
1425
+ }),
1426
+ signal: AbortSignal.timeout(15e3)
1427
+ });
1428
+ if (!response.ok) {
1429
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1430
+ }
1431
+ this.sendNotification("notifications/initialized", {});
1432
+ this.connected = true;
1433
+ } catch (err) {
1434
+ throw new Error(`MCP HTTP server ${this.name} unreachable: ${err.message}`);
1435
+ }
1436
+ }
1437
+ disconnect() {
1438
+ this.connected = false;
1439
+ }
1440
+ get isAlive() {
1441
+ return this.connected;
1442
+ }
1443
+ async sendRequest(method, params) {
1444
+ const id = ++this.requestId;
1445
+ const body = { jsonrpc: "2.0", id, method, params };
1446
+ const response = await fetch(this.baseUrl, {
1447
+ method: "POST",
1448
+ headers: this.headers,
1449
+ body: JSON.stringify(body),
1450
+ signal: AbortSignal.timeout(3e4)
1451
+ });
1452
+ if (!response.ok) {
1453
+ throw new Error(`MCP HTTP error (${this.name}): ${response.status} ${response.statusText}`);
1454
+ }
1455
+ const json = await response.json();
1456
+ if (json.error) {
1457
+ throw new Error(`MCP error (${this.name}): ${json.error.message}`);
1458
+ }
1459
+ return json.result;
1460
+ }
1461
+ sendNotification(method, params) {
1462
+ void fetch(this.baseUrl, {
1463
+ method: "POST",
1464
+ headers: this.headers,
1465
+ body: JSON.stringify({ jsonrpc: "2.0", method, params }),
1466
+ signal: AbortSignal.timeout(1e4)
1467
+ }).catch(() => {
1468
+ });
1469
+ }
1470
+ };
1471
+
1472
+ // src/mcp/sse-transport.ts
1473
+ var SSETransport = class {
1474
+ requestId = 0;
1475
+ pendingRequests = /* @__PURE__ */ new Map();
1476
+ abortController = null;
1477
+ connected = false;
1478
+ name;
1479
+ baseUrl;
1480
+ messageEndpoint;
1481
+ sseEndpoint;
1482
+ headers;
1483
+ constructor(config, name) {
1484
+ this.name = name;
1485
+ if (!config.url) {
1486
+ throw new Error(`SSE transport requires 'url' in config for server ${name}`);
1487
+ }
1488
+ this.baseUrl = config.url.replace(/\/$/, "");
1489
+ this.sseEndpoint = `${this.baseUrl}/sse`;
1490
+ this.messageEndpoint = `${this.baseUrl}/message`;
1491
+ this.headers = {
1492
+ "Content-Type": "application/json",
1493
+ ...config.headers
1494
+ };
1495
+ }
1496
+ async connect() {
1497
+ this.abortController = new AbortController();
1498
+ const ssePromise = this.startSSEListener();
1499
+ await new Promise((resolve) => setTimeout(resolve, 500));
1500
+ const initResult = await this.sendRequest("initialize", {
1501
+ protocolVersion: "2024-11-05",
1502
+ capabilities: {},
1503
+ clientInfo: { name: "notch-cli", version: "0.4.8" }
1504
+ });
1505
+ if (!initResult) {
1506
+ throw new Error(`MCP SSE server ${this.name} failed to initialize`);
1507
+ }
1508
+ this.sendNotification("notifications/initialized", {});
1509
+ this.connected = true;
1510
+ ssePromise.catch(() => {
1511
+ this.connected = false;
1512
+ });
1513
+ }
1514
+ disconnect() {
1515
+ this.abortController?.abort();
1516
+ this.abortController = null;
1517
+ this.connected = false;
1518
+ for (const [id, pending] of this.pendingRequests) {
1519
+ pending.reject(new Error(`MCP SSE transport disconnected`));
1520
+ this.pendingRequests.delete(id);
1521
+ }
1522
+ }
1523
+ get isAlive() {
1524
+ return this.connected;
1525
+ }
1526
+ async sendRequest(method, params) {
1527
+ const id = ++this.requestId;
1528
+ const body = { jsonrpc: "2.0", id, method, params };
1529
+ const resultPromise = new Promise((resolve, reject) => {
1530
+ this.pendingRequests.set(id, { resolve, reject });
1531
+ setTimeout(() => {
1532
+ if (this.pendingRequests.has(id)) {
1533
+ this.pendingRequests.delete(id);
1534
+ reject(new Error(`MCP SSE request ${method} timed out`));
1535
+ }
1536
+ }, 3e4);
1537
+ });
1538
+ const response = await fetch(this.messageEndpoint, {
1539
+ method: "POST",
1540
+ headers: this.headers,
1541
+ body: JSON.stringify(body),
1542
+ signal: AbortSignal.timeout(1e4)
1543
+ });
1544
+ if (!response.ok) {
1545
+ this.pendingRequests.delete(id);
1546
+ throw new Error(`MCP SSE POST error (${this.name}): ${response.status} ${response.statusText}`);
1547
+ }
1548
+ return resultPromise;
1549
+ }
1550
+ sendNotification(method, params) {
1551
+ const body = { jsonrpc: "2.0", method, params };
1552
+ void fetch(this.messageEndpoint, {
1553
+ method: "POST",
1554
+ headers: this.headers,
1555
+ body: JSON.stringify(body),
1556
+ signal: AbortSignal.timeout(1e4)
1557
+ }).catch(() => {
1558
+ });
1559
+ }
1560
+ /**
1561
+ * Start listening for Server-Sent Events.
1562
+ * Parses the SSE stream and resolves pending requests.
1563
+ */
1564
+ async startSSEListener() {
1565
+ const response = await fetch(this.sseEndpoint, {
1566
+ headers: {
1567
+ Accept: "text/event-stream",
1568
+ ...this.headers
1569
+ },
1570
+ signal: this.abortController?.signal
1571
+ });
1572
+ if (!response.ok || !response.body) {
1573
+ throw new Error(`SSE connection failed: ${response.status}`);
1574
+ }
1575
+ const reader = response.body.getReader();
1576
+ const decoder = new TextDecoder();
1577
+ let buffer = "";
1578
+ try {
1579
+ while (true) {
1580
+ const { done, value } = await reader.read();
1581
+ if (done) break;
1582
+ buffer += decoder.decode(value, { stream: true });
1583
+ const events = buffer.split("\n\n");
1584
+ buffer = events.pop() ?? "";
1585
+ for (const event of events) {
1586
+ const lines = event.split("\n");
1587
+ let data = "";
1588
+ for (const line of lines) {
1589
+ if (line.startsWith("data: ")) {
1590
+ data += line.slice(6);
1591
+ }
1592
+ }
1593
+ if (data) {
1594
+ this.handleSSEMessage(data);
1595
+ }
1596
+ }
1597
+ }
1598
+ } catch (err) {
1599
+ if (err.name !== "AbortError") {
1600
+ this.connected = false;
1601
+ }
1602
+ }
1603
+ }
1604
+ handleSSEMessage(raw) {
1605
+ try {
1606
+ const msg = JSON.parse(raw);
1607
+ if (msg.id !== void 0 && this.pendingRequests.has(msg.id)) {
1608
+ const pending = this.pendingRequests.get(msg.id);
1609
+ this.pendingRequests.delete(msg.id);
1610
+ if (msg.error) {
1611
+ pending.reject(new Error(`MCP SSE error: ${msg.error.message}`));
1612
+ } else {
1613
+ pending.resolve(msg.result);
1614
+ }
1615
+ }
1616
+ } catch {
1617
+ }
1618
+ }
1619
+ };
1620
+
1621
+ // src/mcp/client.ts
1622
+ function createTransport(config, name) {
1623
+ const type = detectTransport(config);
1624
+ switch (type) {
1625
+ case "http":
1626
+ return new HttpTransport(config, name);
1627
+ case "sse":
1628
+ return new SSETransport(config, name);
1629
+ case "stdio":
1630
+ default:
1631
+ return new StdioTransport(config, name);
1632
+ }
1633
+ }
1634
+ var MCPClient = class {
1635
+ transport;
1636
+ serverName;
1637
+ _tools = [];
1638
+ constructor(config, serverName) {
1639
+ this.serverName = serverName;
1640
+ this.transport = createTransport(config, serverName);
1641
+ }
1642
+ /**
1643
+ * Start the MCP server and initialize the connection.
1644
+ */
1645
+ async connect() {
1646
+ await this.transport.connect();
1647
+ await this.transport.sendRequest("initialize", {
1648
+ protocolVersion: "2024-11-05",
1649
+ capabilities: {},
1650
+ clientInfo: { name: "notch-cli", version: "0.4.8" }
1651
+ });
1652
+ this.transport.sendNotification("notifications/initialized", {});
1653
+ const result = await this.transport.sendRequest("tools/list", {});
1654
+ this._tools = result.tools ?? [];
1655
+ }
1656
+ /**
1657
+ * Get discovered tools from this server.
1658
+ */
1659
+ get tools() {
1660
+ return this._tools;
1661
+ }
1662
+ /**
1663
+ * Check if the MCP server is still alive.
1664
+ */
1665
+ get isAlive() {
1666
+ return this.transport.isAlive;
1667
+ }
1668
+ /**
1669
+ * Call a tool on the MCP server. Auto-reconnects if the server has crashed.
1670
+ */
1671
+ async callTool(name, args) {
1672
+ if (!this.isAlive) {
1673
+ try {
1674
+ await this.transport.connect();
1675
+ } catch (err) {
1676
+ throw new Error(`MCP server ${this.serverName} is down and could not reconnect: ${err.message}`);
1677
+ }
1678
+ }
1679
+ return this.transport.sendRequest("tools/call", { name, arguments: args });
1680
+ }
1681
+ /**
1682
+ * Disconnect from the MCP server.
1683
+ */
1684
+ disconnect() {
1685
+ this.transport.disconnect();
1686
+ }
1687
+ };
1688
+ function mcpToolsToNotch(client, serverName) {
1689
+ return client.tools.map((toolDef) => {
1690
+ const params = z4.record(z4.unknown()).describe(
1691
+ toolDef.description || `MCP tool from ${serverName}`
1692
+ );
1693
+ const notchTool = {
1694
+ name: `mcp_${serverName}_${toolDef.name}`,
1695
+ description: `[MCP/${serverName}] ${toolDef.description}`,
1696
+ parameters: params,
1697
+ async execute(args, _ctx) {
1698
+ try {
1699
+ const result = await client.callTool(toolDef.name, args);
1700
+ const mcpResult = result;
1701
+ if (mcpResult?.content && Array.isArray(mcpResult.content)) {
1702
+ const text = mcpResult.content.filter((c) => c.type === "text").map((c) => c.text).join("\n");
1703
+ return { content: text || JSON.stringify(result) };
1704
+ }
1705
+ return { content: typeof result === "string" ? result : JSON.stringify(result, null, 2) };
1706
+ } catch (err) {
1707
+ return { content: `MCP error (${serverName}/${toolDef.name}): ${err.message}`, isError: true };
1708
+ }
1709
+ }
1710
+ };
1711
+ return notchTool;
1712
+ });
1713
+ }
1714
+ function parseMCPConfig(config) {
1715
+ const servers = config?.mcpServers;
1716
+ if (!servers || typeof servers !== "object") return {};
1717
+ const result = {};
1718
+ for (const [name, cfg] of Object.entries(servers)) {
1719
+ const c = cfg;
1720
+ if (c?.command || c?.url) {
1721
+ result[name] = {
1722
+ command: c.command,
1723
+ args: c.args,
1724
+ env: c.env,
1725
+ cwd: c.cwd,
1726
+ transport: c.transport,
1727
+ url: c.url,
1728
+ headers: c.headers
1729
+ };
1730
+ }
1731
+ }
1732
+ return result;
1733
+ }
1734
+
1735
+ // src/safety/guardian.ts
1736
+ import fs from "fs/promises";
1737
+ import { fileURLToPath } from "url";
1738
+ import path from "path";
1739
+ import { generateText } from "ai";
1740
+ import { z as z5 } from "zod";
1741
+
1742
+ // src/safety/guardian-policy.ts
1743
+ var GUARDIAN_POLICY_EMBEDDED = `# Notch Guardian Risk Policy
1744
+
1745
+ You are the Notch Guardian \u2014 an independent second-opinion safety reviewer. You do
1746
+ not write code, answer the user's question, or plan the task. Your only job is to
1747
+ score a proposed tool call against the taxonomy below and return a JSON verdict.
1748
+
1749
+ Assume the caller is a trusted developer working on their own machine. Do not
1750
+ inflate severity because the action is "large", "long-running", or touches paths
1751
+ outside a writable workspace root \u2014 those facts alone are not risk signals. Focus
1752
+ on evidence of **actual** data exfiltration, credential theft, persistent security
1753
+ weakening, or irreversible damage.
1754
+
1755
+ ## Environment Assumptions
1756
+
1757
+ - Single-developer laptop or CI runner. No multi-tenant blast radius.
1758
+ - The user's own project files, \`.git\`, \`node_modules\`, build outputs, and
1759
+ \`~/.notch\` are **local** and non-sensitive by default.
1760
+ - Trusted network destinations: \`github.com\`, \`gitlab.com\`, \`npmjs.com\`,
1761
+ \`pypi.org\`, \`crates.io\`, \`modal.run\`, the active Notch model endpoints, any
1762
+ host mentioned in \`package.json\` / \`.gitmodules\` / committed config, and
1763
+ \`localhost\` / \`127.0.0.1\` / \`0.0.0.0\` on any port.
1764
+ - Untrusted by default: pastebins, webhook catchers, URL shorteners,
1765
+ transfer.sh, termbin, \`0x0.st\`, \`ix.io\`, arbitrary raw IPs, \`*.onion\`, and
1766
+ any domain whose sole purpose is receiving posted data.
1767
+ - \`git push\` to a remote already listed in \`git remote -v\` is trusted. A push
1768
+ to a brand-new remote URL introduced in the same command is not.
1769
+
1770
+ ## Risk Taxonomy
1771
+
1772
+ ### 1. Data Exfiltration
1773
+
1774
+ Private workspace content, environment variables, secrets, or org data being
1775
+ sent to an untrusted destination.
1776
+
1777
+ - **low** \u2014 reading public docs, fetching a package from a package registry,
1778
+ curl to a documented API that the user's code already talks to, uploading an
1779
+ artifact to the user's own cloud bucket named in committed config, \`gh api\`
1780
+ calls against the user's own repos, \`git push\` to an existing tracked
1781
+ remote, telemetry the user explicitly opted into.
1782
+ - **medium** \u2014 uploading a build artifact to a first-party storage service not
1783
+ yet in committed config, opening a tunnel (\`ngrok\`, \`cloudflared\`) that only
1784
+ exposes a specific local port the user asked about, \`rsync\` to a host listed
1785
+ in \`~/.ssh/config\`.
1786
+ - **high** \u2014 \`curl -X POST\` with \`@file\` from inside the repo to a domain not
1787
+ in the trusted list, piping \`cat ~/.aws/credentials\` or \`.env\` through any
1788
+ network command, \`scp\`/\`rsync\` of the whole repo to a fresh remote, posting
1789
+ stdout/stderr containing private paths to a pastebin, base64-encoding a
1790
+ secret and shipping it as a query string.
1791
+ - **critical** \u2014 exfiltrating \`.ssh/id_*\`, \`.aws/credentials\`, \`.npmrc\` with
1792
+ \`_authToken\`, \`.pypirc\`, browser cookie databases, keychains, or any
1793
+ SSO/OAuth refresh token to **any** external destination, including "trusted"
1794
+ ones. Shipping \`.env*\` files to pastebins or attacker-controlled webhooks.
1795
+ Mass-uploading \`**/*.pem\`, \`**/*.key\`, \`**/id_rsa*\` anywhere off-host.
1796
+
1797
+ Override: sending secrets to a third-party destination is **critical** even if
1798
+ the user authorized the tool call broadly \u2014 demand explicit per-destination
1799
+ consent.
1800
+
1801
+ ### 2. Credential Probing
1802
+
1803
+ Harvesting secrets from places they weren't handed to you.
1804
+
1805
+ - **low** \u2014 reading \`process.env.SOMETHING\` the user's code already references,
1806
+ using a token the user passed in via flag/env, calling \`aws sts
1807
+ get-caller-identity\` with the ambient profile, \`gh auth status\`.
1808
+ - **medium** \u2014 \`env | grep -i\` for patterns like \`TOKEN|KEY|SECRET\` without a
1809
+ clear reason, shelling out to \`security find-generic-password\` on macOS
1810
+ keychain for a single named entry.
1811
+ - **high** \u2014 recursive scan of \`$HOME\` for \`id_rsa\`, \`credentials\`, \`.env\`,
1812
+ \`*.pem\`, \`*.keystore\`; dumping browser cookie/login SQLite DBs (Chrome
1813
+ \`Login Data\`, Firefox \`logins.json\`, Safari keychain); reading
1814
+ \`/etc/shadow\`, \`/etc/sudoers\`; calling \`lsof -i -nP\` followed by file reads
1815
+ on anything that looks like a session; enumerating 1Password/Bitwarden
1816
+ vault files on disk.
1817
+ - **critical** \u2014 active credential dumping tools (\`mimikatz\`, \`lazagne\`,
1818
+ \`SharpChrome\`, \`credhistview\`), decrypting DPAPI blobs, extracting saved
1819
+ WiFi passwords with \`netsh wlan show profile key=clear\`, scripting against
1820
+ the gnome-keyring / kwallet D-Bus APIs to enumerate all entries.
1821
+
1822
+ Override: reading a single, user-named credential file the task needs (e.g.
1823
+ \`cat $HOME/.config/gh/hosts.yml\` when the user asked to configure gh) is
1824
+ **low**. Shelling into macOS keychain for a single user-specified item is
1825
+ **low**.
1826
+
1827
+ ### 3. Persistent Security Weakening
1828
+
1829
+ Changes that outlive the session and open future attack surface.
1830
+
1831
+ - **low** \u2014 setting a local git hook for the user's own repo, adding \`127.0.0.1
1832
+ myapp.test\` to \`/etc/hosts\`, narrowly scoped temporary cert pin override in a
1833
+ dev-only config file.
1834
+ - **medium** \u2014 disabling TLS verification for one command (\`curl -k\`, \`git -c
1835
+ http.sslVerify=false fetch\`), chmod \`644\` on a previously \`600\` config file,
1836
+ turning on a single feature-flag that lowers a check for this session only.
1837
+ - **high** \u2014 \`chmod -R 777\` on directories, disabling SELinux / AppArmor /
1068
1838
  Gatekeeper globally, flipping \`insecure_skip_verify: true\` in committed
1069
1839
  infra config, adding a permissive \`AllowUsers *\` to \`sshd_config\`,
1070
1840
  installing a new trusted root CA, committing \`NODE_TLS_REJECT_UNAUTHORIZED=0\`
@@ -1149,12 +1919,12 @@ the human without blocking it.
1149
1919
  `;
1150
1920
 
1151
1921
  // src/safety/guardian.ts
1152
- var AssessmentSchema = z3.object({
1153
- category: z3.string().min(1).max(120),
1154
- severity: z3.enum(["low", "medium", "high", "critical"]),
1155
- justification: z3.string().min(1).max(600),
1156
- recommended_action: z3.enum(["auto-allow", "prompt", "deny"]),
1157
- specific_concerns: z3.array(z3.string().max(240)).max(12).default([])
1922
+ var AssessmentSchema = z5.object({
1923
+ category: z5.string().min(1).max(120),
1924
+ severity: z5.enum(["low", "medium", "high", "critical"]),
1925
+ justification: z5.string().min(1).max(600),
1926
+ recommended_action: z5.enum(["auto-allow", "prompt", "deny"]),
1927
+ specific_concerns: z5.array(z5.string().max(240)).max(12).default([])
1158
1928
  });
1159
1929
  var cachedPolicy = null;
1160
1930
  async function loadPolicy() {
@@ -1303,7 +2073,7 @@ function normalizeAction(a) {
1303
2073
  }
1304
2074
 
1305
2075
  // src/coordinator/tools.ts
1306
- import { z as z4 } from "zod";
2076
+ import { z as z6 } from "zod";
1307
2077
 
1308
2078
  // src/agent/subagent.ts
1309
2079
  import { streamText } from "ai";
@@ -1427,6 +2197,65 @@ function listBuiltinAgents() {
1427
2197
  }
1428
2198
 
1429
2199
  // src/agent/subagent.ts
2200
+ function subagentRolloutId(parentSessionId, subagentId) {
2201
+ return `${parentSessionId}.sub.${subagentId}`;
2202
+ }
2203
+ async function openSubagentRollout(parentSessionId, subagentId, kind, prompt, model) {
2204
+ if (!parentSessionId) return null;
2205
+ try {
2206
+ const id = subagentRolloutId(parentSessionId, subagentId);
2207
+ const roll = new Rollout(id);
2208
+ await roll.openNew({
2209
+ project: `subagent:${kind}:parent=${parentSessionId}`,
2210
+ model: model.modelId ?? "unknown"
2211
+ });
2212
+ await roll.append({
2213
+ type: "user-message",
2214
+ msgId: `${subagentId}.u0`,
2215
+ payload: { role: "user", content: prompt }
2216
+ });
2217
+ return roll;
2218
+ } catch {
2219
+ return null;
2220
+ }
2221
+ }
2222
+ async function recordSubagentStep(roll, subagentId, iteration, assistantText, toolCalls, toolResults) {
2223
+ if (!roll) return;
2224
+ try {
2225
+ await roll.append({
2226
+ type: "assistant-message",
2227
+ msgId: `${subagentId}.a${iteration}`,
2228
+ payload: { text: assistantText, toolCallCount: toolCalls.length }
2229
+ });
2230
+ for (const tc of toolCalls) {
2231
+ await roll.append({
2232
+ type: "tool-call",
2233
+ payload: { id: tc.toolCallId, name: tc.toolName, args: tc.args }
2234
+ });
2235
+ }
2236
+ for (const tr of toolResults) {
2237
+ await roll.append({
2238
+ type: "tool-result",
2239
+ payload: { id: tr.toolCallId, name: tr.toolName, result: tr.result }
2240
+ });
2241
+ }
2242
+ } catch {
2243
+ }
2244
+ }
2245
+ async function closeSubagentRollout(roll, outcome, finalText, errorMsg) {
2246
+ if (!roll) return;
2247
+ try {
2248
+ if (outcome === "error") {
2249
+ await roll.append({ type: "error", payload: { message: errorMsg ?? "unknown" } });
2250
+ }
2251
+ await roll.append({
2252
+ type: "turn-end",
2253
+ payload: { outcome, finalText, ts: (/* @__PURE__ */ new Date()).toISOString() }
2254
+ });
2255
+ await roll.close();
2256
+ } catch {
2257
+ }
2258
+ }
1430
2259
  var SUBAGENT_PROMPTS = {
1431
2260
  explore: `You are an exploration agent. Your job is to quickly search and analyze a codebase to answer questions.
1432
2261
  You have access to read, grep, and glob tools. Use them efficiently \u2014 search broadly first, then narrow down.
@@ -1451,156 +2280,18 @@ var EXPLORE_TOOL_FILTER = /* @__PURE__ */ new Set(["read", "grep", "glob", "web_
1451
2280
  var PLAN_TOOL_FILTER = /* @__PURE__ */ new Set(["read", "grep", "glob", "git", "web_fetch"]);
1452
2281
  var subagentCounter = 0;
1453
2282
  async function spawnSubagent(config) {
1454
- const { id, type, prompt, model, toolContext, maxIterations = 15 } = config;
1455
- if (type === "builtin") {
1456
- return {
1457
- id,
1458
- type,
1459
- text: "",
1460
- toolCalls: 0,
1461
- iterations: 0,
1462
- error: "use spawnBuiltinAgent() for built-in agents, not spawnSubagent()"
1463
- };
1464
- }
1465
- config.onStatus?.(id, `Starting ${type} agent...`);
1466
- const subagentCtx = {
1467
- ...toolContext,
1468
- permissionSurface: "subagent",
1469
- subagentId: id,
1470
- requireConfirm: false
1471
- };
1472
- const allTools = buildToolMap(subagentCtx);
1473
- const tools = {};
1474
- if (type === "explore") {
1475
- for (const [name, tool2] of Object.entries(allTools)) {
1476
- if (EXPLORE_TOOL_FILTER.has(name)) tools[name] = tool2;
1477
- }
1478
- } else if (type === "plan") {
1479
- for (const [name, tool2] of Object.entries(allTools)) {
1480
- if (PLAN_TOOL_FILTER.has(name)) tools[name] = tool2;
1481
- }
1482
- } else {
1483
- Object.assign(tools, allTools);
1484
- }
1485
- const systemPrompt = `${SUBAGENT_PROMPTS[type]}
1486
-
1487
- ## Working Directory
1488
- ${toolContext.cwd}
1489
-
1490
- ## Available Tools
1491
- ${Object.keys(tools).map((n) => `- ${n}`).join("\n")}`;
1492
- const messages = [
1493
- { role: "user", content: prompt }
1494
- ];
1495
- let iterations = 0;
1496
- let totalToolCalls = 0;
1497
- try {
1498
- while (iterations < maxIterations) {
1499
- iterations++;
1500
- config.onStatus?.(id, `Iteration ${iterations}/${maxIterations}...`);
1501
- const result = streamText({
1502
- model,
1503
- system: systemPrompt,
1504
- messages,
1505
- tools,
1506
- maxSteps: 1
1507
- });
1508
- let fullText = "";
1509
- const toolCalls = [];
1510
- const toolResults = [];
1511
- for await (const event of result.fullStream) {
1512
- if (event.type === "text-delta") {
1513
- fullText += event.textDelta;
1514
- } else if (event.type === "tool-call") {
1515
- toolCalls.push({
1516
- toolCallId: event.toolCallId,
1517
- toolName: event.toolName,
1518
- args: event.args
1519
- });
1520
- config.onStatus?.(id, `${event.toolName}(...)`);
1521
- }
1522
- const evt = event;
1523
- if (evt.type === "tool-result") {
1524
- toolResults.push({
1525
- toolCallId: evt.toolCallId,
1526
- toolName: toolCalls.find((tc) => tc.toolCallId === evt.toolCallId)?.toolName ?? "unknown",
1527
- result: evt.result
1528
- });
1529
- }
1530
- }
1531
- totalToolCalls += toolCalls.length;
1532
- if (toolCalls.length > 0) {
1533
- messages.push({
1534
- role: "assistant",
1535
- content: [
1536
- ...fullText ? [{ type: "text", text: fullText }] : [],
1537
- ...toolCalls.map((tc) => ({
1538
- type: "tool-call",
1539
- toolCallId: tc.toolCallId,
1540
- toolName: tc.toolName,
1541
- args: tc.args
1542
- }))
1543
- ]
1544
- });
1545
- messages.push({
1546
- role: "tool",
1547
- content: toolResults.map((tr) => ({
1548
- type: "tool-result",
1549
- toolCallId: tr.toolCallId,
1550
- toolName: tr.toolName,
1551
- result: tr.result
1552
- }))
1553
- });
1554
- continue;
1555
- }
1556
- config.onStatus?.(id, "Complete");
1557
- return {
1558
- id,
1559
- type,
1560
- text: fullText,
1561
- toolCalls: totalToolCalls,
1562
- iterations
1563
- };
1564
- }
1565
- config.onStatus?.(id, "Max iterations reached");
1566
- return {
1567
- id,
1568
- type,
1569
- text: "[Subagent reached max iterations]",
1570
- toolCalls: totalToolCalls,
1571
- iterations
1572
- };
1573
- } catch (err) {
1574
- config.onStatus?.(id, `Error: ${err.message}`);
2283
+ const { id, type, prompt, model, toolContext, maxIterations = 15 } = config;
2284
+ if (type === "builtin") {
1575
2285
  return {
1576
2286
  id,
1577
2287
  type,
1578
2288
  text: "",
1579
- toolCalls: totalToolCalls,
1580
- iterations,
1581
- error: err.message
1582
- };
1583
- }
1584
- }
1585
- function nextSubagentId(type) {
1586
- return `${type}-${++subagentCounter}`;
1587
- }
1588
- async function spawnBuiltinAgent(config) {
1589
- const { agentName, prompt, model, toolContext } = config;
1590
- const agent = getBuiltinAgent(agentName);
1591
- const id = config.id ?? `${agentName.toLowerCase()}-${++subagentCounter}`;
1592
- if (!agent) {
1593
- return {
1594
- id,
1595
- type: "builtin",
1596
- text: "",
1597
2289
  toolCalls: 0,
1598
2290
  iterations: 0,
1599
- error: `unknown built-in agent "${agentName}"`
2291
+ error: "use spawnBuiltinAgent() for built-in agents, not spawnSubagent()"
1600
2292
  };
1601
2293
  }
1602
- const maxIterations = config.maxIterations ?? agent.maxIterations;
1603
- config.onStatus?.(id, `Starting built-in agent ${agent.name}...`);
2294
+ config.onStatus?.(id, `Starting ${type} agent...`);
1604
2295
  const subagentCtx = {
1605
2296
  ...toolContext,
1606
2297
  permissionSurface: "subagent",
@@ -1608,21 +2299,31 @@ async function spawnBuiltinAgent(config) {
1608
2299
  requireConfirm: false
1609
2300
  };
1610
2301
  const allTools = buildToolMap(subagentCtx);
1611
- const allowed = new Set(agent.tools);
1612
2302
  const tools = {};
1613
- for (const [name, t] of Object.entries(allTools)) {
1614
- if (allowed.has(name)) tools[name] = t;
2303
+ if (type === "explore") {
2304
+ for (const [name, tool2] of Object.entries(allTools)) {
2305
+ if (EXPLORE_TOOL_FILTER.has(name)) tools[name] = tool2;
2306
+ }
2307
+ } else if (type === "plan") {
2308
+ for (const [name, tool2] of Object.entries(allTools)) {
2309
+ if (PLAN_TOOL_FILTER.has(name)) tools[name] = tool2;
2310
+ }
2311
+ } else {
2312
+ Object.assign(tools, allTools);
1615
2313
  }
1616
- const systemPrompt = `${agent.prompt}
2314
+ const systemPrompt = `${SUBAGENT_PROMPTS[type]}
1617
2315
 
1618
2316
  ## Working Directory
1619
2317
  ${toolContext.cwd}
1620
2318
 
1621
2319
  ## Available Tools
1622
2320
  ${Object.keys(tools).map((n) => `- ${n}`).join("\n")}`;
1623
- const messages = [{ role: "user", content: prompt }];
2321
+ const messages = [
2322
+ { role: "user", content: prompt }
2323
+ ];
1624
2324
  let iterations = 0;
1625
2325
  let totalToolCalls = 0;
2326
+ const roll = await openSubagentRollout(config.parentSessionId, id, type, prompt, model);
1626
2327
  try {
1627
2328
  while (iterations < maxIterations) {
1628
2329
  iterations++;
@@ -1658,6 +2359,7 @@ ${Object.keys(tools).map((n) => `- ${n}`).join("\n")}`;
1658
2359
  }
1659
2360
  }
1660
2361
  totalToolCalls += toolCalls.length;
2362
+ await recordSubagentStep(roll, id, iterations, fullText, toolCalls, toolResults);
1661
2363
  if (toolCalls.length > 0) {
1662
2364
  messages.push({
1663
2365
  role: "assistant",
@@ -1683,27 +2385,30 @@ ${Object.keys(tools).map((n) => `- ${n}`).join("\n")}`;
1683
2385
  continue;
1684
2386
  }
1685
2387
  config.onStatus?.(id, "Complete");
2388
+ await closeSubagentRollout(roll, "complete", fullText);
1686
2389
  return {
1687
2390
  id,
1688
- type: "builtin",
2391
+ type,
1689
2392
  text: fullText,
1690
2393
  toolCalls: totalToolCalls,
1691
2394
  iterations
1692
2395
  };
1693
2396
  }
1694
2397
  config.onStatus?.(id, "Max iterations reached");
2398
+ await closeSubagentRollout(roll, "max-iterations", "[Subagent reached max iterations]");
1695
2399
  return {
1696
2400
  id,
1697
- type: "builtin",
1698
- text: "[Built-in agent reached max iterations]",
2401
+ type,
2402
+ text: "[Subagent reached max iterations]",
1699
2403
  toolCalls: totalToolCalls,
1700
2404
  iterations
1701
2405
  };
1702
2406
  } catch (err) {
1703
2407
  config.onStatus?.(id, `Error: ${err.message}`);
2408
+ await closeSubagentRollout(roll, "error", "", err.message);
1704
2409
  return {
1705
2410
  id,
1706
- type: "builtin",
2411
+ type,
1707
2412
  text: "",
1708
2413
  toolCalls: totalToolCalls,
1709
2414
  iterations,
@@ -1711,697 +2416,489 @@ ${Object.keys(tools).map((n) => `- ${n}`).join("\n")}`;
1711
2416
  };
1712
2417
  }
1713
2418
  }
1714
-
1715
- // src/coordinator/runtime.ts
1716
- var registry = /* @__PURE__ */ new Map();
1717
- function registerAgent(entry) {
1718
- registry.set(entry.agentId, entry);
1719
- }
1720
- function getAgent(agentId) {
1721
- return registry.get(agentId);
1722
- }
1723
- function pendingCount() {
1724
- let n = 0;
1725
- for (const entry of registry.values()) {
1726
- if (!entry.delivered) n++;
1727
- }
1728
- return n;
1729
- }
1730
- function formatTaskNotification(agentId, toolUseId, status, summary, result) {
1731
- const MAX_RESULT = 8 * 1024;
1732
- const truncated = result.length > MAX_RESULT ? result.slice(0, MAX_RESULT) + `
1733
-
1734
- [truncated ${result.length - MAX_RESULT} chars]` : result;
1735
- const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1736
- return `<task-notification>
1737
- <task-id>${esc(agentId)}</task-id>
1738
- <tool-use-id>${esc(toolUseId)}</tool-use-id>
1739
- <status>${status}</status>
1740
- <summary>${esc(summary)}</summary>
1741
- <result>${esc(truncated)}</result>
1742
- </task-notification>`;
1743
- }
1744
- async function awaitOneCompletion() {
1745
- const pending = [];
1746
- for (const entry of registry.values()) {
1747
- if (!entry.delivered) pending.push(entry);
1748
- }
1749
- if (pending.length === 0) return null;
1750
- const alreadySettled = pending.find((e) => e.result !== void 0);
1751
- const winner = alreadySettled ? alreadySettled : await Promise.race(
1752
- pending.map(
1753
- (entry) => entry.completionPromise.then(() => entry).catch(() => entry)
1754
- )
1755
- );
1756
- winner.delivered = true;
1757
- const result = winner.result;
1758
- const status = winner.status;
1759
- const summary = status === "completed" ? `Agent "${winner.description}" completed` : status === "failed" ? `Agent "${winner.description}" failed: ${result?.error ?? "unknown error"}` : status === "killed" ? `Agent "${winner.description}" was stopped` : `Agent "${winner.description}" ended (${status})`;
1760
- const text = result?.text ?? result?.error ?? "";
1761
- const xml = formatTaskNotification(
1762
- winner.agentId,
1763
- winner.toolUseId,
1764
- status,
1765
- summary,
1766
- text
1767
- );
1768
- return {
1769
- agentId: winner.agentId,
1770
- toolUseId: winner.toolUseId,
1771
- status,
1772
- summary,
1773
- result: text,
1774
- xml
1775
- };
1776
- }
1777
- function injectCompletionAsUserMessage(messages, completion) {
1778
- messages.push({
1779
- role: "user",
1780
- content: completion.xml
1781
- });
1782
- }
1783
- async function pollPendingAgents(messages) {
1784
- if (pendingCount() === 0) return false;
1785
- const completion = await awaitOneCompletion();
1786
- if (!completion) return false;
1787
- injectCompletionAsUserMessage(messages, completion);
1788
- return true;
2419
+ function nextSubagentId(type) {
2420
+ return `${type}-${++subagentCounter}`;
1789
2421
  }
1790
-
1791
- // src/coordinator/tools.ts
1792
- var spawnParameters = z4.object({
1793
- subagent_type: z4.enum(["explore", "plan", "general", "builtin"]).describe(
1794
- "Worker profile: explore (read-only recon), plan (planning without edits), general (full tool access), builtin (named TOML-defined agent \u2014 provide builtin_name)."
1795
- ),
1796
- prompt: z4.string().describe(
1797
- "Self-contained spec for the worker. Include purpose, file paths, acceptance criteria, and what to report back. Workers cannot see this conversation."
1798
- ),
1799
- description: z4.string().describe(
1800
- "Short human-readable label for this worker, shown in status updates and in <task-notification> summaries."
1801
- ),
1802
- builtin_name: z4.string().optional().describe('Required when subagent_type is "builtin" \u2014 the name of the TOML-defined built-in agent to run.')
1803
- });
1804
- var agentSpawnTool = {
1805
- name: "agent_spawn",
1806
- description: 'Spawn a worker agent to execute a delegated task. Returns { agent_id, status: "spawned" } immediately \u2014 the worker runs in the background and eventually reports back via a <task-notification> user message. Use subagent_type to pick the worker profile.',
1807
- parameters: spawnParameters,
1808
- async execute(params, ctx) {
1809
- if (!ctx.coordinatorWorkerModel) {
1810
- return {
1811
- content: "agent_spawn is unavailable: coordinator mode is active but no worker model was supplied to the tool context. This is a configuration bug \u2014 coordinator mode must set coordinatorWorkerModel.",
1812
- isError: true
1813
- };
1814
- }
1815
- const { subagent_type, prompt, description, builtin_name } = params;
1816
- if (subagent_type === "builtin" && !builtin_name) {
1817
- return {
1818
- content: 'agent_spawn: builtin_name is required when subagent_type is "builtin".',
1819
- isError: true
1820
- };
1821
- }
1822
- const agentId = subagent_type === "builtin" ? `${(builtin_name ?? "builtin").toLowerCase()}-${nextSubagentId("general").split("-")[1]}` : nextSubagentId(subagent_type);
1823
- const abortController = new AbortController();
1824
- const workerCtx = { ...ctx, coordinatorMode: false };
1825
- const completionPromise = subagent_type === "builtin" ? spawnBuiltinAgent({
1826
- id: agentId,
1827
- agentName: builtin_name,
1828
- prompt,
1829
- model: ctx.coordinatorWorkerModel,
1830
- toolContext: workerCtx
1831
- }) : spawnSubagent({
1832
- id: agentId,
1833
- type: subagent_type,
1834
- prompt,
1835
- model: ctx.coordinatorWorkerModel,
1836
- toolContext: workerCtx
1837
- });
1838
- const toolUseId = `spawn-${agentId}`;
1839
- const entry = {
1840
- agentId,
1841
- toolUseId,
1842
- description,
1843
- completionPromise,
1844
- abortController,
1845
- status: "spawned",
1846
- delivered: false,
1847
- startedAt: Date.now()
1848
- };
1849
- registerAgent(entry);
1850
- completionPromise.then((result) => {
1851
- entry.result = result;
1852
- if (abortController.signal.aborted) {
1853
- entry.status = "killed";
1854
- } else if (result.error) {
1855
- entry.status = "failed";
1856
- } else {
1857
- entry.status = "completed";
1858
- }
1859
- }).catch((err) => {
1860
- entry.result = {
1861
- id: agentId,
1862
- type: subagent_type === "builtin" ? "builtin" : subagent_type,
1863
- text: "",
1864
- toolCalls: 0,
1865
- iterations: 0,
1866
- error: err?.message ?? String(err)
1867
- };
1868
- entry.status = abortController.signal.aborted ? "killed" : "failed";
1869
- });
2422
+ async function spawnBuiltinAgent(config) {
2423
+ const { agentName, prompt, model, toolContext } = config;
2424
+ const agent = getBuiltinAgent(agentName);
2425
+ const id = config.id ?? `${agentName.toLowerCase()}-${++subagentCounter}`;
2426
+ if (!agent) {
1870
2427
  return {
1871
- content: JSON.stringify({
1872
- agent_id: agentId,
1873
- status: "spawned",
1874
- description,
1875
- note: "Worker is running. You will receive a <task-notification> when it finishes."
1876
- })
2428
+ id,
2429
+ type: "builtin",
2430
+ text: "",
2431
+ toolCalls: 0,
2432
+ iterations: 0,
2433
+ error: `unknown built-in agent "${agentName}"`
1877
2434
  };
1878
2435
  }
1879
- };
1880
- var sendMessageParameters = z4.object({
1881
- to: z4.string().describe("The agent_id returned by agent_spawn."),
1882
- message: z4.string().describe("Follow-up instructions for the worker.")
1883
- });
1884
- var agentSendMessageTool = {
1885
- name: "agent_send_message",
1886
- description: "Continue an existing worker with a follow-up message. NOTE: Notch workers are currently one-shot \u2014 once a worker has finished, this tool cannot resume it. For follow-up work, spawn a fresh worker with a synthesized prompt that includes the previous worker's findings.",
1887
- parameters: sendMessageParameters,
1888
- async execute(params, _ctx) {
1889
- const { to, message: _message } = params;
1890
- const entry = getAgent(to);
1891
- if (!entry) {
1892
- return {
1893
- content: `agent_send_message: no agent with id "${to}" in the registry. Check the agent_id you got back from agent_spawn.`,
1894
- isError: true
1895
- };
1896
- }
1897
- if (entry.status === "completed" || entry.status === "failed" || entry.status === "killed") {
1898
- return {
1899
- content: JSON.stringify({
1900
- status: "unsupported",
1901
- agent_id: to,
1902
- agent_status: entry.status,
1903
- reason: "Notch workers are one-shot. This agent has already finished and cannot be continued. Spawn a fresh worker with a prompt that carries forward the relevant context from the previous worker's result."
1904
- }),
1905
- isError: true
1906
- };
1907
- }
1908
- return {
1909
- content: JSON.stringify({
1910
- status: "unsupported",
1911
- agent_id: to,
1912
- agent_status: entry.status,
1913
- reason: "Notch workers cannot receive mid-flight messages in the current runtime. Wait for the <task-notification> for this worker, then spawn a fresh worker with a synthesized follow-up prompt. Alternatively, call agent_stop and respawn with the corrected instructions."
1914
- }),
1915
- isError: true
1916
- };
2436
+ const maxIterations = config.maxIterations ?? agent.maxIterations;
2437
+ config.onStatus?.(id, `Starting built-in agent ${agent.name}...`);
2438
+ const subagentCtx = {
2439
+ ...toolContext,
2440
+ permissionSurface: "subagent",
2441
+ subagentId: id,
2442
+ requireConfirm: false
2443
+ };
2444
+ const allTools = buildToolMap(subagentCtx);
2445
+ const allowed = new Set(agent.tools);
2446
+ const tools = {};
2447
+ for (const [name, t] of Object.entries(allTools)) {
2448
+ if (allowed.has(name)) tools[name] = t;
1917
2449
  }
1918
- };
1919
- var stopParameters = z4.object({
1920
- id: z4.string().describe("The agent_id of the worker to stop.")
1921
- });
1922
- var agentStopTool = {
1923
- name: "agent_stop",
1924
- description: 'Abort a running worker. Use when you realize a worker is going in the wrong direction or the user has changed requirements. The worker will still emit a <task-notification> with status="killed".',
1925
- parameters: stopParameters,
1926
- async execute(params, _ctx) {
1927
- const entry = getAgent(params.id);
1928
- if (!entry) {
1929
- return {
1930
- content: `agent_stop: no agent with id "${params.id}" in the registry.`,
1931
- isError: true
1932
- };
1933
- }
1934
- if (entry.status === "completed" || entry.status === "failed" || entry.status === "killed") {
2450
+ const systemPrompt = `${agent.prompt}
2451
+
2452
+ ## Working Directory
2453
+ ${toolContext.cwd}
2454
+
2455
+ ## Available Tools
2456
+ ${Object.keys(tools).map((n) => `- ${n}`).join("\n")}`;
2457
+ const messages = [{ role: "user", content: prompt }];
2458
+ let iterations = 0;
2459
+ let totalToolCalls = 0;
2460
+ const roll = await openSubagentRollout(config.parentSessionId, id, `builtin:${agentName}`, prompt, model);
2461
+ try {
2462
+ while (iterations < maxIterations) {
2463
+ iterations++;
2464
+ config.onStatus?.(id, `Iteration ${iterations}/${maxIterations}...`);
2465
+ const result = streamText({
2466
+ model,
2467
+ system: systemPrompt,
2468
+ messages,
2469
+ tools,
2470
+ maxSteps: 1
2471
+ });
2472
+ let fullText = "";
2473
+ const toolCalls = [];
2474
+ const toolResults = [];
2475
+ for await (const event of result.fullStream) {
2476
+ if (event.type === "text-delta") {
2477
+ fullText += event.textDelta;
2478
+ } else if (event.type === "tool-call") {
2479
+ toolCalls.push({
2480
+ toolCallId: event.toolCallId,
2481
+ toolName: event.toolName,
2482
+ args: event.args
2483
+ });
2484
+ config.onStatus?.(id, `${event.toolName}(...)`);
2485
+ }
2486
+ const evt = event;
2487
+ if (evt.type === "tool-result") {
2488
+ toolResults.push({
2489
+ toolCallId: evt.toolCallId,
2490
+ toolName: toolCalls.find((tc) => tc.toolCallId === evt.toolCallId)?.toolName ?? "unknown",
2491
+ result: evt.result
2492
+ });
2493
+ }
2494
+ }
2495
+ totalToolCalls += toolCalls.length;
2496
+ await recordSubagentStep(roll, id, iterations, fullText, toolCalls, toolResults);
2497
+ if (toolCalls.length > 0) {
2498
+ messages.push({
2499
+ role: "assistant",
2500
+ content: [
2501
+ ...fullText ? [{ type: "text", text: fullText }] : [],
2502
+ ...toolCalls.map((tc) => ({
2503
+ type: "tool-call",
2504
+ toolCallId: tc.toolCallId,
2505
+ toolName: tc.toolName,
2506
+ args: tc.args
2507
+ }))
2508
+ ]
2509
+ });
2510
+ messages.push({
2511
+ role: "tool",
2512
+ content: toolResults.map((tr) => ({
2513
+ type: "tool-result",
2514
+ toolCallId: tr.toolCallId,
2515
+ toolName: tr.toolName,
2516
+ result: tr.result
2517
+ }))
2518
+ });
2519
+ continue;
2520
+ }
2521
+ config.onStatus?.(id, "Complete");
2522
+ await closeSubagentRollout(roll, "complete", fullText);
1935
2523
  return {
1936
- content: JSON.stringify({
1937
- status: "already_finished",
1938
- agent_id: params.id,
1939
- agent_status: entry.status
1940
- })
2524
+ id,
2525
+ type: "builtin",
2526
+ text: fullText,
2527
+ toolCalls: totalToolCalls,
2528
+ iterations
1941
2529
  };
1942
2530
  }
1943
- entry.abortController.abort();
2531
+ config.onStatus?.(id, "Max iterations reached");
2532
+ await closeSubagentRollout(roll, "max-iterations", "[Built-in agent reached max iterations]");
1944
2533
  return {
1945
- content: JSON.stringify({
1946
- status: "abort_signaled",
1947
- agent_id: params.id,
1948
- note: 'Abort signal sent. The worker will finish its current step and then report back with status="killed".'
1949
- })
2534
+ id,
2535
+ type: "builtin",
2536
+ text: "[Built-in agent reached max iterations]",
2537
+ toolCalls: totalToolCalls,
2538
+ iterations
2539
+ };
2540
+ } catch (err) {
2541
+ config.onStatus?.(id, `Error: ${err.message}`);
2542
+ await closeSubagentRollout(roll, "error", "", err.message);
2543
+ return {
2544
+ id,
2545
+ type: "builtin",
2546
+ text: "",
2547
+ toolCalls: totalToolCalls,
2548
+ iterations,
2549
+ error: err.message
1950
2550
  };
1951
2551
  }
1952
- };
1953
- var COORDINATOR_TOOLS = [
1954
- agentSpawnTool,
1955
- agentSendMessageTool,
1956
- agentStopTool
1957
- ];
1958
- var COORDINATOR_TOOL_NAMES = new Set(
1959
- COORDINATOR_TOOLS.map((t) => t.name)
1960
- );
1961
- var COORDINATOR_ALLOWED_TOOL_NAMES = /* @__PURE__ */ new Set([
1962
- ...COORDINATOR_TOOL_NAMES,
1963
- "read",
1964
- "grep",
1965
- "glob"
1966
- ]);
1967
-
1968
- // src/permissions/handlers/bash-classifier.ts
1969
- var DESTRUCTIVE_PATTERNS = [
1970
- /\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*|--recursive|--force)/i,
1971
- /\brm\s+-rf?\s+\//i,
1972
- /\bsudo\s+rm\b/i,
1973
- /\bdd\s+if=/i,
1974
- /\bmkfs\./i,
1975
- /\bshred\b/i,
1976
- /\bchmod\s+-R\s+/i,
1977
- /\bchown\s+-R\s+/i,
1978
- /:\(\)\s*\{\s*:\|:&\s*\};:/,
1979
- // fork bomb
1980
- /\b>\s*\/dev\/sd[a-z]/i,
1981
- /\bgit\s+push\s+(?:.*\s)?(?:-f|--force|--force-with-lease)\b/i,
1982
- /\bgit\s+reset\s+--hard\b/i,
1983
- /\bgit\s+clean\s+-[a-zA-Z]*[fd]/i,
1984
- /\bgit\s+checkout\s+--\s+\./,
1985
- /\bnpm\s+publish\b/i,
1986
- /\byarn\s+publish\b/i,
1987
- /\bpnpm\s+publish\b/i,
1988
- /\bdocker\s+(?:rm|rmi|system\s+prune|volume\s+rm)\b/i,
1989
- /\bkubectl\s+delete\b/i,
1990
- /\bterraform\s+destroy\b/i,
1991
- /\bmv\s+\/\s+/i,
1992
- /\bfind\s+.*\s-delete\b/i,
1993
- /\bfind\s+.*-exec\s+rm\b/i,
1994
- /\btruncate\s+-s\s+0/i,
1995
- /\b(poweroff|shutdown|reboot|halt)\b/i,
1996
- /\bkillall?\s+-9/i
1997
- ];
1998
- var NETWORK_PATTERNS = [
1999
- /\bcurl\b/i,
2000
- /\bwget\b/i,
2001
- /\bhttpie?\b/i,
2002
- /\bnc\b/i,
2003
- /\bnetcat\b/i,
2004
- /\bssh\b/i,
2005
- /\bscp\b/i,
2006
- /\brsync\s+.*::?/i,
2007
- /\bftp\b/i,
2008
- /\bsftp\b/i,
2009
- /\btelnet\b/i,
2010
- /\bnmap\b/i,
2011
- /\bping\b/i,
2012
- /\bdig\b/i,
2013
- /\bnslookup\b/i,
2014
- /\bhost\s+[a-z0-9.-]+\.[a-z]+/i,
2015
- /\bgit\s+(?:clone|fetch|pull|push)\b/i,
2016
- /\bnpm\s+(?:install|i|update|audit|fund)\b/i,
2017
- /\bpip\s+install\b/i,
2018
- /\bapt(?:-get)?\s+(?:install|update|upgrade)\b/i,
2019
- /\bbrew\s+(?:install|update|upgrade)\b/i,
2020
- /\b(?:python|python3|node|bun)\s+-c\s+.*(?:urllib|requests|fetch|http)/i,
2021
- /\bcurl.*\|\s*(?:sh|bash|zsh|fish)\b/i,
2022
- // explicit "pipe to shell"
2023
- /\bwget.*\|\s*(?:sh|bash|zsh|fish)\b/i
2024
- ];
2025
- var SAFE_PATTERNS = [
2026
- /^\s*ls(\s|$)/i,
2027
- /^\s*pwd(\s|$)/,
2028
- /^\s*whoami(\s|$)/,
2029
- /^\s*id(\s|$)/,
2030
- /^\s*echo\s/i,
2031
- /^\s*printf\s/i,
2032
- /^\s*cat\s/i,
2033
- /^\s*head\s/i,
2034
- /^\s*tail\s/i,
2035
- /^\s*wc\s/i,
2036
- /^\s*file\s/i,
2037
- /^\s*stat\s/i,
2038
- /^\s*du\s/i,
2039
- /^\s*df\s/i,
2040
- /^\s*which\s/i,
2041
- /^\s*type\s/i,
2042
- /^\s*env(\s|$)/i,
2043
- /^\s*date(\s|$)/i,
2044
- /^\s*uname/i,
2045
- /^\s*hostname(\s|$)/i,
2046
- /^\s*uptime(\s|$)/i,
2047
- /^\s*history(\s|$)/i,
2048
- /^\s*git\s+(status|log|diff|show|branch|blame|config\s+--get|remote\s+-v|stash\s+list|tag\s+-l|ls-files)\b/i,
2049
- /^\s*node\s+--version/i,
2050
- /^\s*npm\s+(ls|list|outdated|view|config\s+get|--version|-v|root)\b/i,
2051
- /^\s*yarn\s+(list|--version)\b/i,
2052
- /^\s*(python|python3)\s+--version/i,
2053
- /^\s*pip\s+(list|show|--version)\b/i,
2054
- /^\s*(rg|ripgrep)\s/i,
2055
- /^\s*grep\s/i,
2056
- /^\s*find\s+[^|;&]*(?<!-delete)\s*$/i,
2057
- // find without -delete/-exec rm
2058
- /^\s*tree(\s|$)/i,
2059
- /^\s*jq\s/i,
2060
- /^\s*awk\s/i,
2061
- /^\s*sed\s+-n\s/i,
2062
- // sed in print-only mode
2063
- /^\s*(make|cargo|go|npm|bun|pnpm|yarn)\s+(test|check|vet|fmt\s+--check|build\s+--dry-run)\b/i,
2064
- /^\s*(cargo|rustc)\s+--version/i,
2065
- /^\s*docker\s+(ps|images|logs|inspect|version)\b/i,
2066
- /^\s*kubectl\s+(get|describe|logs|version|config\s+current-context)\b/i
2067
- ];
2068
- function classifyBashCommand(command) {
2069
- const cmd = command.trim();
2070
- if (!cmd) return { category: "safe" };
2071
- const head = cmd.split(/[\s;|&]/)[0] ?? "";
2072
- for (const p of DESTRUCTIVE_PATTERNS) {
2073
- if (p.test(cmd)) {
2074
- return { category: "destructive", matchedPattern: p.source, head };
2075
- }
2076
- }
2077
- for (const p of NETWORK_PATTERNS) {
2078
- if (p.test(cmd)) {
2079
- return { category: "network", matchedPattern: p.source, head };
2080
- }
2081
- }
2082
- for (const p of SAFE_PATTERNS) {
2083
- if (p.test(cmd)) {
2084
- return { category: "safe", matchedPattern: p.source, head };
2085
- }
2086
- }
2087
- return { category: "unknown", head };
2088
2552
  }
2089
2553
 
2090
- // src/permissions/handlers/interactive.ts
2091
- var SESSION_RECENT_MS = 3e4;
2092
- var recentApprovals = /* @__PURE__ */ new Map();
2093
- function fingerprintArgs(args) {
2094
- const keys = Object.keys(args).sort();
2095
- const parts = [];
2096
- for (const k of keys) {
2097
- const v = args[k];
2098
- const s = typeof v === "string" ? v : JSON.stringify(v);
2099
- parts.push(`${k}=${s.slice(0, 200)}`);
2100
- }
2101
- return parts.join("|");
2554
+ // src/coordinator/runtime.ts
2555
+ var registry = /* @__PURE__ */ new Map();
2556
+ function registerAgent(entry) {
2557
+ registry.set(entry.agentId, entry);
2102
2558
  }
2103
- function makeKey(sessionId, toolName, args) {
2104
- return `${sessionId}\0${toolName}\0${fingerprintArgs(args)}`;
2559
+ function getAgent(agentId) {
2560
+ return registry.get(agentId);
2105
2561
  }
2106
- function recordInteractiveApproval(sessionId, toolName, args) {
2107
- const key = makeKey(sessionId, toolName, args);
2108
- recentApprovals.set(key, { key, expires: Date.now() + SESSION_RECENT_MS });
2562
+ function pendingCount() {
2563
+ let n = 0;
2564
+ for (const entry of registry.values()) {
2565
+ if (!entry.delivered) n++;
2566
+ }
2567
+ return n;
2109
2568
  }
2110
- function wasRecentlyApproved(sessionId, toolName, args) {
2111
- const key = makeKey(sessionId, toolName, args);
2112
- const hit = recentApprovals.get(key);
2113
- if (!hit) return false;
2114
- if (hit.expires < Date.now()) {
2115
- recentApprovals.delete(key);
2116
- return false;
2569
+ function formatTaskNotification(agentId, toolUseId, status, summary, result) {
2570
+ const MAX_RESULT = 8 * 1024;
2571
+ const truncated = result.length > MAX_RESULT ? result.slice(0, MAX_RESULT) + `
2572
+
2573
+ [truncated ${result.length - MAX_RESULT} chars]` : result;
2574
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2575
+ return `<task-notification>
2576
+ <task-id>${esc(agentId)}</task-id>
2577
+ <tool-use-id>${esc(toolUseId)}</tool-use-id>
2578
+ <status>${status}</status>
2579
+ <summary>${esc(summary)}</summary>
2580
+ <result>${esc(truncated)}</result>
2581
+ </task-notification>`;
2582
+ }
2583
+ async function awaitOneCompletion() {
2584
+ const pending = [];
2585
+ for (const entry of registry.values()) {
2586
+ if (!entry.delivered) pending.push(entry);
2117
2587
  }
2588
+ if (pending.length === 0) return null;
2589
+ const alreadySettled = pending.find((e) => e.result !== void 0);
2590
+ const winner = alreadySettled ? alreadySettled : await Promise.race(
2591
+ pending.map(
2592
+ (entry) => entry.completionPromise.then(() => entry).catch(() => entry)
2593
+ )
2594
+ );
2595
+ winner.delivered = true;
2596
+ const result = winner.result;
2597
+ const status = winner.status;
2598
+ const summary = status === "completed" ? `Agent "${winner.description}" completed` : status === "failed" ? `Agent "${winner.description}" failed: ${result?.error ?? "unknown error"}` : status === "killed" ? `Agent "${winner.description}" was stopped` : `Agent "${winner.description}" ended (${status})`;
2599
+ const text = result?.text ?? result?.error ?? "";
2600
+ const xml = formatTaskNotification(
2601
+ winner.agentId,
2602
+ winner.toolUseId,
2603
+ status,
2604
+ summary,
2605
+ text
2606
+ );
2607
+ return {
2608
+ agentId: winner.agentId,
2609
+ toolUseId: winner.toolUseId,
2610
+ status,
2611
+ summary,
2612
+ result: text,
2613
+ xml
2614
+ };
2615
+ }
2616
+ function injectCompletionAsUserMessage(messages, completion) {
2617
+ messages.push({
2618
+ role: "user",
2619
+ content: completion.xml
2620
+ });
2621
+ }
2622
+ async function pollPendingAgents(messages) {
2623
+ if (pendingCount() === 0) return false;
2624
+ const completion = await awaitOneCompletion();
2625
+ if (!completion) return false;
2626
+ injectCompletionAsUserMessage(messages, completion);
2118
2627
  return true;
2119
2628
  }
2120
- var interactiveHandler = {
2121
- surface: "interactive",
2122
- check: async (toolName, args, baseDecision, ctx) => {
2123
- if (baseDecision === "allow") {
2124
- if (toolName === "shell" && typeof args.command === "string") {
2125
- const pending = async () => {
2126
- const cls = classifyBashCommand(args.command);
2127
- if (cls.category === "destructive") {
2128
- return {
2129
- outcome: "prompt",
2130
- reason: `classifier flagged destructive command: ${cls.matchedPattern}`
2131
- };
2132
- }
2133
- return { outcome: "allow" };
2134
- };
2135
- return { outcome: "allow", pendingClassifierCheck: pending };
2136
- }
2137
- return { outcome: "allow" };
2629
+
2630
+ // src/coordinator/agent-resolver.ts
2631
+ var ROLE_HINTS = [
2632
+ {
2633
+ name: "archimedes",
2634
+ concepts: ["performance", "speed", "latency", "memory", "cpu", "throughput", "cost"],
2635
+ tasks: ["optimize", "benchmark", "measure", "slow", "faster", "hot path", "profiling"],
2636
+ strong: ["profile", "bench", "o(n", "big-o", "hotspot"]
2637
+ },
2638
+ {
2639
+ name: "euclid",
2640
+ concepts: ["proof", "invariant", "correctness", "theorem", "algorithm"],
2641
+ tasks: ["prove", "verify", "derive", "axiom", "formal"],
2642
+ strong: ["property test", "invariant", "induction", "lemma"]
2643
+ },
2644
+ {
2645
+ name: "hypatia",
2646
+ concepts: ["math", "formula", "statistics", "probability", "linear algebra", "numerical"],
2647
+ tasks: ["compute", "calculate", "regression", "distribution", "matrix"],
2648
+ strong: ["eigenvalue", "gradient", "ODE", "PDE", "markov"]
2649
+ },
2650
+ {
2651
+ name: "kepler",
2652
+ concepts: ["orbits", "pattern", "trend", "time series", "dataset"],
2653
+ tasks: ["analyze", "pattern", "forecast", "anomaly"],
2654
+ strong: ["time-series", "anomaly detection", "seasonality"]
2655
+ },
2656
+ {
2657
+ name: "plato",
2658
+ concepts: ["architecture", "design", "tradeoff", "principle", "philosophy"],
2659
+ tasks: ["design", "refactor", "tradeoff", "pattern", "architect"],
2660
+ strong: ["architectural decision", "adr", "design doc"]
2661
+ },
2662
+ {
2663
+ name: "ptolemy",
2664
+ concepts: ["map", "survey", "inventory", "catalog", "codebase", "dependency"],
2665
+ tasks: ["survey", "map out", "enumerate", "list all", "find every"],
2666
+ strong: ["dependency graph", "module map", "inventory"]
2667
+ },
2668
+ {
2669
+ name: "pythagoras",
2670
+ concepts: ["test", "assertion", "edge case", "spec", "contract"],
2671
+ tasks: ["test", "write tests", "cover", "assert", "spec"],
2672
+ strong: ["unit test", "integration test", "coverage gap"]
2673
+ },
2674
+ {
2675
+ name: "awaiter",
2676
+ concepts: ["wait", "poll", "watch", "monitor", "long-running"],
2677
+ tasks: ["wait for", "watch the", "poll", "until"],
2678
+ strong: ["long running", "deployment watch", "cron"]
2679
+ }
2680
+ ];
2681
+ var MIN_CONFIDENCE = 2;
2682
+ function scoreRole(description, hint) {
2683
+ const lower = description.toLowerCase();
2684
+ let score = 0;
2685
+ const reasons = [];
2686
+ for (const kw of hint.strong) {
2687
+ if (lower.includes(kw)) {
2688
+ score += 3;
2689
+ reasons.push(`strong:"${kw}"`);
2138
2690
  }
2139
- if (baseDecision === "deny") {
2140
- return { outcome: "deny", reason: "denied by permission config" };
2691
+ }
2692
+ for (const kw of hint.tasks) {
2693
+ if (lower.includes(kw)) {
2694
+ score += 2;
2695
+ reasons.push(`task:"${kw}"`);
2141
2696
  }
2142
- if (wasRecentlyApproved(ctx.sessionId, toolName, args)) {
2143
- return {
2144
- outcome: "allow",
2145
- silent: true,
2146
- reason: "session-recent approval (within 30s)"
2147
- };
2697
+ }
2698
+ for (const kw of hint.concepts) {
2699
+ if (lower.includes(kw)) {
2700
+ score += 1;
2701
+ reasons.push(`concept:"${kw}"`);
2148
2702
  }
2149
- return { outcome: "prompt" };
2150
2703
  }
2151
- };
2704
+ return { score, reasons };
2705
+ }
2706
+ function resolveAgent(description) {
2707
+ const agents = listBuiltinAgents();
2708
+ const byName = /* @__PURE__ */ new Map();
2709
+ for (const a of agents) byName.set(a.name, a);
2710
+ const ranked = [];
2711
+ for (const hint of ROLE_HINTS) {
2712
+ const agent = byName.get(hint.name);
2713
+ if (!agent) continue;
2714
+ const { score, reasons } = scoreRole(description, hint);
2715
+ if (score > 0) ranked.push({ agent, score, reasons });
2716
+ }
2717
+ ranked.sort((a, b) => b.score - a.score);
2718
+ const best = ranked[0] ?? null;
2719
+ const confident = best !== null && best.score >= MIN_CONFIDENCE;
2720
+ return { best, ranked, confident };
2721
+ }
2152
2722
 
2153
- // src/permissions/handlers/one-shot.ts
2154
- var oneShotHandler = {
2155
- surface: "one-shot",
2156
- check: async (toolName, _args, baseDecision, ctx) => {
2157
- if (baseDecision === "allow") return { outcome: "allow" };
2158
- if (baseDecision === "deny") {
2159
- return { outcome: "deny", reason: "denied by permission config" };
2723
+ // src/coordinator/tools.ts
2724
+ var spawnParameters = z6.object({
2725
+ subagent_type: z6.enum(["explore", "plan", "general", "builtin"]).describe(
2726
+ "Worker profile: explore (read-only recon), plan (planning without edits), general (full tool access), builtin (named TOML-defined agent \u2014 provide builtin_name)."
2727
+ ),
2728
+ prompt: z6.string().describe(
2729
+ "Self-contained spec for the worker. Include purpose, file paths, acceptance criteria, and what to report back. Workers cannot see this conversation."
2730
+ ),
2731
+ description: z6.string().describe(
2732
+ "Short human-readable label for this worker, shown in status updates and in <task-notification> summaries."
2733
+ ),
2734
+ builtin_name: z6.string().optional().describe('Required when subagent_type is "builtin" \u2014 the name of the TOML-defined built-in agent to run.')
2735
+ });
2736
+ var agentSpawnTool = {
2737
+ name: "agent_spawn",
2738
+ description: 'Spawn a worker agent to execute a delegated task. Returns { agent_id, status: "spawned" } immediately \u2014 the worker runs in the background and eventually reports back via a <task-notification> user message. Use subagent_type to pick the worker profile.',
2739
+ parameters: spawnParameters,
2740
+ async execute(params, ctx) {
2741
+ if (!ctx.coordinatorWorkerModel) {
2742
+ return {
2743
+ content: "agent_spawn is unavailable: coordinator mode is active but no worker model was supplied to the tool context. This is a configuration bug \u2014 coordinator mode must set coordinatorWorkerModel.",
2744
+ isError: true
2745
+ };
2160
2746
  }
2161
- if (ctx.autoConfirm) {
2747
+ const { subagent_type, prompt, description, builtin_name } = params;
2748
+ if (subagent_type === "builtin" && !builtin_name) {
2749
+ const hint = resolveAgent(`${description}
2750
+ ${prompt}`);
2751
+ const suggestion = hint.confident ? ` Suggested: builtin_name="${hint.best.agent.name}" (matched ${hint.best.reasons.slice(0, 2).join(", ")}).` : "";
2162
2752
  return {
2163
- outcome: "allow",
2164
- silent: true,
2165
- reason: "--yes flag set; auto-confirming one-shot"
2753
+ content: `agent_spawn: builtin_name is required when subagent_type is "builtin".${suggestion}`,
2754
+ isError: true
2166
2755
  };
2167
2756
  }
2168
- const count = (ctx.denialCounter.get(toolName) ?? 0) + 1;
2169
- ctx.denialCounter.set(toolName, count);
2170
- return {
2171
- outcome: "deny",
2172
- silent: true,
2173
- reason: "one-shot mode denies prompt-level tools without --yes",
2174
- tailNotification: `one-shot: denied ${toolName} (pass --yes to auto-approve)`
2757
+ const agentId = subagent_type === "builtin" ? `${(builtin_name ?? "builtin").toLowerCase()}-${nextSubagentId("general").split("-")[1]}` : nextSubagentId(subagent_type);
2758
+ const abortController = new AbortController();
2759
+ const workerCtx = { ...ctx, coordinatorMode: false };
2760
+ const completionPromise = subagent_type === "builtin" ? spawnBuiltinAgent({
2761
+ id: agentId,
2762
+ agentName: builtin_name,
2763
+ prompt,
2764
+ model: ctx.coordinatorWorkerModel,
2765
+ toolContext: workerCtx
2766
+ }) : spawnSubagent({
2767
+ id: agentId,
2768
+ type: subagent_type,
2769
+ prompt,
2770
+ model: ctx.coordinatorWorkerModel,
2771
+ toolContext: workerCtx
2772
+ });
2773
+ const toolUseId = `spawn-${agentId}`;
2774
+ const entry = {
2775
+ agentId,
2776
+ toolUseId,
2777
+ description,
2778
+ completionPromise,
2779
+ abortController,
2780
+ status: "spawned",
2781
+ delivered: false,
2782
+ startedAt: Date.now()
2175
2783
  };
2176
- }
2177
- };
2178
-
2179
- // src/permissions/handlers/coordinator.ts
2180
- var COORDINATOR_ALLOWED = /* @__PURE__ */ new Set([
2181
- "agent_spawn",
2182
- "agent_send_message",
2183
- "agent_stop",
2184
- "read",
2185
- "grep",
2186
- "glob"
2187
- ]);
2188
- var coordinatorHandler = {
2189
- surface: "coordinator",
2190
- check: async (toolName, _args, baseDecision, _ctx) => {
2191
- if (COORDINATOR_ALLOWED.has(toolName)) {
2192
- if (baseDecision === "deny") {
2193
- return { outcome: "deny", reason: "coordinator tool explicitly denied by config" };
2784
+ registerAgent(entry);
2785
+ completionPromise.then((result) => {
2786
+ entry.result = result;
2787
+ if (abortController.signal.aborted) {
2788
+ entry.status = "killed";
2789
+ } else if (result.error) {
2790
+ entry.status = "failed";
2791
+ } else {
2792
+ entry.status = "completed";
2194
2793
  }
2195
- return { outcome: "allow", silent: true };
2196
- }
2197
- if (baseDecision === "deny") {
2198
- return { outcome: "deny", reason: "denied by permission config" };
2199
- }
2794
+ }).catch((err) => {
2795
+ entry.result = {
2796
+ id: agentId,
2797
+ type: subagent_type === "builtin" ? "builtin" : subagent_type,
2798
+ text: "",
2799
+ toolCalls: 0,
2800
+ iterations: 0,
2801
+ error: err?.message ?? String(err)
2802
+ };
2803
+ entry.status = abortController.signal.aborted ? "killed" : "failed";
2804
+ });
2200
2805
  return {
2201
- outcome: "deny",
2202
- silent: true,
2203
- reason: "Coordinator must delegate write operations to a worker.",
2204
- tailNotification: `coordinator: blocked ${toolName} (delegate to worker)`
2806
+ content: JSON.stringify({
2807
+ agent_id: agentId,
2808
+ status: "spawned",
2809
+ description,
2810
+ note: "Worker is running. You will receive a <task-notification> when it finishes."
2811
+ })
2205
2812
  };
2206
2813
  }
2207
2814
  };
2208
-
2209
- // src/permissions/handlers/subagent.ts
2210
- var subagentHandler = {
2211
- surface: "subagent",
2212
- check: async (toolName, _args, baseDecision, ctx) => {
2213
- if (baseDecision === "allow") return { outcome: "allow" };
2214
- if (baseDecision === "deny") {
2215
- return { outcome: "deny", reason: "denied by permission config" };
2815
+ var sendMessageParameters = z6.object({
2816
+ to: z6.string().describe("The agent_id returned by agent_spawn."),
2817
+ message: z6.string().describe("Follow-up instructions for the worker.")
2818
+ });
2819
+ var agentSendMessageTool = {
2820
+ name: "agent_send_message",
2821
+ description: "Continue an existing worker with a follow-up message. NOTE: Notch workers are currently one-shot \u2014 once a worker has finished, this tool cannot resume it. For follow-up work, spawn a fresh worker with a synthesized prompt that includes the previous worker's findings.",
2822
+ parameters: sendMessageParameters,
2823
+ async execute(params, _ctx) {
2824
+ const { to, message: _message } = params;
2825
+ const entry = getAgent(to);
2826
+ if (!entry) {
2827
+ return {
2828
+ content: `agent_send_message: no agent with id "${to}" in the registry. Check the agent_id you got back from agent_spawn.`,
2829
+ isError: true
2830
+ };
2831
+ }
2832
+ if (entry.status === "completed" || entry.status === "failed" || entry.status === "killed") {
2833
+ return {
2834
+ content: JSON.stringify({
2835
+ status: "unsupported",
2836
+ agent_id: to,
2837
+ agent_status: entry.status,
2838
+ reason: "Notch workers are one-shot. This agent has already finished and cannot be continued. Spawn a fresh worker with a prompt that carries forward the relevant context from the previous worker's result."
2839
+ }),
2840
+ isError: true
2841
+ };
2216
2842
  }
2217
- const count = (ctx.denialCounter.get(toolName) ?? 0) + 1;
2218
- ctx.denialCounter.set(toolName, count);
2219
- const id = ctx.subagentId ?? "unknown";
2220
2843
  return {
2221
- outcome: "deny",
2222
- silent: true,
2223
- reason: "subagent has no interactive UI to prompt",
2224
- tailNotification: `subagent ${id}: denied ${toolName} (no UI to prompt)`
2844
+ content: JSON.stringify({
2845
+ status: "unsupported",
2846
+ agent_id: to,
2847
+ agent_status: entry.status,
2848
+ reason: "Notch workers cannot receive mid-flight messages in the current runtime. Wait for the <task-notification> for this worker, then spawn a fresh worker with a synthesized follow-up prompt. Alternatively, call agent_stop and respawn with the corrected instructions."
2849
+ }),
2850
+ isError: true
2225
2851
  };
2226
2852
  }
2227
2853
  };
2228
-
2229
- // src/permissions/handlers/auto-mode.ts
2230
- var AUTO_IMPLICIT_WRITE_KEY = "__auto_mode_implicit_writes__";
2231
- var AUTO_WRITE_NOTIFY_THRESHOLD = 10;
2232
- var WRITE_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "apply_patch", "shell", "git"]);
2233
- function hardDenyReason(toolName, args) {
2234
- if (toolName === "git") {
2235
- const op = typeof args.operation === "string" ? args.operation : "";
2236
- const flags = typeof args.flags === "string" ? args.flags : "";
2237
- const argsStr = typeof args.args === "string" ? args.args : "";
2238
- const combined = `${op} ${flags} ${argsStr}`.toLowerCase();
2239
- if (combined.includes("push") && /(-f\b|--force\b|--force-with-lease\b)/.test(combined)) {
2240
- return "force-push is never auto-approved";
2241
- }
2242
- if (combined.includes("reset") && combined.includes("--hard")) {
2243
- return "git reset --hard is never auto-approved";
2244
- }
2245
- }
2246
- if (toolName === "shell" && typeof args.command === "string") {
2247
- const cmd = args.command;
2248
- if (/\bnpm\s+publish\b/i.test(cmd) || /\byarn\s+publish\b/i.test(cmd) || /\bpnpm\s+publish\b/i.test(cmd)) {
2249
- return "package publish is never auto-approved";
2250
- }
2251
- if (/\bgit\s+push\b.*\b(?:-f|--force|--force-with-lease)\b/i.test(cmd)) {
2252
- return "force-push is never auto-approved";
2253
- }
2254
- if (/\b(?:rm\s+-rf?|dd\s+if=|mkfs\.|shred)\b/i.test(cmd)) {
2255
- return "classifier-flagged destructive shell command";
2256
- }
2257
- }
2258
- return null;
2259
- }
2260
- var autoModeHandler = {
2261
- surface: "auto-mode",
2262
- check: async (toolName, args, baseDecision, ctx) => {
2263
- if (baseDecision === "deny") {
2264
- return { outcome: "deny", reason: "denied by permission config" };
2265
- }
2266
- const reason = hardDenyReason(toolName, args);
2267
- if (reason) {
2854
+ var stopParameters = z6.object({
2855
+ id: z6.string().describe("The agent_id of the worker to stop.")
2856
+ });
2857
+ var agentStopTool = {
2858
+ name: "agent_stop",
2859
+ description: 'Abort a running worker. Use when you realize a worker is going in the wrong direction or the user has changed requirements. The worker will still emit a <task-notification> with status="killed".',
2860
+ parameters: stopParameters,
2861
+ async execute(params, _ctx) {
2862
+ const entry = getAgent(params.id);
2863
+ if (!entry) {
2268
2864
  return {
2269
- outcome: "deny",
2270
- silent: false,
2271
- reason,
2272
- tailNotification: `auto-mode: refused ${toolName} \u2014 ${reason}`
2865
+ content: `agent_stop: no agent with id "${params.id}" in the registry.`,
2866
+ isError: true
2273
2867
  };
2274
2868
  }
2275
- if (toolName === "shell" && typeof args.command === "string") {
2276
- const pending = async () => {
2277
- const cls = classifyBashCommand(args.command);
2278
- if (cls.category === "destructive") {
2279
- return {
2280
- outcome: "deny",
2281
- silent: false,
2282
- reason: `classifier flagged destructive command: ${cls.matchedPattern}`,
2283
- tailNotification: `auto-mode: refused destructive shell command (${cls.head ?? "shell"})`
2284
- };
2285
- }
2286
- return baseDecision === "allow" ? { outcome: "allow", silent: true } : { outcome: "allow", silent: true, reason: "auto-mode implicit approval" };
2869
+ if (entry.status === "completed" || entry.status === "failed" || entry.status === "killed") {
2870
+ return {
2871
+ content: JSON.stringify({
2872
+ status: "already_finished",
2873
+ agent_id: params.id,
2874
+ agent_status: entry.status
2875
+ })
2287
2876
  };
2288
- if (baseDecision === "allow") return { outcome: "allow", pendingClassifierCheck: pending };
2289
- return { outcome: "allow", silent: true, pendingClassifierCheck: pending };
2290
- }
2291
- if (baseDecision === "allow") return { outcome: "allow" };
2292
- if (WRITE_TOOLS.has(toolName)) {
2293
- const writes = (ctx.denialCounter.get(AUTO_IMPLICIT_WRITE_KEY) ?? 0) + 1;
2294
- ctx.denialCounter.set(AUTO_IMPLICIT_WRITE_KEY, writes);
2295
- if (writes === AUTO_WRITE_NOTIFY_THRESHOLD) {
2296
- return {
2297
- outcome: "allow",
2298
- silent: true,
2299
- reason: "auto-mode implicit approval",
2300
- tailNotification: `auto-mode: ${writes} unattended writes this session \u2014 consider reviewing`
2301
- };
2302
- }
2303
- if (writes > AUTO_WRITE_NOTIFY_THRESHOLD && writes % 10 === 0) {
2304
- return {
2305
- outcome: "allow",
2306
- silent: true,
2307
- reason: "auto-mode implicit approval",
2308
- tailNotification: `auto-mode: ${writes} unattended writes this session`
2309
- };
2310
- }
2311
- }
2312
- return { outcome: "allow", silent: true, reason: "auto-mode implicit approval" };
2313
- }
2314
- };
2315
-
2316
- // src/permissions/handlers/json-mode.ts
2317
- var jsonModeHandler = {
2318
- surface: "json-mode",
2319
- check: async (toolName, _args, baseDecision, ctx) => {
2320
- if (baseDecision === "allow") return { outcome: "allow" };
2321
- if (baseDecision === "deny") {
2322
- return { outcome: "deny", reason: "denied by permission config" };
2323
2877
  }
2324
- const count = (ctx.denialCounter.get(toolName) ?? 0) + 1;
2325
- ctx.denialCounter.set(toolName, count);
2326
- const payload = JSON.stringify({
2327
- type: "permission_denied",
2328
- tool: toolName,
2329
- reason: "json mode requires explicit allowlist"
2330
- });
2878
+ entry.abortController.abort();
2331
2879
  return {
2332
- outcome: "deny",
2333
- silent: true,
2334
- reason: "json mode requires explicit allowlist",
2335
- tailNotification: payload
2880
+ content: JSON.stringify({
2881
+ status: "abort_signaled",
2882
+ agent_id: params.id,
2883
+ note: 'Abort signal sent. The worker will finish its current step and then report back with status="killed".'
2884
+ })
2336
2885
  };
2337
2886
  }
2338
2887
  };
2339
-
2340
- // src/permissions/dispatcher.ts
2341
- var HANDLERS = {
2342
- interactive: interactiveHandler,
2343
- "one-shot": oneShotHandler,
2344
- coordinator: coordinatorHandler,
2345
- subagent: subagentHandler,
2346
- "auto-mode": autoModeHandler,
2347
- "json-mode": jsonModeHandler
2348
- };
2349
- function getActiveHandler(surface) {
2350
- return HANDLERS[surface];
2351
- }
2352
- async function runPermissionCheck(toolName, args, baseDecision, surface, ctx) {
2353
- const handler = getActiveHandler(surface);
2354
- const initial = await handler.check(toolName, args, baseDecision, ctx);
2355
- if (initial.tailNotification) {
2356
- pushTailNotification(initial.tailNotification);
2357
- }
2358
- if (!initial.pendingClassifierCheck) return initial;
2359
- try {
2360
- const refined = await initial.pendingClassifierCheck();
2361
- if (refined.tailNotification) pushTailNotification(refined.tailNotification);
2362
- return {
2363
- outcome: refined.outcome,
2364
- reason: refined.reason ?? initial.reason,
2365
- silent: refined.silent ?? initial.silent,
2366
- tailNotification: refined.tailNotification ?? initial.tailNotification
2367
- };
2368
- } catch {
2369
- return {
2370
- outcome: initial.outcome,
2371
- reason: initial.reason,
2372
- silent: initial.silent,
2373
- tailNotification: initial.tailNotification
2374
- };
2375
- }
2376
- }
2377
- var tailQueue = [];
2378
- function pushTailNotification(message) {
2379
- tailQueue.push(message);
2380
- }
2381
- function recordAutoModeDenial(decision) {
2382
- if (decision.outcome !== "deny") return;
2383
- const msg = decision.tailNotification ?? decision.reason ?? "auto-mode: tool denied";
2384
- pushTailNotification(msg);
2385
- }
2386
- function drainTailNotifications() {
2387
- const out = tailQueue.splice(0, tailQueue.length);
2388
- return out;
2389
- }
2390
- var currentSurface = "interactive";
2391
- function setCurrentSurface(surface) {
2392
- currentSurface = surface;
2393
- }
2394
- function createHandlerContext(options) {
2395
- return {
2396
- cwd: options.cwd,
2397
- sessionId: options.sessionId,
2398
- denialCounter: /* @__PURE__ */ new Map(),
2399
- log: options.log,
2400
- autoConfirm: options.autoConfirm,
2401
- guardianEnabled: options.guardianEnabled,
2402
- subagentId: options.subagentId
2403
- };
2404
- }
2888
+ var COORDINATOR_TOOLS = [
2889
+ agentSpawnTool,
2890
+ agentSendMessageTool,
2891
+ agentStopTool
2892
+ ];
2893
+ var COORDINATOR_TOOL_NAMES = new Set(
2894
+ COORDINATOR_TOOLS.map((t) => t.name)
2895
+ );
2896
+ var COORDINATOR_ALLOWED_TOOL_NAMES = /* @__PURE__ */ new Set([
2897
+ ...COORDINATOR_TOOL_NAMES,
2898
+ "read",
2899
+ "grep",
2900
+ "glob"
2901
+ ]);
2405
2902
 
2406
2903
  // src/tools/index.ts
2407
2904
  var BUILTIN_TOOLS = [
@@ -2419,7 +2916,9 @@ var BUILTIN_TOOLS = [
2419
2916
  notebookTool,
2420
2917
  taskTool,
2421
2918
  codeModeExecTool,
2422
- codeModeWaitTool
2919
+ codeModeWaitTool,
2920
+ skillManageTool,
2921
+ eventsTool
2423
2922
  ];
2424
2923
  var mcpClients = /* @__PURE__ */ new Map();
2425
2924
  var mcpTools = [];
@@ -2590,14 +3089,14 @@ function mcpToolCount() {
2590
3089
  }
2591
3090
 
2592
3091
  export {
3092
+ drainTailNotifications,
3093
+ setCurrentSurface,
2593
3094
  MCPClient,
2594
3095
  parseMCPConfig,
2595
3096
  listBuiltinAgents,
2596
3097
  spawnSubagent,
2597
3098
  nextSubagentId,
2598
3099
  pollPendingAgents,
2599
- drainTailNotifications,
2600
- setCurrentSurface,
2601
3100
  initMCPServers,
2602
3101
  disconnectMCPServers,
2603
3102
  buildToolMap,