@miphamai/cli 0.82.0 → 0.84.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 (84) hide show
  1. package/package.json +1 -1
  2. package/skills/standard/mipham-code-setup.SKILL.md +30 -8
  3. package/src/agent/agent-context.ts +5 -5
  4. package/src/agent/agent-experience.ts +2 -2
  5. package/src/agent/agent-registry.ts +4 -4
  6. package/src/agent/cross-session/discovery.ts +3 -2
  7. package/src/agent/cross-session/file-inbox.ts +2 -2
  8. package/src/agent/effectiveness-tracker.ts +2 -2
  9. package/src/agent/pattern-analyzer.ts +3 -4
  10. package/src/agent/sub-agent.ts +12 -2
  11. package/src/agent/types.ts +4 -1
  12. package/src/commands/autoloop-journal.ts +2 -2
  13. package/src/commands/environment.ts +2 -1
  14. package/src/commands/loop-scaffold.ts +2 -1
  15. package/src/commands/project.ts +86 -33
  16. package/src/config/keys-manager.ts +2 -2
  17. package/src/config/loader.ts +11 -10
  18. package/src/config/preferences.ts +3 -4
  19. package/src/core/auto-memory.ts +2 -3
  20. package/src/core/constitution-loader.ts +3 -4
  21. package/src/core/crsi-producer.ts +3 -3
  22. package/src/core/crsi-sandbox.ts +3 -2
  23. package/src/core/dream-engine.ts +2 -2
  24. package/src/core/engine.ts +38 -6
  25. package/src/core/error-signature-db.ts +2 -2
  26. package/src/core/eval-harness.ts +121 -4
  27. package/src/core/improvement-track.ts +35 -6
  28. package/src/core/instructions.ts +28 -4
  29. package/src/core/memory/memory-loader.ts +2 -3
  30. package/src/core/paths.ts +21 -1
  31. package/src/core/permission-audit.ts +120 -0
  32. package/src/core/permission-classifier.ts +449 -0
  33. package/src/core/permission-config.ts +106 -15
  34. package/src/core/permission.ts +369 -16
  35. package/src/core/rule-engine.ts +2 -2
  36. package/src/core/rules-loader.ts +3 -2
  37. package/src/core/session-log.ts +2 -3
  38. package/src/core/session-store.ts +2 -3
  39. package/src/core/task-performance.ts +22 -1
  40. package/src/core/workspace-trust.ts +4 -3
  41. package/src/daemon/database.ts +2 -2
  42. package/src/daemon/index.ts +2 -3
  43. package/src/daemon/launch.ts +3 -3
  44. package/src/daemon/server.ts +15 -0
  45. package/src/i18n-core/locales/en-US.json +3 -0
  46. package/src/i18n-core/locales/zh-CN.json +3 -0
  47. package/src/index.tsx +30 -6
  48. package/src/mcp/token-store.ts +2 -2
  49. package/src/plugin/plugin-manager.ts +2 -2
  50. package/src/shared/constants.ts +0 -1
  51. package/src/shared/package-info.ts +1 -1
  52. package/src/shared/types.ts +46 -6
  53. package/src/shared/update.ts +2 -3
  54. package/src/skills/bundled-skills.ts +1 -1
  55. package/src/skills/loader.ts +2 -3
  56. package/src/skills/marketplace.ts +2 -3
  57. package/src/skills/registry.ts +2 -2
  58. package/src/skills/skill-assets.ts +2 -2
  59. package/src/skills/usage.ts +2 -2
  60. package/src/telemetry/consent.ts +2 -3
  61. package/src/tools/agent/enter-plan.ts +3 -2
  62. package/src/tools/agent/exit-plan.ts +1 -1
  63. package/src/tools/agent/list-agents.ts +1 -1
  64. package/src/tools/agent/memory.ts +3 -3
  65. package/src/tools/agent/plan.ts +3 -2
  66. package/src/tools/agent/report-findings.ts +1 -1
  67. package/src/tools/agent/send-message.ts +1 -1
  68. package/src/tools/agent/skill.ts +1 -1
  69. package/src/tools/exec/git.ts +2 -2
  70. package/src/tools/exec/task.ts +1 -1
  71. package/src/tools/file/glob.ts +1 -1
  72. package/src/tools/file/grep.ts +1 -1
  73. package/src/tools/file/read.ts +1 -1
  74. package/src/tools/network/web-fetch.ts +1 -1
  75. package/src/tools/network/web-search.ts +1 -1
  76. package/src/tools/scheduling/cron.ts +5 -5
  77. package/src/tools/scheduling/schedule-wakeup.ts +1 -1
  78. package/src/tools/system/config.ts +2 -2
  79. package/src/tools/system/tool-search.ts +1 -1
  80. package/src/ui/app.tsx +51 -9
  81. package/src/ui/commands.ts +41 -19
  82. package/src/ui/config-wizard.tsx +2 -2
  83. package/src/ui/input.tsx +15 -3
  84. package/src/workflow/journal.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.82.0",
3
+ "version": "0.84.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -77,7 +77,7 @@ Project: [✅/⬜] .mipham/ [✅/⬜] config.yml [✅/⬜] MIPHAM.md
77
77
  User Config: [✅/⬜] ~/.mipham/config.yml
78
78
  API Keys: [N] set (list names or "none")
79
79
  Skills: [N] installed
80
- Permissions: [mode] (default/acceptEdits/plan/bypassPermissions)
80
+ Permissions: [mode] (default/acceptEdits/plan/auto/bypassPermissions)
81
81
  Trust: [✅/⬜] workspace trusted
82
82
  ```
83
83
 
@@ -331,21 +331,43 @@ Or directly:
331
331
 
332
332
  ### 6.1 — Permission Modes
333
333
 
334
- | Mode | Behavior | Use Case |
335
- | ------------------- | ------------------------------- | ----------------------------------- |
336
- | `default` | Prompt for each tool | Normal development (recommended) |
337
- | `acceptEdits` | Auto-allow edits, prompt others | Active coding sessions |
338
- | `plan` | Plan-only, no tool execution | Design & architecture work |
339
- | `bypassPermissions` | Skip all checks | ⚠️ Only for fully trusted codebases |
334
+ | Mode | Behavior | Use Case |
335
+ | ------------------- | -------------------------------------- | ----------------------------------------- |
336
+ | `default` | Ask-first tools are refused (see note) | Normal development (recommended) |
337
+ | `acceptEdits` | Auto-allow edits, ask for other tools | Active coding sessions |
338
+ | `plan` | Plan-only, no tool execution | Design & architecture work |
339
+ | `auto` | A classifier rules on every call | Long unattended runs you still want gated |
340
+ | `bypassPermissions` | Skip all checks | ⚠️ Only for fully trusted codebases |
341
+
342
+ **Note on `default`**: Mipham Code has no interactive approval prompt, so "ask"
343
+ means the call is **refused** with a message naming the mode — it is not queued
344
+ for your answer. That makes `default` the strictest _usable_ mode for Bash and
345
+ file writes; `auto` is the mode that lets gated calls proceed without a human,
346
+ by having a classifier rule on each one.
347
+
348
+ **What `auto` actually gates.** Reads (Read/Grep/Glob) are never sent to the
349
+ classifier — gating them would make `auto` the only mode in the ladder that cannot
350
+ open a file without a round-trip to another model. Everything else that the static
351
+ chain would refuse goes to the classifier, which can only **lift** a refusal: a
352
+ deny rule or an `ask` rule is never overridden. If the classifier cannot be reached
353
+ or answers unusably, the call is refused (fail-closed) with a message saying the
354
+ refusal is _not_ a policy decision and can be retried.
340
355
 
341
356
  ### 6.2 — Configure
342
357
 
343
- In `.mipham/config.yml`:
358
+ Press **Shift+Tab** to change the mode live; it cycles
359
+ `default → acceptEdits → plan → auto` and lasts for the session only.
360
+ `bypassPermissions` is a legal mode but is deliberately **not** on the wheel —
361
+ it is reached by naming it in config, where the user has said what they mean.
362
+
363
+ To persist a mode for a project, in `.mipham/config.yml`:
344
364
 
345
365
  ```yaml
346
366
  permission: default
347
367
  ```
348
368
 
369
+ Any mode name from the table above is accepted, including `bypassPermissions`.
370
+
349
371
  Or via slash command:
350
372
 
351
373
  ```
@@ -1,13 +1,14 @@
1
1
  // apps/cli/src/agent/agent-context.ts
2
2
  import { readdirSync, readFileSync, existsSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
- import { homedir } from 'node:os'
5
4
  import { ContextManager } from '../core/context'
6
5
  import type { ToolDefinition } from '../shared/index.ts'
7
6
  import type { AgentDefinition } from './types'
8
7
  import type { CrsiConfig } from '../shared/index.ts'
9
8
  import { AgentExperience } from './agent-experience'
10
9
  import { ExperienceRuleExtractor } from './experience-rules.js'
10
+ import { miphamHome } from '../core/paths.ts'
11
+ import { MIPHAM_DIR } from '../shared/constants.ts'
11
12
 
12
13
  export interface AgentContextResult {
13
14
  context: ContextManager
@@ -26,17 +27,16 @@ function loadAgentMemory(
26
27
  crsiRuleInjection = true,
27
28
  ): string {
28
29
  let memoryDir: string
29
- const home = homedir()
30
30
 
31
31
  switch (scope) {
32
32
  case 'user':
33
- memoryDir = join(home, '.mipham', 'agent-memory', agentName)
33
+ memoryDir = miphamHome('agent-memory', agentName)
34
34
  break
35
35
  case 'project':
36
- memoryDir = join(process.cwd(), '.mipham', 'agent-memory', agentName)
36
+ memoryDir = join(process.cwd(), MIPHAM_DIR, 'agent-memory', agentName)
37
37
  break
38
38
  case 'local':
39
- memoryDir = join(process.cwd(), '.mipham', 'agent-memory-local', agentName)
39
+ memoryDir = join(process.cwd(), MIPHAM_DIR, 'agent-memory-local', agentName)
40
40
  break
41
41
  }
42
42
 
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { homedir } from 'node:os'
4
3
  import { ExperienceRuleExtractor, type ExperienceRule } from './experience-rules.js'
4
+ import { miphamHome } from '../core/paths.ts'
5
5
 
6
6
  const MAX_EXPERIENCES = 20
7
7
 
@@ -11,7 +11,7 @@ export class AgentExperience {
11
11
 
12
12
  constructor(
13
13
  private readonly agentName: string,
14
- baseDir: string = join(homedir(), '.mipham', 'agent-memory'),
14
+ baseDir: string = miphamHome('agent-memory'),
15
15
  ) {
16
16
  this.expDir = join(baseDir, agentName)
17
17
  this.expFile = join(this.expDir, 'experience.md')
@@ -1,8 +1,9 @@
1
1
  import { readFileSync, existsSync, readdirSync } from 'node:fs'
2
- import { homedir } from 'node:os'
3
2
  import { join, extname } from 'node:path'
4
3
  import { parse as parseYaml } from 'yaml'
5
4
  import type { AgentDefinition, SubAgentType } from './types'
5
+ import { miphamHome } from '../core/paths.ts'
6
+ import { MIPHAM_DIR } from '../shared/constants.ts'
6
7
 
7
8
  interface FrontmatterResult {
8
9
  data: Record<string, unknown>
@@ -91,13 +92,12 @@ export class AgentRegistry {
91
92
 
92
93
  /** Load project-level agents from .mipham/agents/ */
93
94
  loadProjectAgents(cwd: string): void {
94
- this.loadDirectory(join(cwd, '.mipham', 'agents'), 'project')
95
+ this.loadDirectory(join(cwd, MIPHAM_DIR, 'agents'), 'project')
95
96
  }
96
97
 
97
98
  /** Load user-level agents from ~/.mipham/agents/ */
98
99
  loadUserAgents(): void {
99
- const home = homedir()
100
- this.loadDirectory(join(home, '.mipham', 'agents'), 'user')
100
+ this.loadDirectory(miphamHome('agents'), 'user')
101
101
  }
102
102
 
103
103
  /** Get a custom agent by name. Returns undefined for builtins. */
@@ -8,10 +8,11 @@ import {
8
8
  statSync,
9
9
  } from 'node:fs'
10
10
  import { join, basename } from 'node:path'
11
- import { homedir, hostname } from 'node:os'
11
+ import { hostname } from 'node:os'
12
12
  import type { SessionInfo, CrossSessionInbound } from '../../shared/types'
13
+ import { miphamHome } from '../../core/paths.ts'
13
14
 
14
- const MIPHAM_HOME = join(homedir(), '.mipham')
15
+ const MIPHAM_HOME = miphamHome()
15
16
  const ACTIVE_SESSIONS_DIR = join(MIPHAM_HOME, '.active-sessions')
16
17
 
17
18
  /** A session whose heartbeat file hasn't been touched in this long is dead. */
@@ -9,12 +9,11 @@ import {
9
9
  lstatSync,
10
10
  } from 'node:fs'
11
11
  import { join } from 'node:path'
12
- import { homedir } from 'node:os'
13
12
  import type { CrossSessionTransport } from './transport'
14
13
  import type { AgentMessage } from '../message-bus'
15
14
  import type { SessionInfo } from '../../shared/types'
16
15
 
17
- const MIPHAM_HOME = join(homedir(), '.mipham')
16
+ const MIPHAM_HOME = miphamHome()
18
17
  const INBOX_DIR = join(MIPHAM_HOME, 'inbox')
19
18
 
20
19
  /** True if a path exists and is a symbolic link (symlink-attack guard). */
@@ -138,6 +137,7 @@ export class FileInboxTransport implements CrossSessionTransport {
138
137
 
139
138
  // Import discoverSessions lazily to avoid circular dependency
140
139
  import { discoverSessions } from './discovery'
140
+ import { miphamHome } from '../../core/paths.ts'
141
141
 
142
142
  // ── Singleton ──
143
143
 
@@ -1,8 +1,8 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
2
2
  import { join, dirname } from 'node:path'
3
- import { homedir } from 'node:os'
4
3
  import type { CrsiProvenanceBridge, CrsiVerdict } from './crsi-provenance-bridge.js'
5
4
  import { isRecoverableToolFailure } from './recoverable-failure.js'
5
+ import { miphamHome } from '../core/paths.ts'
6
6
 
7
7
  export interface RuleEffectiveness {
8
8
  ruleId: string
@@ -32,7 +32,7 @@ export class EffectivenessTracker {
32
32
  private storePath: string
33
33
  private provenanceBridge?: CrsiProvenanceBridge
34
34
 
35
- constructor(storeDir: string = join(homedir(), '.mipham', 'rule-engine')) {
35
+ constructor(storeDir: string = miphamHome('rule-engine')) {
36
36
  this.storePath = join(storeDir, STORE_FILE)
37
37
  this.data = new Map()
38
38
  }
@@ -1,9 +1,8 @@
1
1
  import { readdirSync, existsSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import { homedir } from 'node:os'
4
2
  import { AgentExperience } from './agent-experience.js'
5
3
  import { parseFailureEntries, categorize, type ExperienceRule } from './experience-rules.js'
6
4
  import type { ToolRule } from '../core/rule-engine.js'
5
+ import { miphamHome } from '../core/paths.ts'
7
6
 
8
7
  export interface Pattern {
9
8
  id: string
@@ -18,7 +17,7 @@ export interface Pattern {
18
17
 
19
18
  export class PatternAnalyzer {
20
19
  analyzeAgent(agentName: string, baseDir?: string): Pattern[] {
21
- const dir = baseDir || join(homedir(), '.mipham', 'agent-memory')
20
+ const dir = baseDir || miphamHome('agent-memory')
22
21
  const exp = new AgentExperience(agentName, dir)
23
22
  const content = exp.getExperience()
24
23
  if (!content) return []
@@ -55,7 +54,7 @@ export class PatternAnalyzer {
55
54
  }
56
55
 
57
56
  analyzeAllAgents(baseDir?: string): Pattern[] {
58
- const dir = baseDir || join(homedir(), '.mipham', 'agent-memory')
57
+ const dir = baseDir || miphamHome('agent-memory')
59
58
  if (!existsSync(dir)) return []
60
59
 
61
60
  let agents: string[]
@@ -498,12 +498,22 @@ export class SubAgent {
498
498
  // The gate is never absent: when nobody handed in a permission system, `gate` is
499
499
  // the CLI's default mode (see above), so this check always runs.
500
500
  // P0-3: Uses isolated subPermission (clamped by org restrictions) instead of parent's.
501
- if (gate.needsApproval(tool, effectiveInput)) {
501
+ //
502
+ // `resolveApproval` replaces `needsApproval` here for the same reason as in
503
+ // `engine.ts`: in `auto` mode the static chain can only say `'ask'`, and the
504
+ // classifier is what turns that into a running call. `gate` only carries a
505
+ // classifier when the sub-agent's resolved mode is `auto` (see
506
+ // `createSubAgentPermission`), so the default sub-agent is unchanged.
507
+ const decision = await gate.resolveApproval(tool, effectiveInput, { signal })
508
+ if (decision.level === 'ask') {
502
509
  currentMessages.push({
503
510
  role: 'user' as const,
504
511
  content:
505
512
  `Tool "${tu.name}" requires user approval (permission: ask). ` +
506
- `Cannot execute in non-interactive sub-agent context.`,
513
+ `Cannot execute in non-interactive sub-agent context.` +
514
+ (decision.source === 'classifier' && decision.classifierReason
515
+ ? ` Classifier: ${decision.classifierReason}`
516
+ : ''),
507
517
  })
508
518
  continue
509
519
  }
@@ -10,7 +10,10 @@ export interface AgentFrontmatter {
10
10
  tools?: string // comma-separated allowlist
11
11
  disallowedTools?: string
12
12
  model?: string // 'sonnet' | 'opus' | 'haiku' | 'inherit' | full model ID
13
- permissionMode?: 'default' | 'acceptEdits' | 'bypass' | 'plan' | 'bypassPermissions'
13
+ // Hand-written union rather than `PermissionMode`: it also admits the legacy
14
+ // `'bypass'` alias that `resolveAgentMode` normalizes. Adding a mode to
15
+ // `PermissionMode` does **not** fail to compile here — keep the two in step.
16
+ permissionMode?: 'default' | 'acceptEdits' | 'bypass' | 'plan' | 'auto' | 'bypassPermissions'
14
17
  maxTurns?: number
15
18
  skills?: string
16
19
  background?: boolean
@@ -1,8 +1,8 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { homedir } from 'node:os'
3
+ import { miphamHome } from '../core/paths.ts'
4
4
 
5
- const AUTOLOOP_DIR = join(homedir(), '.mipham', 'autoloop')
5
+ const AUTOLOOP_DIR = miphamHome('autoloop')
6
6
 
7
7
  function ensureDir(): void {
8
8
  if (!existsSync(AUTOLOOP_DIR)) mkdirSync(AUTOLOOP_DIR, { recursive: true })
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { CommandHandler } from '../ui/commands.js'
8
8
  import { NPM_INSTALL_COMMAND } from '../shared/index.ts'
9
+ import { miphamHome } from '../core/paths.ts'
9
10
 
10
11
  export { themeCmd, releaseNotesCmd, ideCmd, terminalSetupCmd }
11
12
 
@@ -186,7 +187,7 @@ const terminalSetupCmd: CommandHandler = async () => {
186
187
  const lines: string[] = ['── Terminal Setup ──', '']
187
188
 
188
189
  // ── 1. Generate standalone shell setup script ──
189
- const miphamDir = join(home, '.mipham')
190
+ const miphamDir = miphamHome()
190
191
  mkdirSync(miphamDir, { recursive: true })
191
192
 
192
193
  const shellScript = join(miphamDir, 'shell-setup.sh')
@@ -12,6 +12,7 @@
12
12
  import { mkdirSync, writeFileSync, existsSync, chmodSync } from 'node:fs'
13
13
  import { join, resolve } from 'node:path'
14
14
  import { homedir } from 'node:os'
15
+ import { MIPHAM_DIR } from '../shared/constants.ts'
15
16
 
16
17
  export interface ScaffoldResult {
17
18
  created: string[]
@@ -89,7 +90,7 @@ export function scaffoldLoopKit(basePath: string): ScaffoldResult {
89
90
  const skipped: string[] = []
90
91
 
91
92
  const resolved = resolve(basePath.replace(/^~/, homedir()))
92
- const miphamDir = join(resolved, '.mipham')
93
+ const miphamDir = join(resolved, MIPHAM_DIR)
93
94
 
94
95
  // ── .mipham/ root ──
95
96
  ensureDir(miphamDir, created, skipped)
@@ -6,10 +6,14 @@
6
6
  * /security, /audit, /prompt-audit
7
7
  */
8
8
  import type { CommandHandler, CommandContext, CommandResult } from '../ui/commands.js'
9
+ import type { PermissionMode } from '../shared/index.ts'
9
10
  import { getWorkspaceTrust } from '../core/workspace-trust'
10
11
  import { atomicWriteFileSync } from '../shared/atomic-write'
11
12
  import { CLOUD_PROVIDERS } from '../config/wizard-config'
13
+ import { MODE_CYCLE, PERMISSION_MODE_HIERARCHY } from '../core/permission-config'
12
14
  import { homedir } from 'node:os'
15
+ import { miphamHome } from '../core/paths.ts'
16
+ import { MIPHAM_DIR } from '../shared/constants.ts'
13
17
 
14
18
  export {
15
19
  initCmd,
@@ -22,6 +26,58 @@ export {
22
26
  trustCmd,
23
27
  }
24
28
 
29
+ /**
30
+ * One description per mode — exhaustive by type, so adding a member to
31
+ * `PermissionMode` fails to compile until it is described here.
32
+ *
33
+ * `/permissions` and `/setup 5` both render from this single table. Each used to
34
+ * carry its own hand-written list, and they had already drifted: `/setup 5` was
35
+ * still listing the retired 3-level `auto`/`ask`/`bypass` spellings and telling
36
+ * the user to run `/config permission <level>` — a command that has never existed
37
+ * (`/config` takes no arguments). Descriptions state what the mode does **today**.
38
+ */
39
+ const MODE_DESCRIPTIONS: Record<PermissionMode, string> = {
40
+ plan: 'reads only (Read/Grep/Glob); nothing writes or runs',
41
+ default: 'per-tool defaults (Bash/Write/Edit are refused, not queued)',
42
+ acceptEdits: 'reads + edits free; Bash auto-runs read/check commands',
43
+ auto: 'a classifier rules on each call that would otherwise be refused (reads are never gated)',
44
+ bypassPermissions: 'skip ALL permission checks (⚠️ use with caution)',
45
+ }
46
+
47
+ /**
48
+ * The mode table, in `PERMISSION_MODE_HIERARCHY` order — i.e. least → most
49
+ * permissive, which is what the heading above it claims. The order comes from the
50
+ * hierarchy array, never from this file, so it cannot disagree with the ranking
51
+ * `clampMode` actually walks.
52
+ */
53
+ function renderModeTable(): string {
54
+ return PERMISSION_MODE_HIERARCHY.map((m) => ` ${m.padEnd(19)}— ${MODE_DESCRIPTIONS[m]}`).join(
55
+ '\n',
56
+ )
57
+ }
58
+
59
+ /**
60
+ * What Shift+Tab reaches, in the order the wheel visits it — derived from
61
+ * `MODE_CYCLE` rather than hand-written.
62
+ *
63
+ * Deliberately its **own line**, not the table above: the table is the
64
+ * permissiveness ladder (every legal mode, in `PERMISSION_MODE_HIERARCHY` order)
65
+ * while this is the cycle, and those two are *allowed* to differ — that is the
66
+ * entire reason `ALL_MODES` and `MODE_CYCLE` are separate arrays. Rendering one
67
+ * list as if it were the other is what the old copy did, and it is how a help
68
+ * screen comes to advertise a mode the wheel cannot reach.
69
+ *
70
+ * `src/ui/app.tsx` used to keep its own copy of this order (`PERMISSION_MODES`).
71
+ * The two happened to agree on the day each was written — which is the state that
72
+ * reads as safe and is not: the copy is what would have kept the footer saying
73
+ * `bypassPermissions` after the wheel moved to `auto`. It now imports `MODE_CYCLE`
74
+ * like this file does, so the help line and the footer are one list by
75
+ * construction.
76
+ */
77
+ function renderCycleLine(): string {
78
+ return MODE_CYCLE.join(' → ')
79
+ }
80
+
25
81
  /**
26
82
  * provider id → 环境变量名,例如 `minimax-global` → `MINIMAX_GLOBAL_API_KEY`。
27
83
  *
@@ -36,15 +92,12 @@ function envVarFor(providerId: string): string {
36
92
 
37
93
  const initCmd: CommandHandler = async (ctx) => {
38
94
  const { existsSync, mkdirSync } = await import('node:fs')
39
- const { join } = await import('node:path')
40
- const { homedir } = await import('node:os')
41
95
 
42
- const home = homedir()
43
- const userConfigPath = join(home, '.mipham', 'config.yml')
96
+ const userConfigPath = miphamHome('config.yml')
44
97
 
45
98
  // Generate user-friendly config if it doesn't exist yet
46
99
  if (!existsSync(userConfigPath)) {
47
- mkdirSync(join(home, '.mipham'), { recursive: true })
100
+ mkdirSync(miphamHome(), { recursive: true })
48
101
 
49
102
  // 生成物必须来自随包发布的清单,不能来自 ctx.config —— 后者的 status 表达
50
103
  // 的是「用户停用了哪家」这个**运行时**状态,而 12 家里只有 mipham 声明了它
@@ -89,7 +142,7 @@ const initCmd: CommandHandler = async (ctx) => {
89
142
  # ── Defaults ──
90
143
  defaultProvider: ${ctx.providerId}
91
144
  defaultModel: ${ctx.modelId}
92
- permission: ask
145
+ permission: default
93
146
 
94
147
  # ── Providers (${presetProviders.length} pre-configured — just add your API keys) ──
95
148
  providers:
@@ -186,11 +239,12 @@ Mode: ${ctx.engine.getPermission().getMode()}
186
239
  Messages: ${msgs.length} in context
187
240
  Tools: ${ctx.engine.getTools().size} available
188
241
 
189
- Switch mode with Shift+Tab. Modes (least → most permissive):
190
- default — per-tool defaults (Bash/Write/Edit ask first)
191
- acceptEdits — reads + edits free; Bash auto-runs read/check commands
192
- plan — reads only (Read/Grep/Glob); nothing writes or runs
193
- bypassPermissions — skip ALL permission checks (⚠️ use with caution)
242
+ Shift+Tab cycles: ${renderCycleLine()} (this session only).
243
+ To persist a mode, set \`permission:\` in .mipham/config.yml — every mode below is
244
+ accepted there.
245
+
246
+ Modes (least → most permissive):
247
+ ${renderModeTable()}
194
248
 
195
249
  To let Bash run without asking: press Shift+Tab until the status line shows
196
250
  "acceptEdits", then send your message again.
@@ -338,7 +392,7 @@ const recommendCmd: CommandHandler = async (ctx) => {
338
392
  lines.push('── Configuration Tips ──')
339
393
  lines.push('')
340
394
 
341
- const hasProjectMipham = existsSync(join(cwd, '.mipham'))
395
+ const hasProjectMipham = existsSync(join(cwd, MIPHAM_DIR))
342
396
  if (!hasProjectMipham) {
343
397
  lines.push(' /setup 1 Initialize .mipham/ + MIPHAM.md + config.yml')
344
398
  }
@@ -391,12 +445,11 @@ const setupCmd: CommandHandler = async (ctx, args) => {
391
445
  const { existsSync } = await import('node:fs')
392
446
  const { join } = await import('node:path')
393
447
  const cwd = process.cwd()
394
- const home = homedir()
395
448
 
396
449
  const hasProjectMipham = existsSync(join(cwd, 'MIPHAM.md'))
397
- const hasProjectConfig = existsSync(join(cwd, '.mipham', 'config.yml'))
398
- const hasUserConfig = existsSync(join(home, '.mipham', 'config.yml'))
399
- const hasMiphamDir = existsSync(join(cwd, '.mipham'))
450
+ const hasProjectConfig = existsSync(join(cwd, MIPHAM_DIR, 'config.yml'))
451
+ const hasUserConfig = existsSync(miphamHome('config.yml'))
452
+ const hasMiphamDir = existsSync(join(cwd, MIPHAM_DIR))
400
453
 
401
454
  const activeProviders = ctx.config.providers.filter((p) => p.status === 'active').length
402
455
  const totalProviders = ctx.config.providers.length
@@ -444,7 +497,7 @@ async function setupStep1(ctx: CommandContext): Promise<CommandResult> {
444
497
  const { join } = await import('node:path')
445
498
  const cwd = process.cwd()
446
499
 
447
- const miphamDir = join(cwd, '.mipham')
500
+ const miphamDir = join(cwd, MIPHAM_DIR)
448
501
  const miphamPath = join(cwd, 'MIPHAM.md')
449
502
  const configPath = join(miphamDir, 'config.yml')
450
503
 
@@ -511,7 +564,7 @@ async function setupStep1(ctx: CommandContext): Promise<CommandResult> {
511
564
  # ── Defaults ──
512
565
  defaultProvider: ${ctx.providerId}
513
566
  defaultModel: ${ctx.modelId}
514
- permission: ask
567
+ permission: default
515
568
 
516
569
  # ── Providers (${presetProviders.length} pre-configured — just add your API keys) ──
517
570
  providers:
@@ -687,14 +740,19 @@ async function setupStep5(ctx: CommandContext): Promise<CommandResult> {
687
740
  return {
688
741
  content: `── Step 5: Permissions Setup ──
689
742
 
690
- Current mode: ${ctx.config.permission}
743
+ Current mode: ${ctx.engine.getPermission().getMode()}
744
+ config.yml: ${ctx.config.permission}
691
745
 
692
- Permission levels:
693
- auto — Run tools automatically (suitable for sandboxed envs)
694
- ask — Prompt before each tool execution (default, recommended)
695
- bypass — Skip all checks (⚠ only for trusted codebases)
746
+ Shift+Tab cycles: ${renderCycleLine()} (this session only).
696
747
 
697
- Change with: /config permission <level>
748
+ Modes (least → most permissive):
749
+ ${renderModeTable()}
750
+
751
+ To persist one for a project, .mipham/config.yml:
752
+ permission: acceptEdits
753
+ permissionRules:
754
+ allow: ["Bash(npm test)"] # or: /permissions allow "Bash(npm test)"
755
+ deny: ["Bash(rm *)"]
698
756
 
699
757
  Available tools (${ctx.engine.getTools().size}):
700
758
  File: read, write, edit, glob, grep
@@ -703,12 +761,6 @@ Available tools (${ctx.engine.getTools().size}):
703
761
  Net: web-fetch, web-search
704
762
  Sys: config, mcp
705
763
 
706
- Each tool category can be configured independently in .mipham/config.yml:
707
- permissions:
708
- file: ask
709
- exec: ask
710
- network: auto
711
-
712
764
  Next: /setup 6 for shell integration`,
713
765
  }
714
766
  }
@@ -784,7 +836,8 @@ To persist across sessions, add to .mipham/config.yml:
784
836
  - ${resolved}
785
837
 
786
838
  The AI can now access files in this directory.
787
- Permission level is controlled by /config permission <level>.`,
839
+ Permission mode: Shift+Tab cycles ${renderCycleLine()} (this session);
840
+ \`permission:\` in .mipham/config.yml persists one.`,
788
841
  }
789
842
  }
790
843
 
@@ -872,8 +925,8 @@ const promptAuditCmd: CommandHandler = async () => {
872
925
 
873
926
  // Files to scan
874
927
  const scanDirs = [
875
- join(cwd, '.mipham', 'skills'),
876
- join(cwd, '.mipham', 'rules'),
928
+ join(cwd, MIPHAM_DIR, 'skills'),
929
+ join(cwd, MIPHAM_DIR, 'rules'),
877
930
  join(cwd, '.claude'),
878
931
  ]
879
932
  const skillFiles: string[] = []
@@ -1,10 +1,10 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'
2
2
  import { join, dirname } from 'node:path'
3
- import { homedir } from 'node:os'
4
3
  import { atomicWriteFileSync } from '../shared/atomic-write'
5
4
  import { saveProviderApiKey } from './loader'
5
+ import { miphamHome } from '../core/paths.ts'
6
6
 
7
- const MIPHAM_HOME = join(homedir(), '.mipham')
7
+ const MIPHAM_HOME = miphamHome()
8
8
  const KEYS_FILE = join(MIPHAM_HOME, 'keys.json')
9
9
  const EXPIRY_DAYS = 90
10
10
 
@@ -8,7 +8,6 @@ import {
8
8
  chmodSync,
9
9
  } from 'node:fs'
10
10
  import { join, dirname } from 'node:path'
11
- import { homedir } from 'node:os'
12
11
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
13
12
  import { atomicWriteFileSync } from '../shared/atomic-write'
14
13
  import type {
@@ -29,8 +28,10 @@ import {
29
28
  } from './defaults'
30
29
  import { getCredentialKey, encryptApiKey, decryptApiKey, ENC_PREFIX } from './credential-crypto'
31
30
  import type { SettingsHooks } from '../core/hooks-config'
31
+ import { miphamHome } from '../core/paths.ts'
32
+ import { MIPHAM_DIR } from '../shared/constants.ts'
32
33
 
33
- const MIPHAM_HOME = join(homedir(), '.mipham')
34
+ const MIPHAM_HOME = miphamHome()
34
35
  const BACKUP_PREFIX = 'config.backup-'
35
36
 
36
37
  /**
@@ -212,7 +213,7 @@ export function tryRestoreFromBackup(configPath: string): boolean {
212
213
  function loadMcpJson(cwd: string): McpServerConfig[] {
213
214
  const servers: McpServerConfig[] = []
214
215
  const searchPaths = [
215
- join(cwd, '.mipham', 'mcp.json'),
216
+ join(cwd, MIPHAM_DIR, 'mcp.json'),
216
217
  join(cwd, '.mcp.json'),
217
218
  join(MIPHAM_HOME, 'mcp.json'),
218
219
  ]
@@ -294,7 +295,7 @@ export function loadSettingsJson(
294
295
 
295
296
  const searchPaths: Array<{ path: string; readHooks: boolean; isProject?: boolean }> = [
296
297
  {
297
- path: join(cwd, '.mipham', 'settings.json'),
298
+ path: join(cwd, MIPHAM_DIR, 'settings.json'),
298
299
  readHooks: options.includeProjectHooks ?? false,
299
300
  isProject: true,
300
301
  },
@@ -366,7 +367,7 @@ export type SettingsScope = 'project' | 'user'
366
367
  export function settingsPathFor(scope: SettingsScope, cwd: string = process.cwd()): string {
367
368
  return scope === 'user'
368
369
  ? join(MIPHAM_HOME, 'settings.json')
369
- : join(cwd, '.mipham', 'settings.json')
370
+ : join(cwd, MIPHAM_DIR, 'settings.json')
370
371
  }
371
372
 
372
373
  /**
@@ -442,7 +443,7 @@ export function removeSettingsRule(
442
443
  }
443
444
 
444
445
  export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
445
- const configPath = join(cwd, '.mipham', 'config.yml')
446
+ const configPath = join(cwd, MIPHAM_DIR, 'config.yml')
446
447
  const userConfigPath = join(MIPHAM_HOME, 'config.yml')
447
448
 
448
449
  let config = { ...DEFAULT_CONFIG }
@@ -629,7 +630,7 @@ function mergeCredentialMaskingFile(
629
630
  export function loadCredentialMaskingConfig(cwd: string = process.cwd()): CredentialMaskingConfig {
630
631
  let merged = { ...DEFAULT_CREDENTIAL_MASKING_CONFIG }
631
632
  merged = mergeCredentialMaskingFile(merged, join(MIPHAM_HOME, 'config.yml'), true)
632
- merged = mergeCredentialMaskingFile(merged, join(cwd, '.mipham', 'config.yml'), false)
633
+ merged = mergeCredentialMaskingFile(merged, join(cwd, MIPHAM_DIR, 'config.yml'), false)
633
634
  return merged
634
635
  }
635
636
 
@@ -655,7 +656,7 @@ export function loadUserCredentialMaskingConfig(): CredentialMaskingConfig {
655
656
  * Load background agent configuration from config sources.
656
657
  */
657
658
  export function loadBackgroundAgentConfig(cwd: string = process.cwd()): BackgroundAgentConfig {
658
- const configPath = join(cwd, '.mipham', 'config.yml')
659
+ const configPath = join(cwd, MIPHAM_DIR, 'config.yml')
659
660
  const userConfigPath = join(MIPHAM_HOME, 'config.yml')
660
661
 
661
662
  let merged = { ...DEFAULT_BACKGROUND_AGENT_CONFIG }
@@ -688,7 +689,7 @@ export function loadBackgroundAgentConfig(cwd: string = process.cwd()): Backgrou
688
689
  * Merges project-level over user-level. Returns defaults if no section present.
689
690
  */
690
691
  export function loadCrossSessionConfig(cwd: string = process.cwd()): CrossSessionConfig {
691
- const configPath = join(cwd, '.mipham', 'config.yml')
692
+ const configPath = join(cwd, MIPHAM_DIR, 'config.yml')
692
693
  const userConfigPath = join(MIPHAM_HOME, 'config.yml')
693
694
 
694
695
  let merged = { ...DEFAULT_CROSS_SESSION_CONFIG }
@@ -751,7 +752,7 @@ function decryptProviderApiKeys(providers: ProviderConfig[] | undefined): void {
751
752
  */
752
753
  export function getProviderApiKey(providerId: string, cwd: string = process.cwd()): string | null {
753
754
  const userConfigPath = join(MIPHAM_HOME, 'config.yml')
754
- const projectConfigPath = join(cwd, '.mipham', 'config.yml')
755
+ const projectConfigPath = join(cwd, MIPHAM_DIR, 'config.yml')
755
756
  const configPath = existsSync(userConfigPath) ? userConfigPath : projectConfigPath
756
757
 
757
758
  try {