@miphamai/cli 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -6,6 +6,8 @@
6
6
  * can be queried via the Task tool (output/stop actions) or the Agent View.
7
7
  */
8
8
 
9
+ export type BackgroundTaskKind = 'interactive' | 'forked' | 'attached' | 'unattended'
10
+
9
11
  export type BackgroundTaskStatus = 'running' | 'completed' | 'failed'
10
12
 
11
13
  export interface BackgroundTask {
@@ -13,10 +15,17 @@ export interface BackgroundTask {
13
15
  description: string
14
16
  agentType: string
15
17
  status: BackgroundTaskStatus
18
+ kind: BackgroundTaskKind
16
19
  startedAt: Date
17
20
  completedAt?: Date
18
21
  result?: string
19
22
  error?: string
23
+ /** Git worktree path (forked tasks) */
24
+ worktree?: string
25
+ /** Git branch name (forked tasks) */
26
+ branch?: string
27
+ /** Created PR URL (if requested) */
28
+ prUrl?: string
20
29
  abortController: AbortController
21
30
  }
22
31
 
@@ -39,6 +48,7 @@ export class BackgroundAgentRegistry {
39
48
  description: string,
40
49
  agentType: string,
41
50
  executor: (signal: AbortSignal) => Promise<string>,
51
+ kind: BackgroundTaskKind = 'interactive',
42
52
  ): string {
43
53
  const id = `bg-${++this.idCounter}-${Date.now().toString(36)}`
44
54
 
@@ -47,6 +57,7 @@ export class BackgroundAgentRegistry {
47
57
  description,
48
58
  agentType,
49
59
  status: 'running',
60
+ kind,
50
61
  startedAt: new Date(),
51
62
  abortController: new AbortController(),
52
63
  }
@@ -13,10 +13,13 @@ export interface SessionMessage {
13
13
  content: string
14
14
  }
15
15
 
16
+ export type SessionKind = 'interactive' | 'forked' | 'attached' | 'unattended'
17
+
16
18
  export interface AgentSession {
17
19
  id: string
18
20
  title: string
19
21
  status: SessionStatus
22
+ kind: SessionKind
20
23
  provider: string
21
24
  model: string
22
25
  task: string
@@ -25,6 +28,12 @@ export interface AgentSession {
25
28
  completedAt?: Date
26
29
  elapsedMs: number
27
30
  messages: SessionMessage[]
31
+ /** Git worktree path (forked sessions) */
32
+ worktree?: string
33
+ /** Git branch name (forked sessions) */
34
+ branch?: string
35
+ /** Created PR URL (if requested) */
36
+ prUrl?: string
28
37
  }
29
38
 
30
39
  export interface CreateSessionOptions {
@@ -53,6 +62,7 @@ export class AgentViewManager {
53
62
  id,
54
63
  title,
55
64
  status: 'needs-input',
65
+ kind: 'interactive',
56
66
  provider: options.provider ?? 'unknown',
57
67
  model: options.model ?? 'unknown',
58
68
  task,
@@ -1,4 +1,9 @@
1
- import type { MiphamConfig, InferenceHookConfig } from '../shared/index.ts'
1
+ import type {
2
+ MiphamConfig,
3
+ InferenceHookConfig,
4
+ CredentialMaskingConfig,
5
+ BackgroundAgentConfig,
6
+ } from '../shared/index.ts'
2
7
  import { DEFAULT_PROVIDERS } from '../shared/index.ts'
3
8
  import { PACKAGE_VERSION } from '../shared/index.ts'
4
9
 
@@ -18,3 +23,23 @@ export const DEFAULT_INFERENCE_HOOK_CONFIG: InferenceHookConfig = {
18
23
  organization_id: '',
19
24
  headers: {},
20
25
  }
26
+
27
+ export const DEFAULT_CREDENTIAL_MASKING_CONFIG: CredentialMaskingConfig = {
28
+ enabled: true,
29
+ files: [],
30
+ output_scrubbing: {
31
+ enabled: true,
32
+ patterns: ['(?i)(api[_-]?key|secret|token|password|credential)\\s*[:=]\\s*\\S+'],
33
+ },
34
+ env_filter: {
35
+ enabled: true,
36
+ patterns: ['(?i)(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)$'],
37
+ },
38
+ }
39
+
40
+ export const DEFAULT_BACKGROUND_AGENT_CONFIG: BackgroundAgentConfig = {
41
+ auto_commit: true,
42
+ auto_push: true,
43
+ auto_worktree: true,
44
+ commit_coauthors: true,
45
+ }
@@ -15,8 +15,15 @@ import type {
15
15
  ProviderConfig,
16
16
  McpServerConfig,
17
17
  InferenceHookConfig,
18
+ CredentialMaskingConfig,
19
+ BackgroundAgentConfig,
18
20
  } from '../shared/index.ts'
19
- import { DEFAULT_CONFIG, DEFAULT_INFERENCE_HOOK_CONFIG } from './defaults'
21
+ import {
22
+ DEFAULT_CONFIG,
23
+ DEFAULT_INFERENCE_HOOK_CONFIG,
24
+ DEFAULT_CREDENTIAL_MASKING_CONFIG,
25
+ DEFAULT_BACKGROUND_AGENT_CONFIG,
26
+ } from './defaults'
20
27
 
21
28
  const MIPHAM_HOME = join(homedir(), '.mipham')
22
29
  const BACKUP_PREFIX = 'config.backup-'
@@ -298,3 +305,74 @@ export function loadInferenceHookConfig(cwd: string = process.cwd()): InferenceH
298
305
 
299
306
  return merged
300
307
  }
308
+
309
+ /**
310
+ * Load credential masking configuration from the same config sources.
311
+ * Merges project-level over user-level. Returns defaults if no section present.
312
+ */
313
+ export function loadCredentialMaskingConfig(cwd: string = process.cwd()): CredentialMaskingConfig {
314
+ const configPath = join(cwd, '.mipham', 'config.yml')
315
+ const userConfigPath = join(MIPHAM_HOME, 'config.yml')
316
+
317
+ let merged = { ...DEFAULT_CREDENTIAL_MASKING_CONFIG }
318
+
319
+ const paths = [userConfigPath, configPath] // project wins (loaded last)
320
+ for (const path of paths) {
321
+ try {
322
+ if (!existsSync(path)) continue
323
+ const raw = readFileSync(path, 'utf-8')
324
+ const parsed = parseYaml(raw) as Record<string, unknown>
325
+ const section = parsed.credential_masking as Partial<CredentialMaskingConfig> | undefined
326
+ if (section) {
327
+ merged = {
328
+ enabled: section.enabled ?? merged.enabled,
329
+ files: section.files ?? merged.files,
330
+ output_scrubbing: {
331
+ enabled: section.output_scrubbing?.enabled ?? merged.output_scrubbing.enabled,
332
+ patterns: section.output_scrubbing?.patterns ?? merged.output_scrubbing.patterns,
333
+ },
334
+ env_filter: {
335
+ enabled: section.env_filter?.enabled ?? merged.env_filter.enabled,
336
+ patterns: section.env_filter?.patterns ?? merged.env_filter.patterns,
337
+ },
338
+ }
339
+ }
340
+ } catch {
341
+ // Silently skip malformed configs
342
+ }
343
+ }
344
+
345
+ return merged
346
+ }
347
+
348
+ /**
349
+ * Load background agent configuration from config sources.
350
+ */
351
+ export function loadBackgroundAgentConfig(cwd: string = process.cwd()): BackgroundAgentConfig {
352
+ const configPath = join(cwd, '.mipham', 'config.yml')
353
+ const userConfigPath = join(MIPHAM_HOME, 'config.yml')
354
+
355
+ let merged = { ...DEFAULT_BACKGROUND_AGENT_CONFIG }
356
+
357
+ const paths = [userConfigPath, configPath]
358
+ for (const path of paths) {
359
+ try {
360
+ if (!existsSync(path)) continue
361
+ const raw = readFileSync(path, 'utf-8')
362
+ const parsed = parseYaml(raw) as Record<string, unknown>
363
+ const section = parsed.background_agent as Partial<BackgroundAgentConfig> | undefined
364
+ if (section) {
365
+ merged = {
366
+ auto_commit: section.auto_commit ?? merged.auto_commit,
367
+ auto_push: section.auto_push ?? merged.auto_push,
368
+ auto_worktree: section.auto_worktree ?? merged.auto_worktree,
369
+ commit_coauthors: section.commit_coauthors ?? merged.commit_coauthors,
370
+ }
371
+ }
372
+ } catch {
373
+ // Silently skip malformed configs
374
+ }
375
+ }
376
+
377
+ return merged
378
+ }
@@ -0,0 +1,203 @@
1
+ import { relative, isAbsolute } from 'node:path'
2
+ import { homedir } from 'node:os'
3
+ import type { CredentialMaskingConfig, CredentialFileRule } from '../shared/index.ts'
4
+
5
+ /** Sentinel value used to replace masked credentials. */
6
+ export const CREDENTIAL_SENTINEL = '__MIPHAM_CREDENTIAL_MASKED__'
7
+
8
+ /**
9
+ * Check if a file path matches any credential file rule.
10
+ * Returns the matching rule, or null if no match.
11
+ */
12
+ export function matchCredentialFile(
13
+ filePath: string,
14
+ config: CredentialMaskingConfig,
15
+ ): CredentialFileRule | null {
16
+ if (!config.enabled || config.files.length === 0) return null
17
+
18
+ const expanded = expandHome(filePath)
19
+ for (const rule of config.files) {
20
+ if (matchPath(expanded, rule.path)) {
21
+ return rule
22
+ }
23
+ }
24
+ return null
25
+ }
26
+
27
+ /**
28
+ * Apply credential masking to file content.
29
+ * - 'full' mode: return sentinel value only
30
+ * - 'extract' mode: replace regex-matched tokens with sentinel
31
+ */
32
+ export function maskContent(content: string, rule: CredentialFileRule): string {
33
+ if (rule.mode === 'full') {
34
+ return CREDENTIAL_SENTINEL
35
+ }
36
+
37
+ // extract mode
38
+ let masked = content
39
+ for (const extractRule of rule.extract) {
40
+ const replacement = extractRule.replacement || CREDENTIAL_SENTINEL
41
+ try {
42
+ const regex = new RegExp(extractRule.pattern, 'gm')
43
+ masked = masked.replace(regex, replacement)
44
+ } catch {
45
+ // Invalid regex — skip this rule silently
46
+ }
47
+ }
48
+ return masked
49
+ }
50
+
51
+ /**
52
+ * Scrub credential patterns from stdout/stderr output.
53
+ * Uses the configured output_scrubbing patterns to detect and replace
54
+ * credentials that may have leaked into command output.
55
+ */
56
+ export function maskOutput(output: string, config: CredentialMaskingConfig): string {
57
+ if (!config.enabled || !config.output_scrubbing.enabled) return output
58
+
59
+ let masked = output
60
+ for (const pattern of config.output_scrubbing.patterns) {
61
+ try {
62
+ // Strip (?i) inline flags — JS uses the 'i' flag instead
63
+ const clean = pattern.replace(/^\(\?i\)/, '')
64
+ const regex = new RegExp(clean, 'gim')
65
+ masked = masked.replace(regex, (match) => {
66
+ // Replace everything after the separator (= or :)
67
+ return match.replace(/\s*[:=]\s*\S+/, `=${CREDENTIAL_SENTINEL}`)
68
+ })
69
+ } catch {
70
+ // Invalid regex — skip
71
+ }
72
+ }
73
+ return masked
74
+ }
75
+
76
+ /**
77
+ * Filter sensitive environment variables before spawning a subprocess.
78
+ * Returns a sanitized copy of process.env with matching vars removed.
79
+ */
80
+ export function filterEnv(
81
+ env: Record<string, string | undefined>,
82
+ config: CredentialMaskingConfig,
83
+ ): Record<string, string | undefined> {
84
+ if (!config.enabled || !config.env_filter.enabled) return { ...env }
85
+
86
+ const filtered: Record<string, string | undefined> = {}
87
+ for (const [key, value] of Object.entries(env)) {
88
+ let blocked = false
89
+ for (const pattern of config.env_filter.patterns) {
90
+ try {
91
+ // Strip (?i) inline flags — JS uses the 'i' flag instead
92
+ const clean = pattern.replace(/^\(\?i\)/, '')
93
+ const regex = new RegExp(clean, 'i')
94
+ if (regex.test(key)) {
95
+ blocked = true
96
+ break
97
+ }
98
+ } catch {
99
+ // Invalid regex — skip
100
+ }
101
+ }
102
+ if (blocked) {
103
+ // Replace with sentinel so tools can detect they're filtered
104
+ filtered[key] = CREDENTIAL_SENTINEL
105
+ } else {
106
+ filtered[key] = value
107
+ }
108
+ }
109
+
110
+ return filtered
111
+ }
112
+
113
+ /**
114
+ * Get the file display name for a sentinel value (for model visibility).
115
+ * The model sees the sentinel, doesn't know the original file path.
116
+ */
117
+ export function getSentinelDisplay(hint?: string): string {
118
+ if (hint) {
119
+ return `[Credential file masked: ${hint}]`
120
+ }
121
+ return CREDENTIAL_SENTINEL
122
+ }
123
+
124
+ // ── Internal helpers ──
125
+
126
+ /**
127
+ * Expand ~ to the user's home directory.
128
+ */
129
+ function expandHome(p: string): string {
130
+ if (p.startsWith('~')) {
131
+ return homedir() + p.slice(1)
132
+ }
133
+ return p
134
+ }
135
+
136
+ /**
137
+ * Match a file path against a glob-like pattern.
138
+ * Supports:
139
+ * - ** globstar (matches any number of path segments)
140
+ * - * wildcard within a single path segment
141
+ * - ~ home directory expansion
142
+ * - basename-only matching (e.g. ".env*" matches any .env file)
143
+ */
144
+ function matchPath(filePath: string, pattern: string): boolean {
145
+ const expandedPattern = expandHome(pattern)
146
+ const normalizedFile = filePath.replace(/\\/g, '/')
147
+ const normalizedPattern = expandedPattern.replace(/\\/g, '/')
148
+
149
+ // Exact match
150
+ if (normalizedFile === normalizedPattern) {
151
+ return true
152
+ }
153
+
154
+ // File ends with pattern (for relative basename matching like ".env*")
155
+ if (normalizedFile.endsWith('/' + normalizedPattern)) {
156
+ return true
157
+ }
158
+
159
+ // Convert glob pattern to regex
160
+ const regex = globToRegex(normalizedPattern)
161
+ return regex.test(normalizedFile)
162
+ }
163
+
164
+ /**
165
+ * Convert a glob pattern to a RegExp.
166
+ * - ** → .* (matches any path segments)
167
+ * - * → [^/]* (matches within a single segment)
168
+ * - ? → . (single char)
169
+ * - All other special regex chars are escaped.
170
+ */
171
+ function globToRegex(pattern: string): RegExp {
172
+ let regexStr = ''
173
+ let i = 0
174
+ while (i < pattern.length) {
175
+ const ch = pattern[i]!
176
+ if (ch === '*' && pattern[i + 1] === '*') {
177
+ // ** — match zero or more path segments
178
+ regexStr += '.*'
179
+ i += 2
180
+ // Skip trailing slash after **
181
+ if (pattern[i] === '/') i++
182
+ continue
183
+ }
184
+ if (ch === '*') {
185
+ regexStr += '[^/]*'
186
+ i++
187
+ continue
188
+ }
189
+ if (ch === '?') {
190
+ regexStr += '.'
191
+ i++
192
+ continue
193
+ }
194
+ // Escape regex special chars
195
+ if ('\\^$.|+()[]{}'.includes(ch)) {
196
+ regexStr += '\\' + ch
197
+ } else {
198
+ regexStr += ch
199
+ }
200
+ i++
201
+ }
202
+ return new RegExp('^' + regexStr + '$')
203
+ }
@@ -20,6 +20,17 @@ export class PermissionSystem {
20
20
  private legacyDefaultFallback: PermissionLevel | null = null
21
21
  private mode: PermissionMode = 'default'
22
22
 
23
+ // ── Permission cache (P2) ──
24
+ /** Short-lived cache: toolName+input → permissionLevel. Invalidated on rule/mode change. */
25
+ private checkCache = new Map<string, PermissionLevel>()
26
+ private cacheMode: PermissionMode | null = null
27
+
28
+ /** Invalidate the permission cache (called on any rule/mode change). */
29
+ private invalidateCache(): void {
30
+ this.checkCache.clear()
31
+ this.cacheMode = null
32
+ }
33
+
23
34
  constructor(modeOrLevel: PermissionLevel = 'default') {
24
35
  if (VALID_MODES.has(modeOrLevel)) {
25
36
  this.mode = modeOrLevel as PermissionMode
@@ -34,6 +45,7 @@ export class PermissionSystem {
34
45
 
35
46
  setMode(mode: PermissionMode): void {
36
47
  this.mode = mode
48
+ this.invalidateCache()
37
49
  }
38
50
 
39
51
  getMode(): PermissionMode {
@@ -75,6 +87,8 @@ export class PermissionSystem {
75
87
  for (const rule of config.deny) {
76
88
  this.denyRules.push(compileRule(rule, 'deny'))
77
89
  }
90
+
91
+ this.invalidateCache()
78
92
  }
79
93
 
80
94
  // ── Permission check ──
@@ -91,37 +105,74 @@ export class PermissionSystem {
91
105
  * 8. System default → 'ask'
92
106
  */
93
107
  check(tool: ToolDefinition, input: Record<string, unknown>): PermissionLevel {
108
+ // ── Cache lookup (P2): reuse decision for same tool+mode+input ──
109
+ const cacheKey = tool.name + '|' + JSON.stringify(input, Object.keys(input).sort())
110
+ if (this.cacheMode === this.mode) {
111
+ const cached = this.checkCache.get(cacheKey)
112
+ if (cached !== undefined) return cached
113
+ } else {
114
+ // Mode changed — invalidate entire cache
115
+ this.checkCache.clear()
116
+ this.cacheMode = this.mode
117
+ }
118
+
94
119
  // 1. Check deny rules (always win)
95
120
  for (const rule of this.denyRules) {
96
- if (this.ruleMatches(rule, tool, input)) return 'ask'
121
+ if (this.ruleMatches(rule, tool, input)) {
122
+ const result: PermissionLevel = 'ask'
123
+ this.checkCache.set(cacheKey, result)
124
+ return result
125
+ }
97
126
  }
98
127
 
99
128
  // 2. Check ask rules
100
129
  for (const rule of this.askRules) {
101
- if (this.ruleMatches(rule, tool, input)) return 'ask'
130
+ if (this.ruleMatches(rule, tool, input)) {
131
+ const result: PermissionLevel = 'ask'
132
+ this.checkCache.set(cacheKey, result)
133
+ return result
134
+ }
102
135
  }
103
136
 
104
137
  // 3. Check allow rules
105
138
  for (const rule of this.allowRules) {
106
- if (this.ruleMatches(rule, tool, input)) return 'bypass'
139
+ if (this.ruleMatches(rule, tool, input)) {
140
+ const result: PermissionLevel = 'bypass'
141
+ this.checkCache.set(cacheKey, result)
142
+ return result
143
+ }
107
144
  }
108
145
 
109
146
  // 4. Legacy exact-name rules (backward compat)
110
147
  const legacyLevel = this.legacyRules.get(tool.name)
111
- if (legacyLevel !== undefined) return legacyLevel
148
+ if (legacyLevel !== undefined) {
149
+ this.checkCache.set(cacheKey, legacyLevel)
150
+ return legacyLevel
151
+ }
112
152
 
113
153
  // 5. Mode baseline
114
154
  const baseline = this.modeBaseline(tool)
115
- if (baseline !== 'mode-baseline') return baseline
155
+ if (baseline !== 'mode-baseline') {
156
+ this.checkCache.set(cacheKey, baseline)
157
+ return baseline
158
+ }
116
159
 
117
160
  // 6. Tool's own permission level (backward compat fallback)
118
- if (tool.permission) return tool.permission
161
+ if (tool.permission) {
162
+ this.checkCache.set(cacheKey, tool.permission)
163
+ return tool.permission
164
+ }
119
165
 
120
166
  // 7. Legacy constructor fallback
121
- if (this.legacyDefaultFallback) return this.legacyDefaultFallback
167
+ if (this.legacyDefaultFallback) {
168
+ this.checkCache.set(cacheKey, this.legacyDefaultFallback)
169
+ return this.legacyDefaultFallback
170
+ }
122
171
 
123
172
  // 8. System default
124
- return 'ask'
173
+ const result: PermissionLevel = 'ask'
174
+ this.checkCache.set(cacheKey, result)
175
+ return result
125
176
  }
126
177
 
127
178
  needsApproval(tool: ToolDefinition, input: Record<string, unknown>): boolean {
@@ -193,6 +244,7 @@ export class PermissionSystem {
193
244
  if (level === 'auto') this.mode = 'auto'
194
245
  else if (level === 'bypass') this.mode = 'bypassPermissions'
195
246
  else this.mode = 'default'
247
+ this.invalidateCache()
196
248
  }
197
249
 
198
250
  getDefaultLevel(): PermissionLevel {
@@ -229,11 +281,13 @@ export class PermissionSystem {
229
281
  else if (rule.level === 'ask') this.ask(rule.toolName)
230
282
  }
231
283
  }
284
+ this.invalidateCache()
232
285
  }
233
286
 
234
287
  removeRule(toolName: string): void {
235
288
  this.legacyRules.delete(toolName)
236
289
  this.removeRuleFromArrays(toolName)
290
+ this.invalidateCache()
237
291
  }
238
292
 
239
293
  private removeRuleFromArrays(toolName: string): void {
package/src/index.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { join } from 'node:path'
2
2
  import { render } from 'ink'
3
3
  import { App } from './ui/app'
4
- import { loadConfig, loadInferenceHookConfig } from './config/loader'
4
+ import { loadConfig, loadInferenceHookConfig, loadCredentialMaskingConfig } from './config/loader'
5
5
  import { bootstrapProviders } from './providers/bootstrap'
6
6
  import { InstructionsLoader } from './core/instructions'
7
7
  import { loadSessionMemories } from './core/memory/memory-loader'
@@ -157,6 +157,13 @@ export async function runApp(options: RunOptions): Promise<void> {
157
157
  const inferenceHookConfig = loadInferenceHookConfig()
158
158
  engine.setInferenceHookConfig(inferenceHookConfig)
159
159
 
160
+ // Wire credential masking configuration into tools
161
+ const credentialMaskingConfig = loadCredentialMaskingConfig()
162
+ const { setCredentialMaskingConfigForRead } = await import('./tools/file/read')
163
+ const { setCredentialMaskingConfigForBash } = await import('./tools/exec/bash')
164
+ setCredentialMaskingConfigForRead(credentialMaskingConfig)
165
+ setCredentialMaskingConfigForBash(credentialMaskingConfig)
166
+
160
167
  // Sync engine permission with config (fix: UI shows "auto" but engine defaulted to bypass-legacy)
161
168
  if (config.permission) {
162
169
  engine.getPermission().setDefaultLevel(config.permission as PermissionLevel)
@@ -45,6 +45,7 @@ export class PluginManager {
45
45
  mkdirSync(destDir, { recursive: true })
46
46
  this.copyDir(sourcePath, destDir)
47
47
 
48
+ const similarWarning = this.findSimilarWarning(validation.manifest.name)
48
49
  this.plugins.push({
49
50
  name: validation.manifest.name,
50
51
  version: validation.manifest.version,
@@ -56,7 +57,9 @@ export class PluginManager {
56
57
  this.saveState()
57
58
  return {
58
59
  success: true,
59
- message: `Plugin "${validation.manifest.name}" v${validation.manifest.version} installed`,
60
+ message:
61
+ `Plugin "${validation.manifest.name}" v${validation.manifest.version} installed` +
62
+ (similarWarning ? `\n⚠ ${similarWarning}` : ''),
60
63
  }
61
64
  }
62
65
 
@@ -120,9 +123,12 @@ export class PluginManager {
120
123
  })
121
124
 
122
125
  this.saveState()
126
+ const similarWarning = this.findSimilarWarning(pluginName)
123
127
  return {
124
128
  success: true,
125
- message: `Plugin "${pluginName}" installed from npm`,
129
+ message:
130
+ `Plugin "${pluginName}" installed from npm` +
131
+ (similarWarning ? `\n⚠ ${similarWarning}` : ''),
126
132
  }
127
133
  } catch (err: unknown) {
128
134
  const msg = err instanceof Error ? err.message : String(err)
@@ -140,6 +146,37 @@ export class PluginManager {
140
146
  return [...this.plugins]
141
147
  }
142
148
 
149
+ /**
150
+ * Check if the new plugin name is similar to any existing installed plugin.
151
+ * Warns about potential name squatting / confusion.
152
+ * Returns a warning string or empty string.
153
+ */
154
+ private findSimilarWarning(newName: string): string {
155
+ const existing = this.plugins.map((p) => p.name)
156
+ const similar = existing.filter((name) => this.areNamesSimilar(newName, name))
157
+ if (similar.length === 0) return ''
158
+ return `Similar plugin${similar.length > 1 ? 's' : ''} already installed: ${similar.join(', ')}. Verify you trust this source.`
159
+ }
160
+
161
+ /**
162
+ * Two names are "similar" if:
163
+ * - One is a prefix of the other (e.g. "auth" vs "auth-pro")
164
+ * - They differ by ≤ 2 characters (Levenshtein distance)
165
+ */
166
+ private areNamesSimilar(a: string, b: string): boolean {
167
+ if (a === b) return false // exact match is handled by "already installed" check
168
+ if (a.startsWith(b) || b.startsWith(a)) return true
169
+
170
+ // Simple edit-distance approximation: count differing chars
171
+ const maxLen = Math.max(a.length, b.length)
172
+ let diffs = 0
173
+ for (let i = 0; i < maxLen; i++) {
174
+ if (a[i] !== b[i]) diffs++
175
+ if (diffs > 2) return false
176
+ }
177
+ return diffs <= 2
178
+ }
179
+
143
180
  /**
144
181
  * Register a cleanup callback that will be invoked when the named plugin is removed.
145
182
  * This allows the plugin loader to tear down hooks, agents, MCP connections, and tools.
@@ -335,3 +335,46 @@ export interface InferenceCheckResponse {
335
335
  verdict: 'allow' | 'deny'
336
336
  reason?: string
337
337
  }
338
+
339
+ // ── Credential Masking Types ──
340
+
341
+ /** Full-file masking rule: entire file content replaced with sentinel. */
342
+ export interface CredentialFullMaskRule {
343
+ path: string
344
+ mode: 'full'
345
+ }
346
+
347
+ /** Extract-based masking rule: only regex-matched tokens are replaced. */
348
+ export interface CredentialExtractRule {
349
+ path: string
350
+ mode: 'extract'
351
+ extract: Array<{
352
+ pattern: string
353
+ replacement?: string
354
+ }>
355
+ }
356
+
357
+ export type CredentialFileRule = CredentialFullMaskRule | CredentialExtractRule
358
+
359
+ /** Configuration for credential masking, loaded from config.yml. */
360
+ export interface CredentialMaskingConfig {
361
+ enabled: boolean
362
+ files: CredentialFileRule[]
363
+ output_scrubbing: {
364
+ enabled: boolean
365
+ patterns: string[]
366
+ }
367
+ env_filter: {
368
+ enabled: boolean
369
+ patterns: string[]
370
+ }
371
+ }
372
+
373
+ // ── Background Agent Types ──
374
+
375
+ export interface BackgroundAgentConfig {
376
+ auto_commit: boolean
377
+ auto_push: boolean
378
+ auto_worktree: boolean
379
+ commit_coauthors: boolean
380
+ }
@@ -1,4 +1,11 @@
1
- import type { ToolDefinition } from '../../shared/index.ts'
1
+ import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
2
+
3
+ // Injected at startup — set via index.tsx
4
+ let credentialConfig: CredentialMaskingConfig | undefined
5
+
6
+ export function setCredentialMaskingConfigForBash(config: CredentialMaskingConfig): void {
7
+ credentialConfig = config
8
+ }
2
9
 
3
10
  // ── Dangerous command patterns ──
4
11
  const BLOCKED_PATTERNS = [
@@ -115,19 +122,38 @@ export const bashTool: ToolDefinition = {
115
122
  }
116
123
 
117
124
  try {
125
+ // ── Credential masking: filter sensitive env vars ──
126
+ let spawnEnv: Record<string, string | undefined> | undefined
127
+ if (credentialConfig?.enabled && credentialConfig.env_filter.enabled) {
128
+ const { filterEnv } = await import('../../core/credential-masker')
129
+ spawnEnv = filterEnv(process.env as Record<string, string | undefined>, credentialConfig)
130
+ }
131
+
118
132
  const proc = Bun.spawn(['bash', '-c', command], {
119
133
  cwd: ctx.cwd,
120
134
  stdout: 'pipe',
121
135
  stderr: 'pipe',
136
+ env: spawnEnv,
122
137
  })
123
138
 
124
139
  const timer = setTimeout(() => proc.kill(), timeout)
125
- const output = await new Response(proc.stdout).text()
140
+ const rawOutput = await new Response(proc.stdout).text()
126
141
  const exitCode = await proc.exited
127
142
  clearTimeout(timer)
128
143
 
144
+ // ── Credential masking: scrub output ──
145
+ let output = rawOutput
146
+ if (credentialConfig?.enabled && credentialConfig.output_scrubbing.enabled) {
147
+ const { maskOutput } = await import('../../core/credential-masker')
148
+ output = maskOutput(rawOutput, credentialConfig)
149
+ }
150
+
129
151
  if (exitCode !== 0) {
130
- const stderr = await new Response(proc.stderr).text()
152
+ const rawStderr = await new Response(proc.stderr).text()
153
+ const stderr =
154
+ credentialConfig?.enabled && credentialConfig.output_scrubbing.enabled
155
+ ? (await import('../../core/credential-masker')).maskOutput(rawStderr, credentialConfig)
156
+ : rawStderr
131
157
  return {
132
158
  success: false,
133
159
  content: output.slice(0, 5_000),
@@ -1,7 +1,14 @@
1
1
  import { readFileSync, existsSync, statSync } from 'node:fs'
2
- import type { ToolDefinition } from '../../shared/index.ts'
2
+ import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
3
3
  import { resolveSafe } from '../../security/path'
4
4
 
5
+ // Injected at startup — set via index.tsx
6
+ let credentialConfig: CredentialMaskingConfig | undefined
7
+
8
+ export function setCredentialMaskingConfigForRead(config: CredentialMaskingConfig): void {
9
+ credentialConfig = config
10
+ }
11
+
5
12
  export const readTool: ToolDefinition = {
6
13
  name: 'Read',
7
14
  description: 'Read a file from the local filesystem. Supports offset and limit for large files.',
@@ -37,6 +44,28 @@ export const readTool: ToolDefinition = {
37
44
  const offset = (params.offset as number) || 0
38
45
  const limit = (params.limit as number) || 2000
39
46
  const content = readFileSync(filePath, 'utf-8')
47
+
48
+ // ── Credential masking ──
49
+ if (credentialConfig) {
50
+ const { matchCredentialFile, maskContent, CREDENTIAL_SENTINEL } =
51
+ await import('../../core/credential-masker')
52
+ const rule = matchCredentialFile(filePath, credentialConfig)
53
+ if (rule) {
54
+ const masked = maskContent(content, rule)
55
+ // Full-file mask: return sentinel immediately (offset/limit don't apply)
56
+ if (masked === CREDENTIAL_SENTINEL) {
57
+ return { success: true, content: masked }
58
+ }
59
+ // Extract mode: apply offset/limit on masked content
60
+ const maskedLines = masked.split('\n')
61
+ const maskedSlice = maskedLines.slice(offset, offset + limit)
62
+ const maskedResult = maskedSlice
63
+ .map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`)
64
+ .join('\n')
65
+ return { success: true, content: maskedResult }
66
+ }
67
+ }
68
+
40
69
  const lines = content.split('\n')
41
70
  const slice = lines.slice(offset, offset + limit)
42
71
  const result = slice
package/src/ui/app.tsx CHANGED
@@ -372,6 +372,11 @@ export function App({
372
372
  setPickerOpen((prev) => !prev)
373
373
  return
374
374
  }
375
+ // Ctrl+F → toggle focus mode
376
+ if (_input === '\x06') {
377
+ setFocusMode((prev) => !prev)
378
+ return
379
+ }
375
380
  // Ctrl+O → toggle last tool call expand/collapse
376
381
  if (_input === '\x0f') {
377
382
  setMessages((prev) => {
@@ -480,7 +485,7 @@ export function App({
480
485
  ● {PERMISSION_LABELS[permissionMode]}
481
486
  </Text>
482
487
  <Text dimColor> · Shift+Tab mode</Text>
483
- <Text dimColor> · Ctrl+P model · Ctrl+O expand</Text>
488
+ <Text dimColor> · Ctrl+P model · Ctrl+F focus · Ctrl+O expand</Text>
484
489
  <Text dimColor> · Esc cancel · /help · ← agents</Text>
485
490
  </Box>
486
491
  </Box>
package/src/ui/chat.tsx CHANGED
@@ -18,16 +18,16 @@ function displayCwd(): string {
18
18
  }
19
19
 
20
20
  export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
21
- // In focus mode, show only the last user+assistant exchange
22
- const displayMessages = focusMode ? getLastExchange(messages) : messages
21
+ // In focus mode, compact tool activity into summary lines
22
+ const displayMessages = focusMode ? compactForFocus(messages) : messages
23
23
 
24
24
  return (
25
25
  <Box flexDirection="column" marginY={1} flexGrow={1}>
26
26
  {focusMode && messages.length > 0 && (
27
27
  <Box marginBottom={1}>
28
28
  <Text dimColor>
29
- 🔍 Focus mode — showing last exchange only ({displayMessages.length} of{' '}
30
- {messages.length} messages hidden)
29
+ 🔍 Focus — {countHidden(messages, displayMessages)} hidden · /focus to toggle · Ctrl+O
30
+ to expand
31
31
  </Text>
32
32
  </Box>
33
33
  )}
@@ -92,22 +92,55 @@ export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
92
92
  }
93
93
 
94
94
  /**
95
- * In focus mode, show only the last user→assistant exchange.
96
- * Walk backwards from the end to find the last user message, then include
97
- * everything from that point onward.
95
+ * Compact messages for focus view:
96
+ * - Groups consecutive tool calls into [▶ N tools] ↳ name1 ↳ name2 summary lines
97
+ * - Keeps all user, assistant, and system messages intact
98
98
  */
99
- function getLastExchange(messages: ChatMessage[]): ChatMessage[] {
100
- if (messages.length <= 2) return messages
99
+ function compactForFocus(messages: ChatMessage[]): ChatMessage[] {
100
+ const result: ChatMessage[] = []
101
+ let toolGroup: ChatMessage[] = []
101
102
 
102
- // Find the last user message index
103
- let lastUserIdx = -1
104
- for (let i = messages.length - 1; i >= 0; i--) {
105
- if (messages[i]!.role === 'user') {
106
- lastUserIdx = i
107
- break
103
+ for (const msg of messages) {
104
+ if (msg.toolMeta) {
105
+ toolGroup.push(msg)
106
+ } else {
107
+ // Flush pending tool group
108
+ if (toolGroup.length > 0) {
109
+ result.push(summarizeToolGroup(toolGroup))
110
+ toolGroup = []
111
+ }
112
+ result.push(msg)
108
113
  }
109
114
  }
115
+ // Flush remaining
116
+ if (toolGroup.length > 0) {
117
+ result.push(summarizeToolGroup(toolGroup))
118
+ }
119
+
120
+ return result
121
+ }
122
+
123
+ /** Summarize a group of tool calls into a single compact message. */
124
+ function summarizeToolGroup(tools: ChatMessage[]): ChatMessage {
125
+ const names = tools.map((t) => t.toolMeta!.name)
126
+ const uniqueNames = [...new Set(names)]
127
+ const toolList = uniqueNames.map((n) => `↳ ${n}`).join(' ')
128
+ return {
129
+ role: 'system',
130
+ content: `[▶ ${tools.length} tool${tools.length > 1 ? 's' : ''}] ${toolList}`,
131
+ toolMeta: {
132
+ name: `tools (${tools.length})`,
133
+ input: '',
134
+ output: tools.map((t) => t.content).join('\n'),
135
+ collapsed: true,
136
+ },
137
+ }
138
+ }
110
139
 
111
- if (lastUserIdx === -1) return messages
112
- return messages.slice(lastUserIdx)
140
+ /** Count how many messages are hidden in focus mode. */
141
+ function countHidden(all: ChatMessage[], compacted: ChatMessage[]): string {
142
+ const toolCount = all.filter((m) => m.toolMeta).length
143
+ const hidden = all.length - compacted.length
144
+ if (hidden <= 0) return `showing all ${all.length} messages`
145
+ return `${hidden} tool outputs folded (${toolCount} total tool calls)`
113
146
  }
@@ -3052,6 +3052,223 @@ Permission level is controlled by /config permission <level>.`,
3052
3052
  // Security / Audit — security review checklist
3053
3053
  // ═══════════════════════════════════════════════════════════════
3054
3054
 
3055
+ const promptAuditCmd: CommandHandler = async () => {
3056
+ const { existsSync, readFileSync, readdirSync, statSync } = await import('node:fs')
3057
+ const { join, extname, basename } = await import('node:path')
3058
+ const cwd = process.cwd()
3059
+
3060
+ // Patterns that suggest prompts written for older/less-capable models
3061
+ const AUDIT_RULES: Array<{
3062
+ id: string
3063
+ pattern: RegExp
3064
+ severity: 'low' | 'medium' | 'high'
3065
+ message: string
3066
+ suggestion: string
3067
+ }> = [
3068
+ {
3069
+ id: 'over-explaining',
3070
+ pattern:
3071
+ /you are (a|an) (helpful |friendly |knowledgeable )?(AI |language model |assistant)/i,
3072
+ severity: 'low',
3073
+ message: 'Self-identification preamble ("You are a...")',
3074
+ suggestion: "Modern models don't need role preamble. Remove or shorten to 1 line.",
3075
+ },
3076
+ {
3077
+ id: 'step-by-step',
3078
+ pattern: /(think|reason|work) (step[ -]?by[ -]?step|through this|carefully about this)/i,
3079
+ severity: 'low',
3080
+ message: 'Step-by-step reasoning prompt',
3081
+ suggestion: 'Reasoning models handle this natively. Remove for token savings.',
3082
+ },
3083
+ {
3084
+ id: 'do-not-list',
3085
+ pattern:
3086
+ /(do not|never|you must not|don't|under no circumstances).*\n.*(do not|never|you must not|don't)/i,
3087
+ severity: 'medium',
3088
+ message: 'Multiple "do not" constraints',
3089
+ suggestion: 'Modern models need fewer prohibitions. Consolidate to 1-2 key constraints.',
3090
+ },
3091
+ {
3092
+ id: 'token-waste',
3093
+ pattern:
3094
+ /(remember|keep in mind|it is important to note|please note that|i want to emphasize)/i,
3095
+ severity: 'low',
3096
+ message: 'Filler emphasis phrases ("remember", "please note")',
3097
+ suggestion: 'Remove filler. State the instruction directly.',
3098
+ },
3099
+ {
3100
+ id: 'format-over-spec',
3101
+ pattern: /(you must (always |only )?respond (in|with|using) (JSON|XML|YAML|markdown|HTML))/i,
3102
+ severity: 'low',
3103
+ message: 'Over-specified output format',
3104
+ suggestion: 'Modern models follow format instructions with fewer words. Shorten.',
3105
+ },
3106
+ {
3107
+ id: 'example-overload',
3108
+ pattern: /((?:example|for instance|e\.g\.,).*\n){3,}/i,
3109
+ severity: 'low',
3110
+ message: 'Three or more examples in sequence',
3111
+ suggestion: '1-2 examples suffice for modern models. Remove redundant ones.',
3112
+ },
3113
+ {
3114
+ id: 'old-model-ref',
3115
+ pattern: /(GPT-3|GPT-3\.5|Claude\s*[12][^3]|text-davinci|command-r|llama\s*2)/i,
3116
+ severity: 'high',
3117
+ message: 'Reference to older model',
3118
+ suggestion: 'Update model references to current generation (Sonnet 5, Opus 5, GPT-5, etc.).',
3119
+ },
3120
+ {
3121
+ id: 'long-preamble',
3122
+ pattern: /^.{200,}$/m,
3123
+ severity: 'medium',
3124
+ message: 'Very long single-line instructions (200+ chars)',
3125
+ suggestion: 'Break into bullet points. Modern models parse structured prompts better.',
3126
+ },
3127
+ {
3128
+ id: 'verbose-constraint',
3129
+ pattern:
3130
+ /you (must|should|shall) (always |only |never )?(ensure|guarantee|verify|validate|confirm|cross-check|double-check)/i,
3131
+ severity: 'medium',
3132
+ message: 'Overly verbose constraint language',
3133
+ suggestion: 'Replace "you must ensure that" with imperative: "Ensure".',
3134
+ },
3135
+ ]
3136
+
3137
+ // Files to scan
3138
+ const scanDirs = [
3139
+ join(cwd, '.mipham', 'skills'),
3140
+ join(cwd, '.mipham', 'rules'),
3141
+ join(cwd, '.claude'),
3142
+ ]
3143
+ const skillFiles: string[] = []
3144
+ // Also check standard project files
3145
+ const projectFiles = [join(cwd, 'CLAUDE.md'), join(cwd, 'MIPHAM.md'), join(cwd, 'PRODUCT.md')]
3146
+
3147
+ // Collect skill/rules files
3148
+ for (const dir of scanDirs) {
3149
+ try {
3150
+ if (!existsSync(dir)) continue
3151
+ const walk = (d: string) => {
3152
+ const entries = readdirSync(d)
3153
+ for (const e of entries) {
3154
+ const fp = join(d, e)
3155
+ const st = statSync(fp)
3156
+ if (st.isDirectory()) {
3157
+ walk(fp)
3158
+ continue
3159
+ }
3160
+ if (['.md', '.SKILL.md', '.mipham-skill.md', '.txt'].some((ext) => fp.endsWith(ext))) {
3161
+ skillFiles.push(fp)
3162
+ }
3163
+ }
3164
+ }
3165
+ walk(dir)
3166
+ } catch {
3167
+ /* skip */
3168
+ }
3169
+ }
3170
+
3171
+ const allFiles = [...new Set([...projectFiles.filter((f) => existsSync(f)), ...skillFiles])]
3172
+
3173
+ if (allFiles.length === 0) {
3174
+ return {
3175
+ content: [
3176
+ '🔍 Prompt Audit',
3177
+ '',
3178
+ 'No prompt files found to audit.',
3179
+ 'Place skills in .mipham/skills/ and rules in .mipham/rules/.',
3180
+ ].join('\n'),
3181
+ }
3182
+ }
3183
+
3184
+ // Scan each file
3185
+ const results: Array<{
3186
+ file: string
3187
+ line: number
3188
+ ruleId: string
3189
+ severity: string
3190
+ message: string
3191
+ suggestion: string
3192
+ }> = []
3193
+
3194
+ for (const fp of allFiles) {
3195
+ try {
3196
+ const content = readFileSync(fp, 'utf-8')
3197
+ const lines = content.split('\n')
3198
+ for (const rule of AUDIT_RULES) {
3199
+ // Multi-line pattern: check full content
3200
+ if (rule.id === 'do-not-list' || rule.id === 'example-overload') {
3201
+ if (rule.pattern.test(content)) {
3202
+ // Find approx line
3203
+ const match = rule.pattern.exec(content)
3204
+ const idx = match ? content.slice(0, match.index).split('\n').length : 1
3205
+ results.push({
3206
+ file: fp.replace(cwd + '/', ''),
3207
+ line: idx,
3208
+ ruleId: rule.id,
3209
+ severity: rule.severity,
3210
+ message: rule.message,
3211
+ suggestion: rule.suggestion,
3212
+ })
3213
+ }
3214
+ continue
3215
+ }
3216
+
3217
+ // Line-by-line patterns
3218
+ for (let i = 0; i < lines.length; i++) {
3219
+ const line = lines[i]!
3220
+ if (rule.pattern.test(line)) {
3221
+ results.push({
3222
+ file: fp.replace(cwd + '/', ''),
3223
+ line: i + 1,
3224
+ ruleId: rule.id,
3225
+ severity: rule.severity,
3226
+ message: rule.message,
3227
+ suggestion: rule.suggestion,
3228
+ })
3229
+ }
3230
+ }
3231
+ }
3232
+ } catch {
3233
+ /* skip unreadable files */
3234
+ }
3235
+ }
3236
+
3237
+ // Format output
3238
+ const severityIcon: Record<string, string> = { high: '🔴', medium: '🟡', low: '🟢' }
3239
+ const grouped: Record<string, typeof results> = {}
3240
+ for (const r of results) {
3241
+ const key = r.file
3242
+ if (!grouped[key]) grouped[key] = []
3243
+ grouped[key]!.push(r)
3244
+ }
3245
+
3246
+ const output: string[] = [
3247
+ `🔍 Prompt Audit — ${allFiles.length} file(s) scanned, ${results.length} finding(s)`,
3248
+ '',
3249
+ ]
3250
+
3251
+ if (results.length === 0) {
3252
+ output.push('✅ All prompts look optimized for modern models. No issues found.')
3253
+ } else {
3254
+ for (const [file, items] of Object.entries(grouped)) {
3255
+ output.push(`📄 ${file} (${items!.length} finding${items!.length > 1 ? 's' : ''})`)
3256
+ for (const item of items!) {
3257
+ output.push(
3258
+ ` ${severityIcon[item.severity] || '⚪'} L${item.line}: [${item.ruleId}] ${item.message}`,
3259
+ )
3260
+ output.push(` → ${item.suggestion}`)
3261
+ }
3262
+ output.push('')
3263
+ }
3264
+ }
3265
+
3266
+ output.push('─'.repeat(60))
3267
+ output.push('Scanned: ' + allFiles.map((f) => basename(f)).join(', '))
3268
+
3269
+ return { content: output.join('\n') }
3270
+ }
3271
+
3055
3272
  const securityCmd: CommandHandler = async () => {
3056
3273
  const findings: string[] = []
3057
3274
  const ok: string[] = []
@@ -3691,6 +3908,128 @@ Run \`mipham agents\` for the interactive dashboard.`,
3691
3908
  // Artifacts
3692
3909
  // ═══════════════════════════════════════════════════════════════
3693
3910
 
3911
+ const forkCmd: CommandHandler = async (ctx, args) => {
3912
+ const prompt = args.join(' ')
3913
+ if (!prompt.trim()) {
3914
+ return {
3915
+ content: [
3916
+ 'Usage: /fork <task description>',
3917
+ '',
3918
+ 'Fork a task into an isolated worktree with auto commit/push.',
3919
+ '',
3920
+ 'Workflow:',
3921
+ ' 1. Creates a new git worktree on branch worktree/<slug>',
3922
+ ' 2. Spawns a background agent to work on the task',
3923
+ ' 3. Auto-commits changes with Co-Authored-By on completion',
3924
+ ' 4. Auto-pushes to origin',
3925
+ ' 5. Reports branch and worktree path in the result',
3926
+ '',
3927
+ 'Examples:',
3928
+ ' /fork Refactor the auth module',
3929
+ ' /fork Add input validation to all API endpoints',
3930
+ ].join('\n'),
3931
+ }
3932
+ }
3933
+
3934
+ const { execSync } = await import('node:child_process')
3935
+ const { join } = await import('node:path')
3936
+
3937
+ try {
3938
+ execSync('git rev-parse --git-dir', { stdio: 'ignore' })
3939
+ } catch {
3940
+ return { content: '/fork requires a git repository.' }
3941
+ }
3942
+
3943
+ const agentViewManager = ctx.engine.getAgentViewManager?.()
3944
+ if (!agentViewManager) {
3945
+ return { content: 'Agent View manager is initializing...' }
3946
+ }
3947
+
3948
+ const slug = prompt
3949
+ .toLowerCase()
3950
+ .replace(/[^a-z0-9]+/g, '-')
3951
+ .replace(/^-|-$/g, '')
3952
+ .slice(0, 40)
3953
+ const name = `${slug}-${Date.now().toString(36)}`
3954
+ const branch = `worktree/${name}`
3955
+ const wtPath = join(process.cwd(), '.claude', 'worktrees', name)
3956
+
3957
+ try {
3958
+ execSync(`git worktree add -b ${branch} ${wtPath} HEAD`, { stdio: 'ignore', timeout: 30_000 })
3959
+ } catch (err) {
3960
+ try {
3961
+ execSync(`git worktree remove --force ${wtPath}`, { stdio: 'ignore' })
3962
+ } catch {
3963
+ /* ok */
3964
+ }
3965
+ try {
3966
+ execSync(`git branch -D ${branch}`, { stdio: 'ignore' })
3967
+ } catch {
3968
+ /* ok */
3969
+ }
3970
+ return {
3971
+ content: `Worktree creation failed: ${err instanceof Error ? err.message : String(err)}`,
3972
+ }
3973
+ }
3974
+
3975
+ const session = agentViewManager.create(prompt, prompt, {
3976
+ provider: ctx.providerId,
3977
+ model: ctx.modelId,
3978
+ })
3979
+ agentViewManager.addMessage(session.id, { role: 'user', content: prompt })
3980
+ agentViewManager.updateStatus(session.id, 'working')
3981
+ session.worktree = wtPath
3982
+ session.branch = branch
3983
+ session.kind = 'forked'
3984
+
3985
+ const bgReg = (await import('../agent/background-registry')).getBackgroundAgentRegistry()
3986
+ bgReg.spawn(
3987
+ prompt,
3988
+ 'general',
3989
+ async (_signal) => {
3990
+ const { SubAgent } = await import('../agent/sub-agent')
3991
+ const sa = new SubAgent(ctx.engine.getRegistry(), ctx.engine.getTools())
3992
+ const result = await sa.execute(prompt, 'fork: ' + prompt.slice(0, 60), {})
3993
+ try {
3994
+ const { execSync: ex } = await import('node:child_process')
3995
+ ex(`git -C ${wtPath} add -A`, { stdio: 'ignore', timeout: 10_000 })
3996
+ const st = ex(`git -C ${wtPath} status --porcelain`, { encoding: 'utf-8', timeout: 10_000 })
3997
+ if (st.trim()) {
3998
+ ex(
3999
+ `git -C ${wtPath} commit -m "feat: ${prompt.slice(0, 60)}\n\nCo-Authored-By: Claude <noreply@anthropic.com>"`,
4000
+ { stdio: 'ignore', timeout: 10_000 },
4001
+ )
4002
+ ex(`git push origin ${branch}`, { stdio: 'ignore', timeout: 30_000 })
4003
+ }
4004
+ } catch {
4005
+ /* best-effort */
4006
+ }
4007
+ return [
4008
+ `## Fork done: ${prompt}`,
4009
+ '',
4010
+ `Branch: \`${branch}\``,
4011
+ `Worktree: \`${wtPath}\``,
4012
+ '',
4013
+ result || '(no output)',
4014
+ ].join('\n')
4015
+ },
4016
+ 'forked',
4017
+ )
4018
+
4019
+ return {
4020
+ content: [
4021
+ `✓ Forked: ${session.id}`,
4022
+ ` Task: ${prompt.slice(0, 80)}`,
4023
+ ` Branch: ${branch}`,
4024
+ ` Worktree: ${wtPath}`,
4025
+ ` Status: working`,
4026
+ '',
4027
+ 'Agent will auto-commit + push on completion.',
4028
+ 'Use /agents to track.',
4029
+ ].join('\n'),
4030
+ }
4031
+ }
4032
+
3694
4033
  async function openBrowser(url: string): Promise<void> {
3695
4034
  const cmd =
3696
4035
  process.platform === 'darwin'
@@ -3857,6 +4196,7 @@ const commandsListCmd: CommandHandler = () => {
3857
4196
  '/recommend': 'Project',
3858
4197
  '/security': 'Project',
3859
4198
  '/audit': 'Project',
4199
+ '/prompt-audit': 'Code Quality',
3860
4200
  '/ide': 'Environment',
3861
4201
  '/terminal-setup': 'Environment',
3862
4202
  '/memory': 'Environment',
@@ -3866,6 +4206,7 @@ const commandsListCmd: CommandHandler = () => {
3866
4206
  '/feedback': 'Account',
3867
4207
  '/agents': 'Agents',
3868
4208
  '/bg': 'Agents',
4209
+ '/fork': 'Agents',
3869
4210
  '/artifact': 'Artifacts',
3870
4211
  '/schedule': 'Other',
3871
4212
  '/commit': 'Workflow',
@@ -3996,6 +4337,7 @@ registry.set('/permissions', permissionsCmd)
3996
4337
  registry.set('/add-dir', addDirCmd)
3997
4338
  registry.set('/security', securityCmd)
3998
4339
  registry.set('/audit', securityCmd)
4340
+ registry.set('/prompt-audit', promptAuditCmd)
3999
4341
 
4000
4342
  // Environment
4001
4343
  registry.set('/theme', themeCmd)
@@ -4010,6 +4352,7 @@ registry.set('/logout', logoutCmd)
4010
4352
  registry.set('/feedback', feedbackCmd)
4011
4353
  registry.set('/agents', agentsCmd)
4012
4354
  registry.set('/bg', bgCmd)
4355
+ registry.set('/fork', forkCmd)
4013
4356
 
4014
4357
  // Artifacts
4015
4358
  registry.set('/artifact', artifactBaseCmd)
@@ -4119,6 +4462,7 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
4119
4462
  '/recommend': 'Analyze project + recommend skills & setup',
4120
4463
  '/security': 'Security review checklist',
4121
4464
  '/audit': 'Same as /security',
4465
+ '/prompt-audit': 'Audit prompts for modern model optimization',
4122
4466
  '/ide': 'IDE integration guide',
4123
4467
  '/terminal-setup': 'Shell & terminal config',
4124
4468
  '/memory': 'Manage AI memories',
@@ -4129,6 +4473,7 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
4129
4473
  '/feedback': 'Send feedback',
4130
4474
  '/agents': 'Agent view dashboard',
4131
4475
  '/bg': 'Run a background agent task',
4476
+ '/fork': 'Fork task into isolated worktree',
4132
4477
  '/artifact': 'Manage artifacts',
4133
4478
  '/schedule': 'View scheduled tasks and cron jobs',
4134
4479
  '/commit': 'Generate commit message and review staged changes',