@miphamai/cli 0.3.0 → 0.5.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 (74) hide show
  1. package/bin/mipham.ts +188 -0
  2. package/dist/mipham +0 -0
  3. package/package.json +9 -9
  4. package/skills/mipham/om-artifact.mipham-skill.md +69 -0
  5. package/skills/mipham/om-model-optimize.mipham-skill.md +9 -6
  6. package/skills/mipham/om-security.mipham-skill.md +11 -8
  7. package/skills/standard/doc-generator.SKILL.md +6 -1
  8. package/skills/standard/github-ops.SKILL.md +12 -7
  9. package/skills/standard/memory.SKILL.md +1 -0
  10. package/skills/standard/self-review.SKILL.md +1 -0
  11. package/skills/standard/superpower.SKILL.md +7 -7
  12. package/skills/standard/web-search.SKILL.md +3 -0
  13. package/src/agent/agent-context.ts +56 -0
  14. package/src/agent/agent-registry.ts +134 -0
  15. package/src/agent/sub-agent.ts +73 -89
  16. package/src/agent/types.ts +39 -0
  17. package/src/agent-view/agent-view-manager.ts +217 -0
  18. package/src/agent-view/dashboard.tsx +218 -0
  19. package/src/agent-view/session-peek.tsx +72 -0
  20. package/src/agent-view/session-row.tsx +70 -0
  21. package/src/artifacts/manifest.ts +101 -0
  22. package/src/artifacts/server.ts +374 -0
  23. package/src/artifacts/versioning.ts +127 -0
  24. package/src/core/context-compact.ts +50 -0
  25. package/src/core/context-drain.ts +54 -0
  26. package/src/core/context-microcompact.ts +132 -0
  27. package/src/core/context-snip.ts +74 -0
  28. package/src/core/context-token.ts +108 -0
  29. package/src/core/context.ts +169 -0
  30. package/src/core/engine.ts +207 -58
  31. package/src/core/hooks-config.ts +52 -0
  32. package/src/core/hooks-executor.ts +113 -0
  33. package/src/core/hooks.ts +90 -30
  34. package/src/core/instructions.ts +12 -1
  35. package/src/core/memory/memory-loader.ts +23 -0
  36. package/src/core/memory/memory-manager.ts +192 -0
  37. package/src/core/memory/memory-writer.ts +72 -0
  38. package/src/core/permission-config.ts +34 -0
  39. package/src/core/permission-rules.ts +66 -0
  40. package/src/core/permission.ts +224 -34
  41. package/src/index.tsx +30 -2
  42. package/src/mcp/transport.ts +42 -5
  43. package/src/plugin/plugin-loader.ts +36 -0
  44. package/src/plugin/plugin-manager.ts +125 -0
  45. package/src/plugin/plugin-validator.ts +41 -0
  46. package/src/providers/fetch-utils.ts +1 -3
  47. package/src/providers/openai-compat.ts +2 -11
  48. package/src/shared/constants.ts +8 -1
  49. package/src/shared/types.ts +82 -2
  50. package/src/skills/fork-executor.ts +40 -0
  51. package/src/skills/loader.ts +48 -2
  52. package/src/tools/agent/agent.ts +20 -11
  53. package/src/tools/agent/skill.ts +32 -2
  54. package/src/tools/artifact/artifact.ts +144 -0
  55. package/src/tools/computer/app-launcher.ts +39 -0
  56. package/src/tools/computer/browser.ts +48 -0
  57. package/src/tools/computer/computer-use.ts +88 -0
  58. package/src/tools/computer/playwright.d.ts +7 -0
  59. package/src/tools/computer/screenshot.ts +57 -0
  60. package/src/tools/index.ts +6 -0
  61. package/src/ui/app.tsx +20 -7
  62. package/src/ui/chat.tsx +9 -5
  63. package/src/ui/commands.ts +185 -40
  64. package/src/ui/input.tsx +203 -27
  65. package/src/ui/picker.tsx +2 -5
  66. package/src/ui/vim-motions.ts +96 -0
  67. package/src/workflow/budget.ts +33 -0
  68. package/src/workflow/journal.ts +105 -0
  69. package/src/workflow/primitives/agent.ts +51 -0
  70. package/src/workflow/primitives/parallel.ts +8 -0
  71. package/src/workflow/primitives/phase.ts +19 -0
  72. package/src/workflow/primitives/pipeline.ts +24 -0
  73. package/src/workflow/runtime.ts +87 -0
  74. package/src/workflow/sandbox.ts +75 -0
@@ -1,56 +1,127 @@
1
- import type { ToolDefinition, ToolPermission, PermissionLevel, PermissionRule } from '../shared/index.ts'
1
+ import type {
2
+ ToolDefinition,
3
+ PermissionMode,
4
+ PermissionLevel,
5
+ PermissionRule,
6
+ } from '../shared/index.ts'
7
+ import type { PermissionRuleEntry } from '../shared/index.ts'
8
+ import { matchBashRule, compileRule } from './permission-rules'
9
+ import { loadPermissionConfig, nextMode, MODE_CYCLE } from './permission-config'
10
+
11
+ const VALID_MODES: Set<string> = new Set<string>(MODE_CYCLE)
2
12
 
3
13
  export class PermissionSystem {
4
- private rules = new Map<string, PermissionLevel>()
5
- private patternRules: Array<{ pattern: RegExp; level: PermissionLevel }> = []
14
+ private allowRules: PermissionRuleEntry[] = []
15
+ private denyRules: PermissionRuleEntry[] = []
16
+ private askRules: PermissionRuleEntry[] = []
17
+ /** Legacy exact-name rules for backward compat (set via setRule with 'auto' level). */
18
+ private legacyRules = new Map<string, PermissionLevel>()
19
+ /** Legacy default level from constructor when passed non-mode values like 'ask' or 'bypass'. */
20
+ private legacyDefaultFallback: PermissionLevel | null = null
21
+ private mode: PermissionMode = 'default'
6
22
 
7
- constructor(private defaultLevel: ToolPermission = 'auto') {}
23
+ constructor(modeOrLevel: PermissionLevel = 'default') {
24
+ if (VALID_MODES.has(modeOrLevel)) {
25
+ this.mode = modeOrLevel as PermissionMode
26
+ } else {
27
+ // Legacy values ('ask', 'bypass') → store as fallback, use 'default' mode
28
+ this.mode = 'default'
29
+ this.legacyDefaultFallback = modeOrLevel
30
+ }
31
+ }
8
32
 
9
- setDefaultLevel(level: PermissionLevel): void {
10
- this.defaultLevel = level
33
+ // ── Mode management ──
34
+
35
+ setMode(mode: PermissionMode): void {
36
+ this.mode = mode
11
37
  }
12
38
 
13
- getDefaultLevel(): PermissionLevel {
14
- return this.defaultLevel
39
+ getMode(): PermissionMode {
40
+ return this.mode
15
41
  }
16
42
 
17
- setRule(toolNameOrRule: string | PermissionRule, level?: PermissionLevel): void {
18
- if (typeof toolNameOrRule === 'string') {
19
- if (level) this.rules.set(toolNameOrRule, level)
20
- } else {
21
- const rule = toolNameOrRule
22
- if (rule.pattern) {
23
- this.patternRules.push({ pattern: new RegExp(rule.pattern), level: rule.level })
24
- } else {
25
- this.rules.set(rule.toolName, rule.level)
26
- }
27
- }
43
+ cycleMode(): PermissionMode {
44
+ this.mode = nextMode(this.mode)
45
+ return this.mode
28
46
  }
29
47
 
30
- removeRule(toolName: string): void {
31
- this.rules.delete(toolName)
48
+ // ── Rule management ──
49
+
50
+ allow(rule: string): void {
51
+ this.allowRules.push(compileRule(rule, 'allow'))
52
+ }
53
+
54
+ deny(rule: string): void {
55
+ this.denyRules.push(compileRule(rule, 'deny'))
32
56
  }
33
57
 
58
+ ask(rule: string): void {
59
+ this.askRules.push(compileRule(rule, 'ask'))
60
+ }
61
+
62
+ loadConfig(raw: { mode?: string; allow?: string[]; deny?: string[] }): void {
63
+ const config = loadPermissionConfig(
64
+ raw as Partial<{ mode: PermissionMode; allow: string[]; deny: string[] }>,
65
+ )
66
+ this.mode = config.mode
67
+
68
+ this.allowRules = []
69
+ this.denyRules = []
70
+ this.askRules = []
71
+
72
+ for (const rule of config.allow) {
73
+ this.allowRules.push(compileRule(rule, 'allow'))
74
+ }
75
+ for (const rule of config.deny) {
76
+ this.denyRules.push(compileRule(rule, 'deny'))
77
+ }
78
+ }
79
+
80
+ // ── Permission check ──
81
+
34
82
  /**
35
- * Resolve permission level for a tool call.
36
- *
37
- * Fallback chain: exact rule pattern rule → tool permission → system default
83
+ * Resolution chain (first match wins):
84
+ * 1. Deny rules → block
85
+ * 2. Ask rulesrequire approval
86
+ * 3. Allow rules → permit
87
+ * 4. Legacy exact-name rules (backward compat — e.g. setRule('tool', 'auto'))
88
+ * 5. Mode baseline → mode-specific default (overrides tool.permission for explicit modes)
89
+ * 6. Tool's own permission → tool-specific default (backward compat)
90
+ * 7. Legacy constructor fallback (when constructed with 'ask'/'bypass')
91
+ * 8. System default → 'ask'
38
92
  */
39
93
  check(tool: ToolDefinition, input: Record<string, unknown>): PermissionLevel {
40
- // 1. Exact-name rule wins
41
- const ruleLevel = this.rules.get(tool.name)
42
- if (ruleLevel) return ruleLevel
94
+ // 1. Check deny rules (always win)
95
+ for (const rule of this.denyRules) {
96
+ if (this.ruleMatches(rule, tool, input)) return 'ask'
97
+ }
98
+
99
+ // 2. Check ask rules
100
+ for (const rule of this.askRules) {
101
+ if (this.ruleMatches(rule, tool, input)) return 'ask'
102
+ }
43
103
 
44
- // 2. Pattern-based rules (e.g. "file_*" → bypass)
45
- for (const { pattern, level } of this.patternRules) {
46
- if (pattern.test(tool.name)) return level
104
+ // 3. Check allow rules
105
+ for (const rule of this.allowRules) {
106
+ if (this.ruleMatches(rule, tool, input)) return 'bypass'
47
107
  }
48
108
 
49
- // 3. Tool's own permission level
109
+ // 4. Legacy exact-name rules (backward compat)
110
+ const legacyLevel = this.legacyRules.get(tool.name)
111
+ if (legacyLevel !== undefined) return legacyLevel
112
+
113
+ // 5. Mode baseline
114
+ const baseline = this.modeBaseline(tool)
115
+ if (baseline !== 'mode-baseline') return baseline
116
+
117
+ // 6. Tool's own permission level (backward compat fallback)
50
118
  if (tool.permission) return tool.permission
51
119
 
52
- // 4. System default
53
- return this.defaultLevel
120
+ // 7. Legacy constructor fallback
121
+ if (this.legacyDefaultFallback) return this.legacyDefaultFallback
122
+
123
+ // 8. System default
124
+ return 'ask'
54
125
  }
55
126
 
56
127
  needsApproval(tool: ToolDefinition, input: Record<string, unknown>): boolean {
@@ -61,8 +132,127 @@ export class PermissionSystem {
61
132
  return this.check(tool, input) === 'bypass'
62
133
  }
63
134
 
135
+ // ── Helpers ──
136
+
137
+ private ruleMatches(
138
+ rule: PermissionRuleEntry,
139
+ tool: ToolDefinition,
140
+ input: Record<string, unknown>,
141
+ ): boolean {
142
+ // Try Bash-style matching first
143
+ if (rule.pattern.includes('(')) {
144
+ return matchBashRule(rule.pattern, tool.name, input)
145
+ }
146
+ // Simple tool name match
147
+ return rule.pattern === tool.name || rule.compiled.test(tool.name)
148
+ }
149
+
150
+ private modeBaseline(tool: ToolDefinition): PermissionLevel | 'mode-baseline' {
151
+ switch (this.mode) {
152
+ case 'default':
153
+ // Delegate to tool.permission (backward compat)
154
+ return 'mode-baseline'
155
+
156
+ case 'acceptEdits':
157
+ // Reads + file edits free; Bash requires approval
158
+ return tool.category === 'file'
159
+ ? ['Bash'].includes(tool.name)
160
+ ? 'ask'
161
+ : 'bypass'
162
+ : tool.name === 'Bash'
163
+ ? 'ask'
164
+ : 'ask'
165
+
166
+ case 'plan':
167
+ // Only reads, no writes or executes
168
+ return tool.category === 'file' && ['Read', 'Grep', 'Glob'].includes(tool.name)
169
+ ? 'bypass'
170
+ : 'ask'
171
+
172
+ case 'auto':
173
+ // Respect tool-level permissions; safety checks handled by hook layer
174
+ return 'mode-baseline'
175
+
176
+ case 'dontAsk':
177
+ // Only allowlisted tools free (already handled above); everything else requires approval
178
+ return 'ask'
179
+
180
+ case 'bypassPermissions':
181
+ return 'bypass'
182
+
183
+ default:
184
+ return 'mode-baseline'
185
+ }
186
+ }
187
+
188
+ // ── Legacy compatibility ──
189
+
190
+ setDefaultLevel(level: PermissionLevel): void {
191
+ // Map legacy 3-level to new mode
192
+ if (level === 'auto') this.mode = 'auto'
193
+ else if (level === 'bypass') this.mode = 'bypassPermissions'
194
+ else this.mode = 'default'
195
+ }
196
+
197
+ getDefaultLevel(): PermissionLevel {
198
+ // Legacy constructor fallback takes priority
199
+ if (this.legacyDefaultFallback) return this.legacyDefaultFallback
200
+ if (this.mode === 'auto' || this.mode === 'bypassPermissions' || this.mode === 'dontAsk')
201
+ return 'bypass'
202
+ if (this.mode === 'plan') return 'ask'
203
+ return 'auto'
204
+ }
205
+
206
+ setRule(toolNameOrRule: string | PermissionRule, level?: PermissionLevel): void {
207
+ if (typeof toolNameOrRule === 'string') {
208
+ const toolName = toolNameOrRule
209
+ // Remove old entries for this tool
210
+ this.removeRuleFromArrays(toolName)
211
+ if (level !== undefined) {
212
+ this.legacyRules.set(toolName, level)
213
+ // Also sync to new-style arrays for listRules / new API consistency
214
+ if (level === 'bypass') this.allow(toolName)
215
+ else if (level === 'ask') this.ask(toolName)
216
+ // 'auto' is stored only in legacyRules (returns 'auto', not 'bypass')
217
+ }
218
+ } else {
219
+ const rule = toolNameOrRule
220
+ if (rule.pattern) {
221
+ const entry = compileRule(rule.pattern, rule.level === 'bypass' ? 'allow' : 'ask')
222
+ if (rule.level === 'bypass') this.allowRules.push(entry)
223
+ else this.askRules.push(entry)
224
+ } else {
225
+ this.removeRuleFromArrays(rule.toolName)
226
+ this.legacyRules.set(rule.toolName, rule.level)
227
+ if (rule.level === 'bypass') this.allow(rule.toolName)
228
+ else if (rule.level === 'ask') this.ask(rule.toolName)
229
+ }
230
+ }
231
+ }
232
+
233
+ removeRule(toolName: string): void {
234
+ this.legacyRules.delete(toolName)
235
+ this.removeRuleFromArrays(toolName)
236
+ }
237
+
238
+ private removeRuleFromArrays(toolName: string): void {
239
+ this.allowRules = this.allowRules.filter((r) => r.pattern !== toolName)
240
+ this.denyRules = this.denyRules.filter((r) => r.pattern !== toolName)
241
+ this.askRules = this.askRules.filter((r) => r.pattern !== toolName)
242
+ }
243
+
64
244
  listRules(): Map<string, PermissionLevel> {
65
- return new Map(this.rules)
245
+ const map = new Map<string, PermissionLevel>(this.legacyRules)
246
+ for (const r of this.allowRules) {
247
+ if (!map.has(r.pattern)) map.set(r.pattern, 'bypass')
248
+ }
249
+ for (const r of this.denyRules) {
250
+ if (!map.has(r.pattern)) map.set(r.pattern, 'ask')
251
+ }
252
+ for (const r of this.askRules) {
253
+ if (!map.has(r.pattern)) map.set(r.pattern, 'ask')
254
+ }
255
+ return map
66
256
  }
67
257
 
68
258
  getByCategory(
package/src/index.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import { join } from 'node:path'
1
2
  import { render } from 'ink'
2
3
  import { App } from './ui/app'
3
4
  import { loadConfig } from './config/loader'
@@ -10,6 +11,10 @@ import { SkillsLoader } from './skills/loader'
10
11
  import { createToolRegistry } from './tools'
11
12
  import { McpClient } from './mcp/client'
12
13
  import { HookEngine } from './core/hooks'
14
+ import { ArtifactServer } from './artifacts/server'
15
+ import { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
16
+ import { AgentViewManager } from './agent-view/agent-view-manager'
17
+ import { AgentViewDashboard } from './agent-view/dashboard'
13
18
 
14
19
  interface RunOptions {
15
20
  model?: string
@@ -20,6 +25,23 @@ interface RunOptions {
20
25
  }
21
26
 
22
27
  export async function runApp(options: RunOptions): Promise<void> {
28
+ // Handle `mipham agents` subcommand — launch standalone dashboard
29
+ const args = process.argv.slice(2)
30
+ if (args[0] === 'agents') {
31
+ const agentViewManager = new AgentViewManager()
32
+ const { waitUntilExit } = render(
33
+ <AgentViewDashboard
34
+ manager={agentViewManager}
35
+ onAttach={() => {}}
36
+ onExit={() => process.exit(0)}
37
+ />,
38
+ )
39
+ await waitUntilExit()
40
+ process.exit(0)
41
+ }
42
+
43
+ // Create AgentViewManager for the full app (shared across the session)
44
+ const agentViewManager = new AgentViewManager()
23
45
  // Set terminal window title
24
46
  process.stdout.write('\x1b]0;Mipham Code\x07')
25
47
 
@@ -83,16 +105,22 @@ export async function runApp(options: RunOptions): Promise<void> {
83
105
  }
84
106
  }
85
107
 
108
+ // Start artifact server (lazy — first artifact creation triggers listening)
109
+ const artifactsDir = join(process.cwd(), MIPHAM_DIR, ARTIFACTS_DIR)
110
+ const artifactServer = new ArtifactServer(artifactsDir, ARTIFACT_PORT)
111
+
86
112
  // Create query engine
87
113
  const engine = new QueryEngine(registry, context, tools)
88
114
  engine.setHookEngine(hookEngine)
115
+ engine.setArtifactServer(artifactServer)
116
+ engine.setAgentViewManager(agentViewManager)
89
117
  engine.setupContextSummarizer()
90
118
 
91
119
  // Auto-save session on exit
92
- let autoSaveName: string | undefined
93
120
  const saveAndExit = () => {
121
+ artifactServer.stop()
94
122
  if (context.getMessageCount() > 0) {
95
- autoSaveName = SessionStore.autoSave(context.getMessages(), {
123
+ SessionStore.autoSave(context.getMessages(), {
96
124
  provider: defaultProvider,
97
125
  model: defaultModel,
98
126
  })
@@ -1,11 +1,51 @@
1
1
  import { spawn, type ChildProcess } from 'node:child_process'
2
2
  import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './types'
3
3
 
4
- type ResponseHandler = (response: JsonRpcResponse) => void
5
4
  type NotificationHandler = (notification: JsonRpcNotification) => void
6
5
 
7
6
  const REQUEST_TIMEOUT_MS = 30_000
8
7
 
8
+ // ── Environment variable security: block sensitive vars from MCP subprocess ──
9
+ //
10
+ // By default, MCP servers receive a sanitised copy of the parent environment.
11
+ // Sensitive values (API keys, tokens, secrets, cloud credentials) are stripped
12
+ // to prevent data exfiltration by third-party MCP plugins.
13
+ //
14
+ // The `env` parameter on `start()` allows explicit overrides — use it to
15
+ // intentionally pass specific values to a trusted MCP server.
16
+ const SENSITIVE_ENV_PATTERNS = [
17
+ /_API_KEY$/i, // ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.
18
+ /_SECRET$/i, // STRIPE_SECRET, etc.
19
+ /_TOKEN$/i, // GITHUB_TOKEN, NPM_TOKEN, etc.
20
+ /_PASSWORD$/i, // DB_PASSWORD, etc.
21
+ /_CREDENTIALS?$/i, // GOOGLE_APPLICATION_CREDENTIALS, etc.
22
+ /^AWS_/, // AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, etc.
23
+ /^GCLOUD_/, // GCP credentials
24
+ /^AZURE_/, // Azure credentials
25
+ /^DOCKER_/, // DOCKER_TOKEN, DOCKER_PASSWORD
26
+ ]
27
+
28
+ /**
29
+ * Build a sanitised environment for MCP subprocesses.
30
+ * Strips sensitive vars; allows explicit overrides via `extra`.
31
+ */
32
+ function buildProcEnv(extra?: Record<string, string>): Record<string, string> {
33
+ const sanitized: Record<string, string> = {}
34
+
35
+ for (const [key, value] of Object.entries(process.env as Record<string, string>)) {
36
+ if (value === undefined) continue
37
+ if (SENSITIVE_ENV_PATTERNS.some((p) => p.test(key))) continue
38
+ sanitized[key] = value
39
+ }
40
+
41
+ // Explicit overrides take precedence (for intentionally shared secrets)
42
+ if (extra) {
43
+ Object.assign(sanitized, extra)
44
+ }
45
+
46
+ return sanitized
47
+ }
48
+
9
49
  /**
10
50
  * MCP stdio transport — spawns a subprocess and communicates via
11
51
  * newline-delimited JSON-RPC 2.0 messages on stdin/stdout.
@@ -30,10 +70,7 @@ export class StdioTransport {
30
70
  async start(command: string, args: string[], env?: Record<string, string>): Promise<void> {
31
71
  this.closed = false
32
72
 
33
- const procEnv: Record<string, string> = {
34
- ...(process.env as Record<string, string>),
35
- ...env,
36
- }
73
+ const procEnv = buildProcEnv(env)
37
74
 
38
75
  return new Promise((resolve, reject) => {
39
76
  const child = spawn(command, args, {
@@ -0,0 +1,36 @@
1
+ import type { PluginManager } from './plugin-manager'
2
+ import { join } from 'node:path'
3
+ import { existsSync } from 'node:fs'
4
+
5
+ export function loadPlugins(
6
+ pluginManager: PluginManager,
7
+ engine: unknown,
8
+ skillsLoader: unknown,
9
+ ): void {
10
+ for (const plugin of pluginManager.getEnabled()) {
11
+ // Load custom agents
12
+ const agentsDir = join(plugin.path, 'agents')
13
+ if (existsSync(agentsDir)) {
14
+ // → register with AgentRegistry
15
+ }
16
+
17
+ // Load custom skills
18
+ const skillsDir = join(plugin.path, 'skills')
19
+ if (existsSync(skillsDir)) {
20
+ // → register with SkillsLoader
21
+ }
22
+
23
+ // Load MCP servers
24
+ const mcpDir = join(plugin.path, 'mcp-servers')
25
+ if (existsSync(mcpDir)) {
26
+ // → register MCP configs
27
+ }
28
+
29
+ // Load hooks
30
+ // → parse plugin.json hooks → register with HookEngine
31
+ }
32
+
33
+ // Suppress unused parameter warnings — these are wiring stubs for future phases
34
+ void engine
35
+ void skillsLoader
36
+ }
@@ -0,0 +1,125 @@
1
+ import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+ import { validatePlugin } from './plugin-validator'
5
+
6
+ const PLUGIN_DIR = join(homedir(), '.mipham', 'plugins')
7
+
8
+ export interface InstalledPlugin {
9
+ name: string
10
+ version: string
11
+ path: string
12
+ enabled: boolean
13
+ installedAt: string
14
+ }
15
+
16
+ export class PluginManager {
17
+ private plugins: InstalledPlugin[] = []
18
+ private pluginDir: string
19
+ private statePath: string
20
+
21
+ constructor(pluginDir?: string) {
22
+ this.pluginDir = pluginDir ?? PLUGIN_DIR
23
+ mkdirSync(this.pluginDir, { recursive: true })
24
+ this.statePath = join(this.pluginDir, 'state.json')
25
+ this.loadState()
26
+ }
27
+
28
+ install(sourcePath: string): { success: boolean; message: string } {
29
+ const validation = validatePlugin(sourcePath)
30
+ if (!validation.valid || !validation.manifest) {
31
+ return { success: false, message: validation.errors.join('; ') }
32
+ }
33
+
34
+ const destDir = join(this.pluginDir, validation.manifest.name)
35
+ if (existsSync(destDir)) {
36
+ return {
37
+ success: false,
38
+ message: `Plugin "${validation.manifest.name}" is already installed`,
39
+ }
40
+ }
41
+
42
+ // Copy plugin directory
43
+ mkdirSync(destDir, { recursive: true })
44
+ this.copyDir(sourcePath, destDir)
45
+
46
+ this.plugins.push({
47
+ name: validation.manifest.name,
48
+ version: validation.manifest.version,
49
+ path: destDir,
50
+ enabled: true,
51
+ installedAt: new Date().toISOString(),
52
+ })
53
+
54
+ this.saveState()
55
+ return {
56
+ success: true,
57
+ message: `Plugin "${validation.manifest.name}" v${validation.manifest.version} installed`,
58
+ }
59
+ }
60
+
61
+ list(): InstalledPlugin[] {
62
+ return [...this.plugins]
63
+ }
64
+
65
+ remove(name: string): boolean {
66
+ const plugin = this.plugins.find((p) => p.name === name)
67
+ if (!plugin) return false
68
+ try {
69
+ rmSync(plugin.path, { recursive: true, force: true })
70
+ } catch {
71
+ // Directory may already be gone — that's fine
72
+ }
73
+ this.plugins = this.plugins.filter((p) => p.name !== name)
74
+ this.saveState()
75
+ return true
76
+ }
77
+
78
+ enable(name: string): boolean {
79
+ const p = this.plugins.find((p) => p.name === name)
80
+ if (!p) return false
81
+ p.enabled = true
82
+ this.saveState()
83
+ return true
84
+ }
85
+
86
+ disable(name: string): boolean {
87
+ const p = this.plugins.find((p) => p.name === name)
88
+ if (!p) return false
89
+ p.enabled = false
90
+ this.saveState()
91
+ return true
92
+ }
93
+
94
+ getEnabled(): InstalledPlugin[] {
95
+ return this.plugins.filter((p) => p.enabled)
96
+ }
97
+
98
+ private copyDir(src: string, dest: string): void {
99
+ mkdirSync(dest, { recursive: true })
100
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
101
+ const srcPath = join(src, entry.name)
102
+ const destPath = join(dest, entry.name)
103
+ if (entry.isDirectory()) {
104
+ this.copyDir(srcPath, destPath)
105
+ } else {
106
+ const content = readFileSync(srcPath)
107
+ writeFileSync(destPath, content)
108
+ }
109
+ }
110
+ }
111
+
112
+ private loadState(): void {
113
+ try {
114
+ if (existsSync(this.statePath)) {
115
+ this.plugins = JSON.parse(readFileSync(this.statePath, 'utf-8'))
116
+ }
117
+ } catch {
118
+ this.plugins = []
119
+ }
120
+ }
121
+
122
+ private saveState(): void {
123
+ writeFileSync(this.statePath, JSON.stringify(this.plugins, null, 2), 'utf-8')
124
+ }
125
+ }
@@ -0,0 +1,41 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ export interface PluginManifest {
5
+ name: string
6
+ version: string
7
+ miphamVersion?: string
8
+ hooks?: Record<string, unknown>
9
+ }
10
+
11
+ export function validatePlugin(dir: string): {
12
+ valid: boolean
13
+ errors: string[]
14
+ manifest?: PluginManifest
15
+ } {
16
+ const errors: string[] = []
17
+
18
+ try {
19
+ const raw = readFileSync(join(dir, 'plugin.json'), 'utf-8')
20
+ const manifest = JSON.parse(raw) as PluginManifest
21
+
22
+ if (!manifest.name || !/^[a-z0-9-]+$/.test(manifest.name)) {
23
+ errors.push('Invalid plugin name: must be lowercase alphanumeric with hyphens')
24
+ }
25
+ if (!manifest.version) {
26
+ errors.push('Missing required field: version')
27
+ }
28
+
29
+ // Check for suspicious hooks
30
+ if (manifest.hooks) {
31
+ const hooksStr = JSON.stringify(manifest.hooks)
32
+ if (hooksStr.includes('rm -rf') || hooksStr.includes('curl') || hooksStr.includes('eval')) {
33
+ errors.push('Suspicious hook commands detected — manual review required')
34
+ }
35
+ }
36
+
37
+ return { valid: errors.length === 0, errors, manifest }
38
+ } catch (err) {
39
+ return { valid: false, errors: [`Failed to read plugin.json: ${String(err)}`] }
40
+ }
41
+ }
@@ -39,9 +39,7 @@ export async function fetchWithRetry(
39
39
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
40
40
  const controller = new AbortController()
41
41
  const timer = setTimeout(() => controller.abort(), timeout)
42
- const signal = init.signal
43
- ? anySignal([init.signal, controller.signal])
44
- : controller.signal
42
+ const signal = init.signal ? anySignal([init.signal, controller.signal]) : controller.signal
45
43
 
46
44
  try {
47
45
  const response = await fetch(url, { ...init, signal })
@@ -1,10 +1,4 @@
1
- import type {
2
- ProviderConfig,
3
- ModelInfo,
4
- Message,
5
- StreamChunk,
6
- ContentBlock,
7
- } from '../shared/index.ts'
1
+ import type { ProviderConfig, ModelInfo, Message, StreamChunk } from '../shared/index.ts'
8
2
  import type { ProviderInstance, ChatRequest } from './registry'
9
3
  import { fetchWithRetry } from './fetch-utils'
10
4
 
@@ -49,10 +43,7 @@ export class OpenAICompatProvider implements ProviderInstance {
49
43
  let buffer = ''
50
44
 
51
45
  // Track incremental tool calls across streaming deltas
52
- const pendingToolCalls = new Map<
53
- number,
54
- { id: string; name: string; arguments: string }
55
- >()
46
+ const pendingToolCalls = new Map<number, { id: string; name: string; arguments: string }>()
56
47
 
57
48
  while (true) {
58
49
  const { done, value } = await reader.read()
@@ -361,8 +361,15 @@ export const PROTOCOL_LABELS: Record<string, string> = {
361
361
  custom: 'Custom Protocol',
362
362
  }
363
363
 
364
- export const TOOL_CATEGORIES = ['file', 'exec', 'agent', 'network', 'system'] as const
364
+ export const TOOL_CATEGORIES = ['file', 'exec', 'agent', 'network', 'system', 'artifact'] as const
365
365
  export const CONFIG_FILE_NAME = 'config.yml'
366
366
  export const MIPHAM_DIR = '.mipham'
367
367
  export const USER_CONFIG_DIR = '.mipham'
368
368
  export const MEMORY_DIR = 'memory'
369
+
370
+ // ── Artifact Constants ──
371
+ export const ARTIFACTS_DIR = 'artifacts'
372
+ export const ARTIFACT_PORT = 9876
373
+ export const ARTIFACT_MAX_PORT_TRIES = 10
374
+ export const ARTIFACT_MAX_SIZE = 5 * 1024 * 1024 // 5MB
375
+ export const ARTIFACT_ALLOWED_EXTENSIONS = ['.html', '.svg']