@kirosnn/mosaic 0.0.91 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -6
  3. package/package.json +55 -48
  4. package/src/agent/Agent.ts +353 -131
  5. package/src/agent/context.ts +4 -4
  6. package/src/agent/prompts/systemPrompt.ts +209 -70
  7. package/src/agent/prompts/toolsPrompt.ts +285 -138
  8. package/src/agent/provider/anthropic.ts +109 -105
  9. package/src/agent/provider/google.ts +111 -107
  10. package/src/agent/provider/mistral.ts +95 -95
  11. package/src/agent/provider/ollama.ts +73 -17
  12. package/src/agent/provider/openai.ts +146 -102
  13. package/src/agent/provider/rateLimit.ts +178 -0
  14. package/src/agent/provider/reasoning.ts +29 -0
  15. package/src/agent/provider/xai.ts +108 -104
  16. package/src/agent/tools/definitions.ts +15 -1
  17. package/src/agent/tools/executor.ts +717 -98
  18. package/src/agent/tools/exploreExecutor.ts +20 -22
  19. package/src/agent/tools/fetch.ts +58 -0
  20. package/src/agent/tools/glob.ts +20 -4
  21. package/src/agent/tools/grep.ts +64 -9
  22. package/src/agent/tools/plan.ts +27 -0
  23. package/src/agent/tools/question.ts +7 -1
  24. package/src/agent/tools/read.ts +2 -0
  25. package/src/agent/types.ts +15 -14
  26. package/src/components/App.tsx +50 -8
  27. package/src/components/CustomInput.tsx +461 -77
  28. package/src/components/Main.tsx +1459 -1112
  29. package/src/components/Setup.tsx +1 -1
  30. package/src/components/ShortcutsModal.tsx +11 -8
  31. package/src/components/Welcome.tsx +1 -1
  32. package/src/components/main/ApprovalPanel.tsx +4 -3
  33. package/src/components/main/ChatPage.tsx +858 -516
  34. package/src/components/main/HomePage.tsx +58 -39
  35. package/src/components/main/QuestionPanel.tsx +52 -7
  36. package/src/components/main/ThinkingIndicator.tsx +13 -2
  37. package/src/components/main/types.ts +11 -10
  38. package/src/index.tsx +53 -25
  39. package/src/mcp/approvalPolicy.ts +148 -0
  40. package/src/mcp/cli/add.ts +185 -0
  41. package/src/mcp/cli/doctor.ts +77 -0
  42. package/src/mcp/cli/index.ts +85 -0
  43. package/src/mcp/cli/list.ts +50 -0
  44. package/src/mcp/cli/logs.ts +24 -0
  45. package/src/mcp/cli/manage.ts +99 -0
  46. package/src/mcp/cli/show.ts +53 -0
  47. package/src/mcp/cli/tools.ts +77 -0
  48. package/src/mcp/config.ts +223 -0
  49. package/src/mcp/index.ts +80 -0
  50. package/src/mcp/processManager.ts +299 -0
  51. package/src/mcp/rateLimiter.ts +50 -0
  52. package/src/mcp/registry.ts +151 -0
  53. package/src/mcp/schemaConverter.ts +100 -0
  54. package/src/mcp/servers/navigation.ts +854 -0
  55. package/src/mcp/toolCatalog.ts +169 -0
  56. package/src/mcp/types.ts +95 -0
  57. package/src/utils/approvalBridge.ts +45 -12
  58. package/src/utils/approvalModeBridge.ts +17 -0
  59. package/src/utils/commands/approvals.ts +48 -0
  60. package/src/utils/commands/compact.ts +30 -0
  61. package/src/utils/commands/echo.ts +1 -1
  62. package/src/utils/commands/image.ts +109 -0
  63. package/src/utils/commands/index.ts +9 -7
  64. package/src/utils/commands/new.ts +15 -0
  65. package/src/utils/commands/types.ts +3 -0
  66. package/src/utils/config.ts +3 -1
  67. package/src/utils/diffRendering.tsx +13 -16
  68. package/src/utils/exploreBridge.ts +10 -0
  69. package/src/utils/history.ts +82 -40
  70. package/src/utils/imageBridge.ts +28 -0
  71. package/src/utils/images.ts +31 -0
  72. package/src/utils/markdown.tsx +163 -99
  73. package/src/utils/models.ts +31 -16
  74. package/src/utils/notificationBridge.ts +23 -0
  75. package/src/utils/questionBridge.ts +36 -1
  76. package/src/utils/tokenEstimator.ts +32 -0
  77. package/src/utils/toolFormatting.ts +428 -48
  78. package/src/web/app.tsx +65 -5
  79. package/src/web/assets/css/ChatPage.css +102 -30
  80. package/src/web/assets/css/MessageItem.css +26 -29
  81. package/src/web/assets/css/ThinkingIndicator.css +44 -6
  82. package/src/web/assets/css/ToolMessage.css +36 -14
  83. package/src/web/components/ChatPage.tsx +228 -105
  84. package/src/web/components/HomePage.tsx +3 -3
  85. package/src/web/components/MessageItem.tsx +80 -81
  86. package/src/web/components/QuestionPanel.tsx +72 -12
  87. package/src/web/components/Setup.tsx +1 -1
  88. package/src/web/components/Sidebar.tsx +1 -3
  89. package/src/web/components/ThinkingIndicator.tsx +41 -21
  90. package/src/web/router.ts +1 -1
  91. package/src/web/server.tsx +894 -662
  92. package/src/web/storage.ts +23 -1
  93. package/src/web/types.ts +7 -6
  94. package/src/utils/commands/redo.ts +0 -74
  95. package/src/utils/commands/sessions.ts +0 -129
  96. package/src/utils/commands/undo.ts +0 -75
  97. package/src/utils/undoRedo.ts +0 -429
  98. package/src/utils/undoRedoBridge.ts +0 -45
  99. package/src/utils/undoRedoDb.ts +0 -338
@@ -17,28 +17,43 @@ const TIPS = [
17
17
  "Use /clear to reset the current chat session.",
18
18
  "Use Tab to autocomplete commands and arguments.",
19
19
  "Use Ctrl + K to clear the current input line.",
20
+ "Use Ctrl + G to edit the current input in your system editor.",
20
21
  "Use /help to display the list of available commands.",
22
+ "Select text with the mouse to copy it automatically.",
23
+ "Attach an image with /image <path>.",
24
+ "Paste an image with Ctrl/Alt + V (or Cmd + V on macOS).",
21
25
  ];
22
26
 
23
- export function HomePage({ onSubmit, pasteRequestId, shortcutsOpen }: HomePageProps) {
24
- const [currentTipIndex, setCurrentTipIndex] = useState(0);
25
- const [displayedText, setDisplayedText] = useState("");
26
- const [isDeleting, setIsDeleting] = useState(false);
27
- const [isWaiting, setIsWaiting] = useState(false);
28
- const [cursorVisible, setCursorVisible] = useState(true);
27
+ export function HomePage({ onSubmit, pasteRequestId, shortcutsOpen }: HomePageProps) {
28
+ const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80);
29
+ const [currentTipIndex, setCurrentTipIndex] = useState(0);
30
+ const [displayedText, setDisplayedText] = useState("");
31
+ const [isDeleting, setIsDeleting] = useState(false);
32
+ const [isWaiting, setIsWaiting] = useState(false);
33
+ const [cursorVisible, setCursorVisible] = useState(true);
29
34
 
30
- useEffect(() => {
31
- const cursorInterval = setInterval(() => {
32
- setCursorVisible((v) => !v);
33
- }, 500);
34
- return () => clearInterval(cursorInterval);
35
- }, []);
36
-
37
- useEffect(() => {
38
- if (isWaiting) {
39
- const timeout = setTimeout(() => {
40
- setIsWaiting(false);
41
- setIsDeleting(true);
35
+ useEffect(() => {
36
+ const cursorInterval = setInterval(() => {
37
+ setCursorVisible((v) => !v);
38
+ }, 500);
39
+ return () => clearInterval(cursorInterval);
40
+ }, []);
41
+
42
+ useEffect(() => {
43
+ const handleResize = () => {
44
+ setTerminalWidth(process.stdout.columns || 80);
45
+ };
46
+ process.stdout.on('resize', handleResize);
47
+ return () => {
48
+ process.stdout.off('resize', handleResize);
49
+ };
50
+ }, []);
51
+
52
+ useEffect(() => {
53
+ if (isWaiting) {
54
+ const timeout = setTimeout(() => {
55
+ setIsWaiting(false);
56
+ setIsDeleting(true);
42
57
  }, 5000);
43
58
  return () => clearTimeout(timeout);
44
59
  }
@@ -69,10 +84,13 @@ export function HomePage({ onSubmit, pasteRequestId, shortcutsOpen }: HomePagePr
69
84
  return () => clearTimeout(timeout);
70
85
  }
71
86
  }
72
- }, [displayedText, isDeleting, isWaiting, currentTipIndex]);
73
-
74
- return (
75
- <box flexDirection="column" width="100%" height="100%" justifyContent="center" alignItems="center">
87
+ }, [displayedText, isDeleting, isWaiting, currentTipIndex]);
88
+
89
+ const containerWidth = Math.min(80, Math.floor(terminalWidth * 0.8));
90
+ const inputWidth = Math.max(10, containerWidth - 4);
91
+
92
+ return (
93
+ <box flexDirection="column" width="100%" height="100%" justifyContent="center" alignItems="center">
76
94
  <box flexDirection="column" alignItems="center" marginBottom={2}>
77
95
  <text fg="#ffca38" attributes={TextAttributes.BOLD}>███╗ ███╗</text>
78
96
  <text fg="#ffca38" attributes={TextAttributes.BOLD}>████╗ ████║</text>
@@ -80,22 +98,23 @@ export function HomePage({ onSubmit, pasteRequestId, shortcutsOpen }: HomePagePr
80
98
  </box>
81
99
 
82
100
  <box width="80%" maxWidth={80}>
83
- <box
84
- flexDirection="row"
85
- backgroundColor="#1a1a1a"
86
- paddingLeft={2}
87
- paddingRight={2}
88
- paddingTop={1}
89
- paddingBottom={1}
90
- >
91
- <CustomInput
92
- onSubmit={onSubmit}
93
- placeholder="Ask anything..."
94
- focused={!shortcutsOpen}
95
- pasteRequestId={shortcutsOpen ? 0 : pasteRequestId}
96
- />
97
- </box>
98
- </box>
101
+ <box
102
+ flexDirection="row"
103
+ backgroundColor="#1a1a1a"
104
+ paddingLeft={2}
105
+ paddingRight={2}
106
+ paddingTop={1}
107
+ paddingBottom={1}
108
+ >
109
+ <CustomInput
110
+ onSubmit={onSubmit}
111
+ placeholder="Ask anything..."
112
+ focused={!shortcutsOpen}
113
+ pasteRequestId={shortcutsOpen ? 0 : pasteRequestId}
114
+ maxWidth={inputWidth}
115
+ />
116
+ </box>
117
+ </box>
99
118
 
100
119
  <box width="80%" maxWidth={80} marginTop={3} flexDirection="row" justifyContent="center">
101
120
  <text fg="#ffca38" attributes={TextAttributes.BOLD}>ⓘ TIPS: </text>
@@ -108,4 +127,4 @@ export function HomePage({ onSubmit, pasteRequestId, shortcutsOpen }: HomePagePr
108
127
  </box>
109
128
  </box>
110
129
  );
111
- }
130
+ }
@@ -8,15 +8,28 @@ interface QuestionPanelProps {
8
8
  request: QuestionRequest;
9
9
  disabled?: boolean;
10
10
  onAnswer: (index: number, customText?: string) => void;
11
+ maxWidth?: number;
11
12
  }
12
13
 
13
- export function QuestionPanel({ request, disabled = false, onAnswer }: QuestionPanelProps) {
14
+ export function QuestionPanel({ request, disabled = false, onAnswer, maxWidth }: QuestionPanelProps) {
14
15
  const [selectedIndex, setSelectedIndex] = useState(0);
16
+ const [remaining, setRemaining] = useState<number | null>(request.timeout ?? null);
17
+ const [validationError, setValidationError] = useState<string | null>(null);
15
18
 
16
19
  useEffect(() => {
17
20
  setSelectedIndex(0);
21
+ setValidationError(null);
22
+ setRemaining(request.timeout ?? null);
18
23
  }, [request.id]);
19
24
 
25
+ useEffect(() => {
26
+ if (remaining === null || remaining <= 0) return;
27
+ const id = setInterval(() => {
28
+ setRemaining(prev => (prev !== null && prev > 0 ? prev - 1 : prev));
29
+ }, 1000);
30
+ return () => clearInterval(id);
31
+ }, [remaining !== null]);
32
+
20
33
  useKeyboard((key) => {
21
34
  if (disabled) return;
22
35
 
@@ -47,13 +60,30 @@ export function QuestionPanel({ request, disabled = false, onAnswer }: QuestionP
47
60
  if (!text || !text.trim()) {
48
61
  return;
49
62
  }
63
+ if (request.validation) {
64
+ try {
65
+ if (!new RegExp(request.validation.pattern).test(text)) {
66
+ setValidationError(request.validation.message || `Input must match: ${request.validation.pattern}`);
67
+ return;
68
+ }
69
+ } catch {
70
+ setValidationError('Invalid validation pattern');
71
+ return;
72
+ }
73
+ }
74
+ setValidationError(null);
50
75
  onAnswer(0, text);
51
76
  };
52
77
 
78
+ let lastGroup: string | undefined;
79
+
53
80
  return (
54
81
  <box flexDirection="column" width="100%" backgroundColor="#1a1a1a" paddingLeft={1} paddingRight={1} paddingTop={1} paddingBottom={1}>
55
82
  <box flexDirection="row" marginBottom={1}>
56
83
  <text fg="#ffca38" attributes={TextAttributes.BOLD}>Question</text>
84
+ {remaining !== null && (
85
+ <text fg={remaining <= 5 ? '#ff4444' : '#888888'}> Timeout: {remaining}s</text>
86
+ )}
57
87
  </box>
58
88
 
59
89
  <box flexDirection="column" marginBottom={1}>
@@ -67,19 +97,34 @@ export function QuestionPanel({ request, disabled = false, onAnswer }: QuestionP
67
97
  const selected = index === selectedIndex;
68
98
  const prefix = selected ? '> ' : ' ';
69
99
  const number = index <= 8 ? `${index + 1}. ` : ' ';
100
+ const showGroupHeader = option.group && option.group !== lastGroup;
101
+ lastGroup = option.group;
70
102
  return (
71
- <box key={`${request.id}-${index}`} flexDirection="row" backgroundColor={selected ? '#2a2a2a' : 'transparent'} paddingLeft={1} paddingRight={1}>
72
- <text fg={selected ? '#ffca38' : 'white'} attributes={selected ? TextAttributes.BOLD : TextAttributes.NONE}>
73
- {prefix}{number}{option.label}
74
- </text>
103
+ <box key={`${request.id}-${index}`} flexDirection="column">
104
+ {showGroupHeader && (
105
+ <box paddingLeft={1} marginTop={index > 0 ? 1 : 0}>
106
+ <text fg="#888888" attributes={TextAttributes.BOLD}>{option.group}</text>
107
+ </box>
108
+ )}
109
+ <box flexDirection="row" backgroundColor={selected ? '#2a2a2a' : 'transparent'} paddingLeft={1} paddingRight={1}>
110
+ <text fg={selected ? '#ffca38' : 'white'} attributes={selected ? TextAttributes.BOLD : TextAttributes.NONE}>
111
+ {prefix}{number}{option.label}
112
+ </text>
113
+ </box>
75
114
  </box>
76
115
  );
77
116
  })}
78
117
  </box>
79
118
 
119
+ {validationError && (
120
+ <box marginBottom={1} paddingLeft={1}>
121
+ <text fg="#ff4444">{validationError}</text>
122
+ </box>
123
+ )}
124
+
80
125
  <box flexDirection="row">
81
- <CustomInput onSubmit={handleCustomSubmit} placeholder="Tell Mosaic what it should do and press Enter" focused={!disabled} disableHistory={true} />
126
+ <CustomInput onSubmit={handleCustomSubmit} placeholder="Tell Mosaic what it should do and press Enter" focused={!disabled} disableHistory={true} maxWidth={maxWidth} />
82
127
  </box>
83
128
  </box>
84
129
  );
85
- }
130
+ }
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect } from "react";
1
+ import { useState, useEffect } from "react";
2
2
  import { TextAttributes } from "@opentui/core";
3
3
  import { THINKING_WORDS } from "./types";
4
4
 
@@ -7,6 +7,8 @@ interface ThinkingIndicatorProps {
7
7
  hasQuestion: boolean;
8
8
  startTime?: number | null;
9
9
  tokens?: number;
10
+ inProgressStep?: string;
11
+ nextStep?: string;
10
12
  }
11
13
 
12
14
  export function getInputBarBaseLines(): number {
@@ -22,7 +24,8 @@ export function shouldShowThinkingIndicator({ isProcessing, hasQuestion }: Think
22
24
  }
23
25
 
24
26
  export function getBottomReservedLinesForInputBar(props: ThinkingIndicatorProps): number {
25
- return getInputBarBaseLines() + (shouldShowThinkingIndicator(props) ? 2 : 0) + 2;
27
+ const indicatorLines = shouldShowThinkingIndicator(props) ? (props.nextStep ? 3 : 2) : 0;
28
+ return getInputBarBaseLines() + indicatorLines + 2;
26
29
  }
27
30
 
28
31
  export function formatElapsedTime(ms: number | null | undefined, fromStartTime: boolean = true): string {
@@ -93,6 +96,14 @@ export function ThinkingIndicatorBlock(props: ThinkingIndicatorProps) {
93
96
  return (
94
97
  <box flexDirection="column" width="100%">
95
98
  <ThinkingIndicator {...props} />
99
+ {props.nextStep ? (
100
+ <box flexDirection="row" width="100%" paddingLeft={2}>
101
+ <text fg="#ffca38" attributes={TextAttributes.BOLD}>⎿ </text>
102
+ <text fg="#ffca38" attributes={TextAttributes.BOLD}>Next:</text>
103
+ <text> </text>
104
+ <text fg="white" attributes={TextAttributes.DIM}>{props.nextStep}</text>
105
+ </box>
106
+ ) : null}
96
107
  <box flexDirection="row" width="100%">
97
108
  <text> </text>
98
109
  </box>
@@ -26,15 +26,16 @@ export const THINKING_WORDS = [
26
26
  "Revolutionizing"
27
27
  ];
28
28
 
29
- export interface Message {
30
- id: string;
31
- role: "user" | "assistant" | "tool" | "slash";
32
- displayRole?: "user" | "assistant" | "tool" | "slash";
33
- displayContent?: string;
34
- content: string;
35
- toolName?: string;
36
- toolArgs?: Record<string, unknown>;
37
- toolResult?: unknown;
29
+ export interface Message {
30
+ id: string;
31
+ role: "user" | "assistant" | "tool" | "slash";
32
+ displayRole?: "user" | "assistant" | "tool" | "slash";
33
+ displayContent?: string;
34
+ content: string;
35
+ images?: import("../../utils/images").ImageAttachment[];
36
+ toolName?: string;
37
+ toolArgs?: Record<string, unknown>;
38
+ toolResult?: unknown;
38
39
  success?: boolean;
39
40
  isError?: boolean;
40
41
  responseDuration?: number;
@@ -52,4 +53,4 @@ export interface MainProps {
52
53
  shortcutsOpen?: boolean;
53
54
  commandsOpen?: boolean;
54
55
  initialMessage?: string;
55
- }
56
+ }
package/src/index.tsx CHANGED
@@ -37,6 +37,8 @@ interface ParsedArgs {
37
37
  uninstall?: boolean;
38
38
  forceUninstall?: boolean;
39
39
  webServer?: boolean;
40
+ mcpCommand?: boolean;
41
+ mcpArgs?: string[];
40
42
  }
41
43
 
42
44
  class CLI {
@@ -54,8 +56,9 @@ class CLI {
54
56
  parsed.directory = args[i + 1];
55
57
  i += 2;
56
58
  } else if (arg === 'run') {
57
- parsed.initialMessage = args[i + 1];
58
- i += 2;
59
+ const message = args.slice(i + 1).join(' ');
60
+ if (message) parsed.initialMessage = message;
61
+ i = args.length;
59
62
  } else if (arg === 'uninstall') {
60
63
  parsed.uninstall = true;
61
64
  if (args[i + 1] === '--force') {
@@ -64,6 +67,10 @@ class CLI {
64
67
  } else {
65
68
  i++;
66
69
  }
70
+ } else if (arg === 'mcp') {
71
+ parsed.mcpCommand = true;
72
+ parsed.mcpArgs = args.slice(i + 1);
73
+ i = args.length;
67
74
  } else if (arg === 'web') {
68
75
  parsed.webServer = true;
69
76
  i++;
@@ -80,31 +87,39 @@ class CLI {
80
87
 
81
88
  showHelp(): void {
82
89
  const gold = (text: string) => `\x1b[38;2;255;202;56m${text}\x1b[0m`;
90
+ const gray = (text: string) => `\x1b[90m${text}\x1b[0m`;
83
91
 
84
92
  console.log('');
85
- console.log(gold('███╗ ███╗'));
86
- console.log(gold('████╗ ████║'));
87
- console.log(gold('███╔████╔███║'));
88
93
  console.log(`
89
- Mosaic - AI-powered coding agent
90
-
91
- Usage:
92
- mosaic [options] [directory]
93
-
94
- Options:
95
- -h, --help Show this help message
96
- -d, --directory <path> Open in specific directory
97
- run "<message>" Launch with a message to execute
98
- web Start the web interface server
99
- uninstall [--force] Uninstall Mosaic
100
-
101
- Examples:
102
- mosaic Start in current directory
103
- mosaic ./my-project Start in specific directory
104
- mosaic run "fix the bug" Launch with a task
105
- mosaic web Start web server on http://127.0.0.1:8192
106
- mosaic uninstall Interactive uninstall
107
- mosaic uninstall --force Force uninstall (removes all data)
94
+ ${gold('Mosaic')}
95
+
96
+ ${gold('Usage')}
97
+ $ mosaic [options] [path]
98
+ $ mosaic <command> [options]
99
+
100
+ ${gold('Options')}
101
+ -h, --help ${gray('Show this help message')}
102
+ -d, --directory <path> ${gray('Open Mosaic in a specific directory (default: current)')}
103
+
104
+ ${gold('Commands')}
105
+ run "<message>" ${gray('Launch Mosaic with an initial prompt to execute immediately')}
106
+ web ${gray('Start the Mosaic web interface server (default: http://127.0.0.1:8192)')}
107
+ mcp <subcommand> ${gray('Manage Model Context Protocol (MCP) servers')}
108
+ uninstall [--force] ${gray('Uninstall Mosaic from your system')}
109
+
110
+ ${gold('MCP Subcommands')}
111
+ mosaic mcp list ${gray('List configured MCP servers')}
112
+ mosaic mcp add [name] ${gray('Add a new MCP server')}
113
+ mosaic mcp doctor ${gray('Run diagnostics')}
114
+ mosaic mcp help ${gray('View full list of MCP commands')}
115
+
116
+ ${gold('Examples')}
117
+ ${gray('mosaic')} # Start in current directory
118
+ ${gray('mosaic ./my-project')} # Start in specific directory
119
+ ${gray('mosaic run "Fix the bug in main.ts"')} # Launch with a specific task
120
+ ${gray('mosaic web')} # Start the web UI
121
+ ${gray('mosaic mcp list')} # Check connected tools
122
+ ${gray('mosaic uninstall --force')} # Completely remove Mosaic
108
123
  `);
109
124
  }
110
125
 
@@ -128,6 +143,12 @@ if (parsed.uninstall) {
128
143
  process.exit(0);
129
144
  }
130
145
 
146
+ if (parsed.mcpCommand) {
147
+ const { runMcpCli } = await import('./mcp/cli/index');
148
+ await runMcpCli(parsed.mcpArgs ?? []);
149
+ process.exit(0);
150
+ }
151
+
131
152
  if (parsed.webServer) {
132
153
  const { spawn } = await import('child_process');
133
154
  const path = await import('path');
@@ -189,9 +210,16 @@ if (parsed.directory) {
189
210
  import { addRecentProject } from './utils/config';
190
211
  addRecentProject(process.cwd());
191
212
 
213
+ const { initializeMcp } = await import('./mcp/index');
214
+ await initializeMcp().catch(() => { });
215
+
192
216
  process.title = '⁘ Mosaic';
193
217
 
194
- const cleanup = (code = 0) => {
218
+ const cleanup = async (code = 0) => {
219
+ try {
220
+ const { shutdownMcp } = await import('./mcp/index');
221
+ await shutdownMcp();
222
+ } catch { }
195
223
  process.stdout.write('\x1b[?25h');
196
224
  process.exit(code);
197
225
  };
@@ -0,0 +1,148 @@
1
+ import { createHash } from 'crypto';
2
+ import { requestApproval } from '../utils/approvalBridge';
3
+ import type { McpApprovalCacheEntry, McpRiskHint, McpServerConfig } from './types';
4
+
5
+ const READ_KEYWORDS = ['read', 'get', 'list', 'search', 'find', 'show', 'describe', 'query', 'fetch', 'inspect', 'view', 'ls', 'cat'];
6
+ const WRITE_KEYWORDS = ['write', 'set', 'create', 'update', 'put', 'save', 'modify', 'patch', 'upsert', 'insert', 'append'];
7
+ const EXEC_KEYWORDS = ['exec', 'run', 'execute', 'spawn', 'shell', 'eval', 'invoke', 'call', 'process'];
8
+ const DELETE_KEYWORDS = ['delete', 'remove', 'destroy', 'drop', 'purge', 'clean', 'wipe', 'rm', 'unlink'];
9
+ const NET_KEYWORDS = ['http', 'request', 'download', 'upload', 'send', 'post', 'api', 'webhook', 'socket', 'connect'];
10
+
11
+ export class McpApprovalPolicy {
12
+ private cache = new Map<string, McpApprovalCacheEntry>();
13
+
14
+ inferRiskHint(toolName: string, _args: Record<string, unknown>): McpRiskHint {
15
+ const lower = toolName.toLowerCase();
16
+
17
+ for (const kw of DELETE_KEYWORDS) {
18
+ if (lower.includes(kw)) return 'execute';
19
+ }
20
+ for (const kw of EXEC_KEYWORDS) {
21
+ if (lower.includes(kw)) return 'execute';
22
+ }
23
+ for (const kw of WRITE_KEYWORDS) {
24
+ if (lower.includes(kw)) return 'write';
25
+ }
26
+ for (const kw of NET_KEYWORDS) {
27
+ if (lower.includes(kw)) return 'network';
28
+ }
29
+ for (const kw of READ_KEYWORDS) {
30
+ if (lower.includes(kw)) return 'read';
31
+ }
32
+
33
+ return 'unknown';
34
+ }
35
+
36
+ async requestMcpApproval(request: {
37
+ serverId: string;
38
+ serverName: string;
39
+ toolName: string;
40
+ canonicalId: string;
41
+ args: Record<string, unknown>;
42
+ approvalMode: McpServerConfig['approval'];
43
+ }): Promise<{ approved: boolean; customResponse?: string }> {
44
+ if (request.approvalMode === 'never') {
45
+ return { approved: true };
46
+ }
47
+
48
+ const riskHint = this.inferRiskHint(request.toolName, request.args);
49
+
50
+ if (this.checkCache(request.serverId, request.toolName, request.args)) {
51
+ return { approved: true };
52
+ }
53
+
54
+ const argsStr = formatArgs(request.args);
55
+ const payloadSize = JSON.stringify(request.args).length;
56
+
57
+ const preview = {
58
+ title: `MCP: ${request.serverName} / ${request.toolName}`,
59
+ content: argsStr,
60
+ details: [
61
+ `Server: ${request.serverName} (${request.serverId})`,
62
+ `Tool: ${request.toolName}`,
63
+ `Risk: ${riskHint}`,
64
+ `Payload: ${payloadSize} bytes`,
65
+ ],
66
+ };
67
+
68
+ const mcpMeta = {
69
+ serverId: request.serverId,
70
+ serverName: request.serverName,
71
+ canonicalId: request.canonicalId,
72
+ riskHint,
73
+ payloadSize,
74
+ };
75
+
76
+ const result = await requestApproval(
77
+ request.canonicalId,
78
+ { ...request.args, __mcpMeta: mcpMeta },
79
+ preview
80
+ );
81
+
82
+ if (result.approved && request.approvalMode !== 'always') {
83
+ this.addToCache(request.serverId, request.toolName, request.args, request.approvalMode);
84
+ }
85
+
86
+ return result;
87
+ }
88
+
89
+ private checkCache(serverId: string, toolName: string, args: Record<string, unknown>): boolean {
90
+ const now = Date.now();
91
+
92
+ const serverKey = `server:${serverId}`;
93
+ const serverEntry = this.cache.get(serverKey);
94
+ if (serverEntry && serverEntry.expiresAt > now) return true;
95
+
96
+ const toolKey = `tool:${serverId}:${toolName}`;
97
+ const toolEntry = this.cache.get(toolKey);
98
+ if (toolEntry && toolEntry.expiresAt > now) return true;
99
+
100
+ const argsHash = hashArgs(args);
101
+ const argsKey = `toolArgs:${serverId}:${toolName}:${argsHash}`;
102
+ const argsEntry = this.cache.get(argsKey);
103
+ if (argsEntry && argsEntry.expiresAt > now) return true;
104
+
105
+ return false;
106
+ }
107
+
108
+ private addToCache(serverId: string, toolName: string, _args: Record<string, unknown>, mode: McpServerConfig['approval']): void {
109
+ const ttl = 300000;
110
+ const expiresAt = Date.now() + ttl;
111
+
112
+ switch (mode) {
113
+ case 'once-per-server': {
114
+ const key = `server:${serverId}`;
115
+ this.cache.set(key, { scope: 'server', key, expiresAt });
116
+ break;
117
+ }
118
+ case 'once-per-tool': {
119
+ const key = `tool:${serverId}:${toolName}`;
120
+ this.cache.set(key, { scope: 'tool', key, expiresAt });
121
+ break;
122
+ }
123
+ }
124
+ }
125
+
126
+ clearCache(): void {
127
+ this.cache.clear();
128
+ }
129
+ }
130
+
131
+ function hashArgs(args: Record<string, unknown>): string {
132
+ const str = JSON.stringify(args, Object.keys(args).sort());
133
+ return createHash('sha256').update(str).digest('hex').slice(0, 12);
134
+ }
135
+
136
+ function formatArgs(args: Record<string, unknown>): string {
137
+ const entries = Object.entries(args);
138
+ if (entries.length === 0) return '(no arguments)';
139
+
140
+ const lines: string[] = [];
141
+ for (const [key, value] of entries) {
142
+ const strValue = typeof value === 'string'
143
+ ? (value.length > 100 ? value.slice(0, 100) + '...' : value)
144
+ : JSON.stringify(value);
145
+ lines.push(` ${key}: ${strValue}`);
146
+ }
147
+ return lines.join('\n');
148
+ }