@miphamai/cli 0.85.2 → 0.85.3

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.
@@ -147,6 +147,35 @@ function mergeConfig(
147
147
  return merged
148
148
  }
149
149
 
150
+ /**
151
+ * Drop `permission` from a **project-level** `config.yml` before it is merged,
152
+ * reporting it instead of applying it.
153
+ *
154
+ * `permission` is a ceiling, not a rule: it decides what the approval gate lets
155
+ * through without asking. Every other project-level key is a preference that a
156
+ * repository may reasonably commit; this one means "and do not ask me about the
157
+ * commands in here" — which is a decision about the operator, made by whoever
158
+ * wrote the repository. The same reasoning admits project-level `permissions.deny`
159
+ * (it narrows) and withholds `permissions.defaultMode` in `settings.json` (it
160
+ * widens) — the two files are the same door, so both are closed here.
161
+ *
162
+ * `mergeConfig` has no per-key allowlist, so this has to happen at the two points
163
+ * the project file enters. Reported rather than dropped silently: a repo whose
164
+ * setting stopped working should say so out loud, and silence is exactly what
165
+ * "your config was ignored" looks like from the outside.
166
+ */
167
+ function stripProjectPermission(cfg: Partial<MiphamConfig>, path: string): Partial<MiphamConfig> {
168
+ if (cfg.permission === undefined) return cfg
169
+ const { permission, ...rest } = cfg
170
+ const shown = typeof permission === 'string' ? permission : JSON.stringify(permission)
171
+ process.stderr.write(
172
+ `⚠ Mipham Code: ignored permission: ${shown} from project config ${path}\n` +
173
+ ` (a repository must not choose the approval gate — set it in ~/.mipham/config.yml,\n` +
174
+ ` or pass --permission <mode> for this invocation)\n`,
175
+ )
176
+ return rest as Partial<MiphamConfig>
177
+ }
178
+
150
179
  /**
151
180
  * Save a timestamped backup of config.yml to ~/.mipham/.
152
181
  * Keeps at most 5 backups; older ones are pruned.
@@ -254,11 +283,21 @@ function loadMcpJson(cwd: string): McpServerConfig[] {
254
283
 
255
284
  /**
256
285
  * Parsed `settings.json` (Claude Code convention): hooks + permissions.
257
- * Hooks are additive across levels; permissions allow/deny are deduped unions.
286
+ * Hooks are additive across levels; permissions allow/deny are deduped unions —
287
+ * and `defaultMode` is the one member that is **not** merged, because it is a
288
+ * ceiling rather than a rule (see `loadSettingsJson`).
258
289
  */
259
290
  export interface SettingsJson {
260
291
  hooks: SettingsHooks
261
- permissions: { allow: string[]; deny: string[] }
292
+ /**
293
+ * `defaultMode` is present only when the **user-level** file named one, and it
294
+ * is passed through **raw** (not validated here): the accepted spelling is
295
+ * `ALL_MODES`, and the single place that enforces it — plus the warning for a
296
+ * value that is not a mode — is `PermissionSystem.setDefaultLevel`. A second
297
+ * validator here would be a second value domain, and the two would drift the
298
+ * day a mode is added.
299
+ */
300
+ permissions: { allow: string[]; deny: string[]; defaultMode?: string }
262
301
  /**
263
302
  * Present (and `true`) only when the project-level file really did declare
264
303
  * hooks and they were withheld because the caller did not vouch for the
@@ -266,6 +305,13 @@ export interface SettingsJson {
266
305
  * so a caller announcing the skip cannot announce one that never happened.
267
306
  */
268
307
  projectHooksSkipped?: true
308
+ /**
309
+ * Same shape, for `permissions.defaultMode`: present only when the
310
+ * project-level file really declared a mode and it was **withheld**. Unlike
311
+ * `projectHooksSkipped` this is not conditioned on trust — the ceiling does not
312
+ * move for a file that arrives with the code, trusted workspace or not.
313
+ */
314
+ projectModeSkipped?: true
269
315
  /**
270
316
  * The subset of `hooks` that came from the project-level file — the entries
271
317
  * the workspace-trust gate governs. Absent unless the caller vouched for the
@@ -289,14 +335,35 @@ export interface SettingsJson {
289
335
  * established trust (or that only *display* the configured list) opt in
290
336
  * explicitly. The flag gates hooks only: `permissions` still merge from both
291
337
  * levels, since that question is answered by the mode ceiling, not by trust.
338
+ *
339
+ * **That last sentence is the reason `defaultMode` is the exception.** The
340
+ * ceiling only answers the trust question while repository-controlled files
341
+ * cannot move it; a repo that ships `.mipham/settings.json` with
342
+ * `permissions.defaultMode: "auto"` would otherwise hand itself the approval
343
+ * gate. So `defaultMode` is read from the **user-level file only**, and a
344
+ * project-level one is withheld and *reported* (`projectModeSkipped`) rather
345
+ * than dropped in silence — same treatment as a `plan`/`acceptEdits` value that
346
+ * used to be dropped entirely, except that the drop there fell back to the
347
+ * *wider* `default`. The upstream convention says the same thing in its own
348
+ * words: repo-level settings cannot grant `defaultMode`; adopt it in user
349
+ * settings instead.
350
+ *
351
+ * One consequence to keep in mind when reading a merged result: `permissions`
352
+ * is still provenance-free for allow/deny, so a caller cannot tell which of
353
+ * those two rules came from the repository. That is deliberate (above), and it
354
+ * is why the ceiling has to be the thing that repels the repo-controlled half.
292
355
  */
293
356
  export function loadSettingsJson(
294
357
  cwd: string = process.cwd(),
295
358
  options: { includeProjectHooks?: boolean } = {},
296
359
  ): SettingsJson {
297
360
  const hooks: SettingsHooks = {}
298
- const permissions = { allow: [] as string[], deny: [] as string[] }
361
+ const permissions: { allow: string[]; deny: string[]; defaultMode?: string } = {
362
+ allow: [],
363
+ deny: [],
364
+ }
299
365
  let projectHooksSkipped = false
366
+ let projectModeSkipped = false
300
367
  // The project file's entries, kept out of the merge so provenance survives it.
301
368
  const projectHooks: SettingsHooks = {}
302
369
 
@@ -315,7 +382,7 @@ export function loadSettingsJson(
315
382
  if (raw === null) continue
316
383
  const parsed = JSON.parse(raw) as {
317
384
  hooks?: Record<string, unknown>
318
- permissions?: { allow?: unknown; deny?: unknown }
385
+ permissions?: { allow?: unknown; deny?: unknown; defaultMode?: unknown }
319
386
  }
320
387
 
321
388
  if (!readHooks) {
@@ -343,6 +410,20 @@ export function loadSettingsJson(
343
410
  }
344
411
 
345
412
  if (parsed.permissions) {
413
+ // A mode counts as *declared* only when it is a non-empty string — the
414
+ // same test the hooks branch above uses, so a marker cannot outrun the
415
+ // fact it reports. What the string says is not checked here (see the
416
+ // `permissions` field doc): a typo must reach the applier, which is the
417
+ // one place that knows the accepted spellings and issues the warning.
418
+ const declaredMode =
419
+ typeof parsed.permissions.defaultMode === 'string' &&
420
+ parsed.permissions.defaultMode.trim() !== ''
421
+ ? parsed.permissions.defaultMode
422
+ : undefined
423
+ if (declaredMode !== undefined) {
424
+ if (isProject) projectModeSkipped = true
425
+ else permissions.defaultMode = declaredMode
426
+ }
346
427
  for (const key of ['allow', 'deny'] as const) {
347
428
  const list = parsed.permissions[key]
348
429
  if (!Array.isArray(list)) continue
@@ -362,6 +443,7 @@ export function loadSettingsJson(
362
443
  // gated on the strength of a file with nothing in it.
363
444
  const result: SettingsJson = { hooks, permissions }
364
445
  if (projectHooksSkipped) result.projectHooksSkipped = true
446
+ if (projectModeSkipped) result.projectModeSkipped = true
365
447
  if (Object.values(projectHooks).some((entries) => Array.isArray(entries) && entries.length > 0)) {
366
448
  result.projectHooks = projectHooks
367
449
  }
@@ -456,10 +538,20 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
456
538
 
457
539
  let config = { ...DEFAULT_CONFIG }
458
540
 
541
+ // The project file enters `loadConfig` at **two** points (fresh parse, and the
542
+ // parse that follows a restore from backup). Both go through here, so the guard
543
+ // on `permission` cannot be present on one path and missing on the other — a
544
+ // corrupted config that recovers from a backup is still the same repository's
545
+ // file, and "corrupt it once, get the setting honored" would be a bypass of the
546
+ // one key that is refused.
547
+ const applyProjectConfig = (parsed: Partial<MiphamConfig>): void => {
548
+ config = mergeConfig(config, stripProjectPermission(parsed, configPath), false)
549
+ }
550
+
459
551
  // ── Load project-level config ──
460
552
  const projectConfig = safeParseYaml(configPath, 'project config')
461
553
  if (projectConfig) {
462
- config = mergeConfig(config, projectConfig, false)
554
+ applyProjectConfig(projectConfig)
463
555
  } else if (isRegularFile(configPath)) {
464
556
  // A real file is there but failed to parse — try to restore from backup.
465
557
  // 非普通文件走不到这里:那不是「损坏的配置」,而是根本不该当配置读的东西
@@ -471,7 +563,7 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
471
563
  // Retry parsing after restore
472
564
  const restored = safeParseYaml(configPath, 'restored project config')
473
565
  if (restored) {
474
- config = mergeConfig(config, restored, false)
566
+ applyProjectConfig(restored)
475
567
  }
476
568
  }
477
569
  }
@@ -37,6 +37,14 @@ interface Checkpoint {
37
37
  export class ContextManager {
38
38
  private messages: Message[] = []
39
39
  private systemPrompt = ''
40
+ /**
41
+ * 系统提示里的**权限段**是读时派生的,不是组装时烘进 `systemPrompt` 的一份拷贝。
42
+ *
43
+ * 烘进去的那份拷贝会与执行分叉:Shift+Tab 之后闸门与页脚都变了,模型手里还是旧指令
44
+ * —— 往窄切是自纠正的(模型比闸门更保守),**往宽切**则让模型拒绝做它已经被允许做的事。
45
+ * `index.tsx` 把它接到 live `PermissionSystem.getMode()` 上,于是切一次档,下一次请求就变。
46
+ */
47
+ private permissionContextSource: (() => string) | null = null
40
48
  private estimatedTokens = 0
41
49
  private checkpoints: Checkpoint[] = []
42
50
  private checkpointCounter = 0
@@ -116,11 +124,32 @@ export class ContextManager {
116
124
 
117
125
  setSystemPrompt(prompt: string): void {
118
126
  this.systemPrompt = prompt
119
- this.estimatedTokens = this.estimateTokens(prompt)
127
+ this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
128
+ }
129
+
130
+ /**
131
+ * 接线点(`index.tsx`):把权限段接到 live `PermissionSystem.getMode()` 上。
132
+ *
133
+ * 传 `null` 撤销。**不接**时系统提示里没有权限段(这里是唯一的施加点,故
134
+ * `test/integrity/permission-status-parity.test.ts` 从源码侧断这一行在场)。
135
+ */
136
+ setPermissionContextSource(fn: (() => string) | null): void {
137
+ this.permissionContextSource = fn
138
+ }
139
+
140
+ /**
141
+ * 存储的提示 + 读时派生的权限段。
142
+ *
143
+ * 段尾追加(而非插回原来的中段位置)是刻意的:只切档时**前缀保持不变**,
144
+ * 提供方的 prefix cache 仍能命中到权限段之前的部分。
145
+ */
146
+ private composedSystemPrompt(): string {
147
+ const block = this.permissionContextSource?.() ?? ''
148
+ return block ? `${this.systemPrompt}\n\n---\n\n${block}` : this.systemPrompt
120
149
  }
121
150
 
122
151
  getSystemPrompt(): string {
123
- return this.systemPrompt
152
+ return this.composedSystemPrompt()
124
153
  }
125
154
 
126
155
  addMessage(msg: Message): void {
@@ -239,7 +268,7 @@ export class ContextManager {
239
268
  }
240
269
 
241
270
  // Re-estimate tokens
242
- this.estimatedTokens = this.estimateTokens(this.systemPrompt)
271
+ this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
243
272
  for (const msg of this.messages) {
244
273
  this.estimatedTokens += this.estimateTokens(
245
274
  typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
@@ -257,7 +286,7 @@ export class ContextManager {
257
286
  this.messages = []
258
287
  this.checkpoints = []
259
288
  this.checkpointCounter = 0
260
- this.estimatedTokens = this.estimateTokens(this.systemPrompt)
289
+ this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
261
290
  }
262
291
 
263
292
  getMessageCount(): number {
@@ -273,7 +302,7 @@ export class ContextManager {
273
302
  replaceMessages(messages: Message[]): void {
274
303
  this.messages = messages
275
304
  // Re-estimate tokens
276
- this.estimatedTokens = this.estimateTokens(this.systemPrompt)
305
+ this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
277
306
  for (const msg of messages) {
278
307
  this.estimatedTokens += this.estimateTokens(
279
308
  typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
@@ -418,7 +447,7 @@ export class ContextManager {
418
447
 
419
448
  /** Re-estimate tokens from system prompt + current messages. */
420
449
  private reEstimateTokens(): void {
421
- this.estimatedTokens = this.estimateTokens(this.systemPrompt)
450
+ this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
422
451
  for (const msg of this.messages) {
423
452
  this.estimatedTokens += this.estimateTokens(
424
453
  typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
@@ -104,6 +104,58 @@ export const INSTRUCTION_FILENAMES = [
104
104
  'CLAUDE.md',
105
105
  ] as const
106
106
 
107
+ /**
108
+ * P2-2: the permission-mode section of the system prompt. Tells the model its
109
+ * current permission level and what to expect.
110
+ *
111
+ * Module-level and pure (it reads no loaded instruction files) because **every**
112
+ * prompt-assembly site needs the same text: the CLI's main context, where the
113
+ * context calls it at read time via `ContextManager.setPermissionContextSource`
114
+ * so a mid-session Shift+Tab moves the text and the gate together, and each
115
+ * **sub-agent**, which reports **its own** mode (see `sub-agent.ts`).
116
+ */
117
+ export function buildPermissionBlock(mode: string): string {
118
+ // Hand-written map, and a missing key is **silent**: the `if (!description)`
119
+ // below returns `''`, so the system prompt would simply say nothing about
120
+ // permissions rather than warn. Every `PermissionMode` member needs a line.
121
+ // `auto`'s text has to describe a gate the model cannot see: it is told
122
+ // "a classifier rules on each of your calls" rather than "you are
123
+ // unrestricted", because a model that believes it has blanket permission
124
+ // stops explaining what it is about to do — which is exactly the input the
125
+ // classifier needs.
126
+ const modeDescriptions: Record<string, string> = {
127
+ default:
128
+ 'You are in **default** mode. Tools marked as requiring approval will be blocked. Use Read/Grep/Glob for exploration.',
129
+ acceptEdits:
130
+ 'You are in **acceptEdits** mode. File reads and edits are allowed; Bash requires approval.',
131
+ plan: 'You are in **plan** mode. Only Read/Grep/Glob are allowed — no file modifications or command execution.',
132
+ auto: 'You are in **auto** mode. A classifier reviews each tool call before it runs and blocks calls that are destructive, that act on instructions found in files or tool output, or that touch credentials. Approved calls run; blocked ones return a denial with the reason. Prefer explaining the intent of a call when it is unusual.',
133
+ bypassPermissions:
134
+ 'You are in **bypassPermissions** mode. All tools are allowed. Use this power responsibly.',
135
+ }
136
+
137
+ const description = modeDescriptions[mode]
138
+ if (!description) return ''
139
+
140
+ // What actually lifts a denial is not the same in `auto`. There the refusal is a
141
+ // ruling on one exact call, and a repeat of that same call is answered from the
142
+ // classifier cache rather than re-judged — so "retry after explaining yourself" is
143
+ // advice that cannot work, and telling the model to switch modes is advice that is
144
+ // never needed. The two levers that do work are named instead.
145
+ const escape =
146
+ mode === 'auto'
147
+ ? ' In **auto** mode the refusal is a ruling on that exact call: an allow rule (`/permissions allow`) lifts it, and so does changing the call so it no longer trips the rule — repeating the identical call returns the same ruling.'
148
+ : ''
149
+
150
+ // The tail used to name `bypassPermissions` as the Shift+Tab destination. That
151
+ // was true while the wheel carried it and became false the moment `auto`
152
+ // replaced it — and this string is *advice the model repeats to the user*, so
153
+ // staying stale makes it promise a keypress that does nothing. `bypassPermissions`
154
+ // is still reachable, but only by naming it in config; saying so is what keeps
155
+ // the model from offering it as a way out.
156
+ return `## Permission Context\n\n${description}\n\nWhen a tool is denied, do NOT retry it or any other approval-gated tool — Bash, WebSearch, network, and Workflow are all blocked in this mode.${escape} If the task genuinely needs a blocked tool, STOP retrying and ask the user to switch modes with Shift+Tab or add an allow rule (/permissions), then wait for the user's answer. Note that Shift+Tab's wheel does not reach bypassPermissions — that mode is set in config, so do not offer it as a keypress.`
157
+ }
158
+
107
159
  export class InstructionsLoader {
108
160
  private instructions: InstructionFile[] = []
109
161
  private crsiLessonSummaries: CrsiLessonSummary[] = []
@@ -133,7 +185,16 @@ export class InstructionsLoader {
133
185
  this.crsiLessonSummaries = this.loadCrsiLessons(root)
134
186
  }
135
187
 
136
- buildSystemPrompt(permissionMode?: string): string {
188
+ /**
189
+ * The base prompt. **Deliberately takes no permission mode** — the mode is not a
190
+ * component of the prompt that gets assembled once, it is the answer to "which gate
191
+ * will run this call", and that answer changes mid-session (Shift+Tab). Baking it in
192
+ * here froze a copy: narrowing was self-correcting (the model obeys a gate that is now
193
+ * wider than it was told), but *widening* left the model refusing work it was already
194
+ * allowed to do. `ContextManager.setPermissionContextSource` derives the section on
195
+ * every read instead, so `permission.getMode()` is never sampled once and cached.
196
+ */
197
+ buildSystemPrompt(): string {
137
198
  const parts: string[] = []
138
199
 
139
200
  for (const inst of this.instructions) {
@@ -156,11 +217,8 @@ export class InstructionsLoader {
156
217
  parts.push(`<!-- ${levelLabel[inst.level] || inst.level} (${inst.path}) -->\n${content}`)
157
218
  }
158
219
 
159
- // P2-2: Inject current permission mode so the model knows its constraints
160
- if (permissionMode) {
161
- parts.push(this.buildPermissionContext(permissionMode))
162
- }
163
-
220
+ // P2-2 的权限段**不在**这里 —— 见 `buildPermissionBlock` 与
221
+ // `ContextManager.setPermissionContextSource`(读时派生,故切档即生效)。
164
222
  // 开场克制:寒暄只回一句短问候,不上能力清单(避免把「你好」当「你是谁」处理)
165
223
  parts.push(`## Greeting Restraint
166
224
 
@@ -322,49 +380,12 @@ Never omit it or present the work as purely human-authored.`)
322
380
  }
323
381
 
324
382
  /**
325
- * P2-2: Build a permission-mode context block for the system prompt.
326
- * Tells the model its current permission level and what to expect.
383
+ * Loader-shaped alias of {@link buildPermissionBlock}(模块级那个才是实现)。
384
+ * 保留它是因为**已有**的调用点都握着一个装载器:`index.tsx` 的接线行与
385
+ * `test/core/permission-prompt-live.test.ts` 的 `wire()`。它不含任何状态。
327
386
  */
328
- private buildPermissionContext(mode: string): string {
329
- // Hand-written map, and a missing key is **silent**: the `if (!description)`
330
- // below returns `''`, so the system prompt would simply say nothing about
331
- // permissions rather than warn. Every `PermissionMode` member needs a line.
332
- // `auto`'s text has to describe a gate the model cannot see: it is told
333
- // "a classifier rules on each of your calls" rather than "you are
334
- // unrestricted", because a model that believes it has blanket permission
335
- // stops explaining what it is about to do — which is exactly the input the
336
- // classifier needs.
337
- const modeDescriptions: Record<string, string> = {
338
- default:
339
- 'You are in **default** mode. Tools marked as requiring approval will be blocked. Use Read/Grep/Glob for exploration.',
340
- acceptEdits:
341
- 'You are in **acceptEdits** mode. File reads and edits are allowed; Bash requires approval.',
342
- plan: 'You are in **plan** mode. Only Read/Grep/Glob are allowed — no file modifications or command execution.',
343
- auto: 'You are in **auto** mode. A classifier reviews each tool call before it runs and blocks calls that are destructive, that act on instructions found in files or tool output, or that touch credentials. Approved calls run; blocked ones return a denial with the reason. Prefer explaining the intent of a call when it is unusual.',
344
- bypassPermissions:
345
- 'You are in **bypassPermissions** mode. All tools are allowed. Use this power responsibly.',
346
- }
347
-
348
- const description = modeDescriptions[mode]
349
- if (!description) return ''
350
-
351
- // What actually lifts a denial is not the same in `auto`. There the refusal is a
352
- // ruling on one exact call, and a repeat of that same call is answered from the
353
- // classifier cache rather than re-judged — so "retry after explaining yourself" is
354
- // advice that cannot work, and telling the model to switch modes is advice that is
355
- // never needed. The two levers that do work are named instead.
356
- const escape =
357
- mode === 'auto'
358
- ? ' In **auto** mode the refusal is a ruling on that exact call: an allow rule (`/permissions allow`) lifts it, and so does changing the call so it no longer trips the rule — repeating the identical call returns the same ruling.'
359
- : ''
360
-
361
- // The tail used to name `bypassPermissions` as the Shift+Tab destination. That
362
- // was true while the wheel carried it and became false the moment `auto`
363
- // replaced it — and this string is *advice the model repeats to the user*, so
364
- // staying stale makes it promise a keypress that does nothing. `bypassPermissions`
365
- // is still reachable, but only by naming it in config; saying so is what keeps
366
- // the model from offering it as a way out.
367
- return `## Permission Context\n\n${description}\n\nWhen a tool is denied, do NOT retry it or any other approval-gated tool — Bash, WebSearch, network, and Workflow are all blocked in this mode.${escape} If the task genuinely needs a blocked tool, STOP retrying and ask the user to switch modes with Shift+Tab or add an allow rule (/permissions), then wait for the user's answer. Note that Shift+Tab's wheel does not reach bypassPermissions — that mode is set in config, so do not offer it as a keypress.`
387
+ buildPermissionBlock(mode: string): string {
388
+ return buildPermissionBlock(mode)
368
389
  }
369
390
 
370
391
  list(): InstructionFile[] {
@@ -83,10 +83,31 @@ export const PERMISSION_MODE_HIERARCHY: PermissionMode[] = [
83
83
  * `forbiddenModes` / `maxAllowedMode` are applied to.
84
84
  *
85
85
  * Deliberately a separate array from `MODE_CYCLE`, and deliberately able to be a
86
- * **superset** of it: `bypassPermissions` is reachable through config /
87
- * `MIPHAM_DAEMON_PERMISSION` / settings without being something a user can
88
- * Shift+Tab into. Claude Code arranges it the same way — its descriptor table
89
- * lists `bypassPermissions` while its cycle array does not.
86
+ * **superset** of it: `bypassPermissions` is reachable by naming it at one of the
87
+ * doors listed below, without being something a user can Shift+Tab into. Claude
88
+ * Code arranges it the same way — its descriptor table lists
89
+ * `bypassPermissions` while its cycle array does not.
90
+ *
91
+ * **The doors, exhaustively** (this list is the point of the entry — a mode that
92
+ * is legal here and refused at some other door is a mode the user cannot reach
93
+ * and cannot get a reason for):
94
+ *
95
+ * 1. `~/.mipham/config.yml` → `permission: <mode>` (native key; may be the
96
+ * first-run wizard's `default`, so it is ranked *below* 2)
97
+ * 2. `~/.mipham/settings.json` → `permissions.defaultMode` (the adopted
98
+ * upstream key — user level **only**, see 5)
99
+ * 3. `mipham --permission <mode>` — this invocation's word, beats both files
100
+ * 4. `MIPHAM_DAEMON_PERMISSION` — the daemon's gate; the only door the daemon
101
+ * reads at all (`resolveDaemonPermission`)
102
+ * 5. …and nothing else. Project-level `.mipham/config.yml` /
103
+ * `.mipham/settings.json` are **not** doors: those files arrive with the
104
+ * code, so whoever wrote the repository would be choosing the approval gate.
105
+ * A mode declared there is withheld and reported, not applied.
106
+ *
107
+ * Every door lands on the same applier (`PermissionSystem.setDefaultLevel`), which
108
+ * is why this array is also the accepted *value* domain — a second validator would
109
+ * be a second domain, and the day a mode is added the two would disagree at one of
110
+ * the five doors.
90
111
  *
91
112
  * **The two arrays must not be collapsed back into one.** `getAllowedModes`
92
113
  * filters *this* array, never `MODE_CYCLE`. If it filtered the cycle, then the
@@ -115,9 +136,9 @@ export const ALL_MODES: PermissionMode[] = [
115
136
  * disturbing the ranking that `clampMode` walks.
116
137
  *
117
138
  * The two arrays **differ**, and that is the whole reason both exist:
118
- * `bypassPermissions` is a legal mode that no Shift+Tab reaches (asked for
119
- * through config / `MIPHAM_DAEMON_PERMISSION` / settings, where the user named
120
- * it explicitly), while `auto` is on the wheel. Collapsing them back into one
139
+ * `bypassPermissions` is a legal mode that no Shift+Tab reaches (asked for by
140
+ * name at one of the doors listed under `ALL_MODES`, where the user named it
141
+ * explicitly), while `auto` is on the wheel. Collapsing them back into one
121
142
  * would either drop a legal mode or advertise one the wheel cannot reach —
122
143
  * **do not "simplify" one back into the other.**
123
144
  *
@@ -1,6 +1,9 @@
1
1
  // apps/cli/src/daemon/attach-protocol.ts
2
- // Client → Daemon: prompt, interrupt
3
- // Daemon → Client: text, tool_use, tool_result, usage, task_notification, done, error, session_state
2
+ // Client → Daemon: prompt, interrupt, set_mode
3
+ // Daemon → Client: text, tool_use, tool_result, usage, task_notification, done, error,
4
+ // session_state, mode
5
+
6
+ import type { PermissionMode } from '../shared/types'
4
7
 
5
8
  export interface ClientPromptMessage {
6
9
  type: 'prompt'
@@ -11,7 +14,18 @@ export interface ClientInterruptMessage {
11
14
  type: 'interrupt'
12
15
  sessionId: string
13
16
  }
14
- export type ClientMessage = ClientPromptMessage | ClientInterruptMessage
17
+ export interface ClientSetModeMessage {
18
+ type: 'set_mode'
19
+ sessionId: string
20
+ /**
21
+ * 请求的档位。daemon 会先过白名单、再走组织级限制的钳制,然后回播**生效**的那一档。
22
+ *
23
+ * `sessionId` 与 `prompt` / `interrupt` 一样是协议对称用的:daemon 一概以
24
+ * `ws.data.sessionId` 为准(否则一个 attach 就能改**别的**会话的闸门)。
25
+ */
26
+ mode: PermissionMode
27
+ }
28
+ export type ClientMessage = ClientPromptMessage | ClientInterruptMessage | ClientSetModeMessage
15
29
 
16
30
  export interface ServerTextMessage {
17
31
  type: 'text'
@@ -61,6 +75,18 @@ export interface ServerSessionStateMessage {
61
75
  provider: string
62
76
  model: string
63
77
  turnCount: number
78
+ /** 本会话当前**生效**的档位 —— 新 attach 的客户端据此初始化页脚,而不是猜 `default`。 */
79
+ mode: PermissionMode
80
+ }
81
+ /**
82
+ * 回播生效档位。两个触发点:客户端发来 `set_mode`(含被拒的请求 —— 那时回播的是
83
+ * **当前**档),以及 `set_mode` 施加后。带的是 `PermissionSystem.getMode()` 的读数,
84
+ * 即**钳制之后**的值:报请求值就是那条老缺陷的形状 —— 说放行、实际审批。
85
+ */
86
+ export interface ServerModeMessage {
87
+ type: 'mode'
88
+ sessionId: string
89
+ mode: PermissionMode
64
90
  }
65
91
 
66
92
  export type ServerMessage =
@@ -72,3 +98,4 @@ export type ServerMessage =
72
98
  | ServerDoneMessage
73
99
  | ServerErrorMessage
74
100
  | ServerSessionStateMessage
101
+ | ServerModeMessage