@miphamai/cli 0.85.1 → 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.
Files changed (63) hide show
  1. package/bin/mipham.ts +42 -2
  2. package/package.json +1 -1
  3. package/skills/standard/mipham-code-setup.SKILL.md +9 -2
  4. package/src/agent/agent-experience.ts +3 -2
  5. package/src/agent/background-registry.ts +7 -3
  6. package/src/agent/cross-session/discovery.ts +5 -12
  7. package/src/agent/cross-session/file-inbox.ts +2 -2
  8. package/src/agent/effectiveness-tracker.ts +3 -2
  9. package/src/agent/sub-agent.ts +67 -6
  10. package/src/agent/types.ts +10 -0
  11. package/src/agent-view/agent-view-manager.ts +46 -0
  12. package/src/agent-view/dashboard.tsx +106 -16
  13. package/src/agent-view/session-view.tsx +128 -0
  14. package/src/commands/autoloop-journal.ts +6 -5
  15. package/src/commands/environment.ts +11 -8
  16. package/src/commands/project.ts +5 -3
  17. package/src/config/credential-crypto.ts +13 -1
  18. package/src/config/keys-manager.ts +7 -4
  19. package/src/config/loader.ts +144 -41
  20. package/src/config/preferences.ts +6 -3
  21. package/src/core/constitution-loader.ts +3 -2
  22. package/src/core/context.ts +35 -6
  23. package/src/core/crsi-producer.ts +4 -2
  24. package/src/core/crsi-sandbox.ts +2 -1
  25. package/src/core/dream-engine.ts +5 -11
  26. package/src/core/engine.ts +9 -4
  27. package/src/core/error-signature-db.ts +3 -2
  28. package/src/core/eval-harness.ts +3 -3
  29. package/src/core/hooks-executor.ts +60 -2
  30. package/src/core/instructions.ts +69 -48
  31. package/src/core/memory/memory-manager.ts +10 -6
  32. package/src/core/permission-audit.ts +13 -6
  33. package/src/core/permission-config.ts +28 -7
  34. package/src/core/permission-rules.ts +99 -5
  35. package/src/core/permission.ts +6 -1
  36. package/src/core/rule-engine.ts +3 -2
  37. package/src/core/session-log.ts +48 -11
  38. package/src/core/session-store.ts +64 -44
  39. package/src/daemon/attach-protocol.ts +30 -3
  40. package/src/daemon/auth.ts +4 -3
  41. package/src/daemon/index.ts +4 -3
  42. package/src/daemon/remote-engine.ts +173 -24
  43. package/src/daemon/server.ts +51 -1
  44. package/src/daemon/session-worker.ts +34 -1
  45. package/src/i18n-core/locales/en-US.json +1 -0
  46. package/src/i18n-core/locales/zh-CN.json +1 -0
  47. package/src/index.tsx +75 -17
  48. package/src/mcp/oauth.ts +47 -5
  49. package/src/mcp/token-store.ts +10 -11
  50. package/src/providers/openai-compat.ts +11 -0
  51. package/src/shared/arg-validation.ts +74 -2
  52. package/src/shared/package-info.ts +1 -1
  53. package/src/shared/regular-file.ts +63 -0
  54. package/src/shared/sanitize.ts +14 -2
  55. package/src/skills/bundled-skills.ts +1 -1
  56. package/src/tools/agent/memory.ts +4 -2
  57. package/src/tools/agent/workflow.ts +6 -3
  58. package/src/tools/exec/task.ts +82 -30
  59. package/src/tools/scheduling/cron.ts +3 -9
  60. package/src/ui/app.tsx +78 -14
  61. package/src/ui/commands.ts +73 -31
  62. package/src/ui/ctrl-c-confirm.ts +63 -0
  63. package/src/workflow/journal.ts +80 -26
@@ -17,7 +17,13 @@
17
17
  // }
18
18
  // engine.close()
19
19
 
20
- import type { ClientPromptMessage, ClientInterruptMessage, ServerMessage } from './attach-protocol'
20
+ import type {
21
+ ClientPromptMessage,
22
+ ClientInterruptMessage,
23
+ ClientSetModeMessage,
24
+ ServerMessage,
25
+ } from './attach-protocol'
26
+ import { ALL_MODES } from '../core/permission-config'
21
27
  import type { PermissionMode, StreamChunk } from '../shared/types'
22
28
 
23
29
  // ── Public API ───────────────────────────────────────────────────────────────
@@ -54,6 +60,46 @@ export class RemoteEngine {
54
60
  /** Whether this engine has been explicitly closed. */
55
61
  private closed = false
56
62
 
63
+ /** In-flight connection attempt. See `ensureConnected()`. */
64
+ private connecting: Promise<void> | null = null
65
+
66
+ // ── Permission mode (the gate lives on the daemon) ───────────────────────
67
+ //
68
+ // Two values, and the difference between them is the whole contract:
69
+ //
70
+ // - `modeChosen` — the mode **this client's user** picked on this attach. Set by a
71
+ // keypress (so the footer advances immediately — `cyclePermissionMode` computes the
72
+ // next slot from `getMode()`'s read-back, and a `getMode` that only reported confirmed
73
+ // values would freeze the wheel), then overwritten by the daemon's **answer**, because
74
+ // org level `permissionRestrictions` silently rewrite a request and the daemon is the
75
+ // only side that can see the clamp. A non-null value is a standing instruction and gets
76
+ // re-asserted before every prompt. A connect snapshot is not an answer (see
77
+ // `absorbMode`): it predates anything this client sent and must not retract it.
78
+ // - `modeConfirmed` — what the daemon said is in effect, from a `mode` frame or an
79
+ // attach snapshot. **Display only, never asserted**: it is how a client that has not
80
+ // picked anything learns the session's mode, and re-sending it would let a bystander
81
+ // push its own guess over an operator's `MIPHAM_DAEMON_PERMISSION` — a silent override
82
+ // by a client that never expressed an intent.
83
+ private modeChosen: PermissionMode | null = null
84
+ private modeConfirmed: PermissionMode | null = null
85
+
86
+ /** Viewers of the confirmed mode (the TUI footer). See `onPermissionModeChange`. */
87
+ private readonly modeListeners = new Set<(mode: PermissionMode) => void>()
88
+
89
+ /**
90
+ * One stable object — **not** a fresh closure per `getPermission()` call.
91
+ *
92
+ * `app.tsx` calls `getPermission()` per keypress (`cyclePermissionMode(engine.getPermission(), …)`
93
+ * and the initial `useState`), so a per-call object-throws away everything it was told:
94
+ * the mode read back is a brand-new `'default'` and nothing is ever sent to the daemon.
95
+ */
96
+ private readonly permissionFacade = {
97
+ setMode: (mode: PermissionMode): void => {
98
+ this.requestMode(mode)
99
+ },
100
+ getMode: (): PermissionMode => this.modeChosen ?? this.modeConfirmed ?? 'default',
101
+ }
102
+
57
103
  constructor(options: RemoteEngineOptions) {
58
104
  this.sessionId = options.sessionId
59
105
  this.port = options.port
@@ -64,12 +110,20 @@ export class RemoteEngine {
64
110
 
65
111
  /**
66
112
  * Ensure a WebSocket connection to the daemon exists.
67
- * Creates one lazily on the first process() call.
113
+ * Creates one lazily on the first process() or setMode() call.
114
+ *
115
+ * Concurrent callers **share** the in-flight attempt. Without that, the second caller
116
+ * sees `this.ws` set but not yet `OPEN`, treats it as a stale socket, nulls its handlers
117
+ * and closes it — so the first caller's promise can never settle (its `onopen` was
118
+ * detached) and the message it was about to send goes to a socket nobody is listening on.
68
119
  */
69
120
  private ensureConnected(): Promise<void> {
70
121
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
71
122
  return Promise.resolve()
72
123
  }
124
+ if (this.connecting) {
125
+ return this.connecting
126
+ }
73
127
 
74
128
  if (this.ws) {
75
129
  // Stale socket — clean up before reconnecting
@@ -87,7 +141,7 @@ export class RemoteEngine {
87
141
 
88
142
  const url = `ws://127.0.0.1:${this.port}/api/v1/sessions/${this.sessionId}/stream`
89
143
 
90
- return new Promise<void>((resolve, reject) => {
144
+ const attempt = new Promise<void>((resolve, reject) => {
91
145
  const ws = new WebSocket(url)
92
146
  this.ws = ws
93
147
 
@@ -112,6 +166,15 @@ export class RemoteEngine {
112
166
  reject(new Error(`Failed to connect to daemon at 127.0.0.1:${this.port}`))
113
167
  }
114
168
  })
169
+
170
+ this.connecting = attempt
171
+ const clear = () => {
172
+ if (this.connecting === attempt) this.connecting = null
173
+ }
174
+ // Both slots handled on purpose: this promise is only ever awaited by callers that
175
+ // catch, but a rejection here would otherwise surface as unhandled.
176
+ attempt.then(clear, clear)
177
+ return attempt
115
178
  }
116
179
 
117
180
  // ── Prompt Processing (async generator) ──────────────────────────────────
@@ -145,6 +208,17 @@ export class RemoteEngine {
145
208
  this.resolveNext = null
146
209
  this.rejectNext = null
147
210
 
211
+ // Re-assert the mode this client's user picked, **before** the prompt — frames on one
212
+ // socket arrive in the order they were sent, so the daemon applies the gate first and
213
+ // this turn runs under the mode the user is looking at. Idempotent by design, and it
214
+ // heals the two ways the gate can drift behind the client's back (a daemon restart, or
215
+ // a worker evicted while idle and rebuilt from env).
216
+ //
217
+ // Skipped when nothing was ever picked here: the daemon owns the default (env config,
218
+ // an earlier client, an operator), and a bystander must not overwrite it by asserting
219
+ // the value it merely *displays*.
220
+ if (this.modeChosen) await this.sendMode(this.modeChosen)
221
+
148
222
  // Send the prompt
149
223
  const promptMsg: ClientPromptMessage = {
150
224
  type: 'prompt',
@@ -215,9 +289,10 @@ export class RemoteEngine {
215
289
 
216
290
  // ── Stub methods for TUI compatibility ───────────────────────────────────
217
291
  //
218
- // In remote attach mode, the daemon manages providers, agents, permissions,
219
- // and context. These stubs satisfy the TUI's engine interface without
220
- // introducing daemon dependencies into the UI layer.
292
+ // In remote attach mode, the daemon manages providers, agents and context. These stubs
293
+ // satisfy the TUI's engine interface without introducing daemon dependencies into the
294
+ // UI layer. (Permissions are **not** in this list: they are forwarded over the attach
295
+ // protocol — see `getPermission` — because the footer is a display of the daemon's gate.)
221
296
  //
222
297
  // Slash commands may call any of these; we return safe defaults.
223
298
 
@@ -257,17 +332,31 @@ export class RemoteEngine {
257
332
  }
258
333
  }
259
334
 
260
- /** Returns a stub permission object so slash commands don't crash. */
261
- getPermission(): { setMode(_mode: PermissionMode): void; getMode(): PermissionMode } {
262
- // Remote mode: permissions are managed by the daemon, and the attach protocol has
263
- // no read-back — so `getMode` reports the last mode the user asked for (which is
264
- // what the footer has always shown here), not a value the daemon never confirmed.
265
- let requested: PermissionMode = 'default'
266
- return {
267
- setMode: (mode: PermissionMode) => {
268
- requested = mode
269
- },
270
- getMode: () => requested,
335
+ /**
336
+ * The permission surface the footer reads and writes — wired to the daemon's gate,
337
+ * not a local mirror of it (`app.tsx` treats this line as **the mirror of execution**).
338
+ *
339
+ * Writing (`setMode`) sends `set_mode`; the daemon applies it to the session's live
340
+ * `PermissionSystem` and answers with the mode that actually took effect. Reading
341
+ * (`getMode`) answers optimistically until that answer arrives — see the field comments.
342
+ */
343
+ getPermission(): { setMode(mode: PermissionMode): void; getMode(): PermissionMode } {
344
+ return this.permissionFacade
345
+ }
346
+
347
+ /**
348
+ * Subscribe to mode corrections from the daemon (`mode` frames, and the `mode` field of
349
+ * a `session_state` snapshot). Returns an unsubscribe function.
350
+ *
351
+ * Needed because the confirmed value can arrive **asynchronously** — a keypress is not a
352
+ * render, so a footer that only ever read `getMode()` on keypress would keep displaying
353
+ * the pre-clamp value forever. Nothing local has this problem: a local engine's
354
+ * `getMode()` already reflects the clamp the moment `setMode` returns.
355
+ */
356
+ onPermissionModeChange(listener: (mode: PermissionMode) => void): () => void {
357
+ this.modeListeners.add(listener)
358
+ return () => {
359
+ this.modeListeners.delete(listener)
271
360
  }
272
361
  }
273
362
 
@@ -353,6 +442,14 @@ export class RemoteEngine {
353
442
  return
354
443
  }
355
444
 
445
+ // Session facts, not prompt stream: they carry the gate's current mode and must be
446
+ // absorbed even when nothing is consuming chunks (a keypress, not a turn).
447
+ if (msg.type === 'mode' || msg.type === 'session_state') {
448
+ // A snapshot describes the gate as of connect — see `absorbMode`.
449
+ this.absorbMode(msg.mode, msg.type === 'session_state')
450
+ return
451
+ }
452
+
356
453
  const chunk = this.mapMessageToChunk(msg)
357
454
  if (!chunk) return
358
455
 
@@ -364,10 +461,67 @@ export class RemoteEngine {
364
461
  }
365
462
  }
366
463
 
464
+ // ── Permission plumbing ─────────────────────────────────────────────────
465
+
466
+ /** Optimistically take the mode, then tell the daemon (best effort). */
467
+ private requestMode(mode: PermissionMode): void {
468
+ this.modeChosen = mode
469
+ // Fire and forget: the correctness-critical send is the one `process()` makes before
470
+ // every prompt. This one only shortens how long a footer can sit on a clamped value
471
+ // (the daemon answers with `mode`, which usually lands before the next render).
472
+ void this.sendMode(mode)
473
+ }
474
+
475
+ /** Send `set_mode`. Silent when the daemon is unreachable or the socket is closing. */
476
+ private async sendMode(mode: PermissionMode): Promise<void> {
477
+ try {
478
+ await this.ensureConnected()
479
+ const msg: ClientSetModeMessage = { type: 'set_mode', sessionId: this.sessionId, mode }
480
+ this.ws?.send(JSON.stringify(msg))
481
+ } catch {
482
+ // No daemon: the mode stays local, exactly as it did before this existed. The next
483
+ // `process()` re-asserts it, so nothing is lost if the connect was merely slow.
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Take the daemon's word for the effective mode and hand it to the footer.
489
+ *
490
+ * Validated against `ALL_MODES` rather than trusted: this frame crosses a process
491
+ * boundary and the two sides version independently (an older daemon need not send the
492
+ * field at all), and a bad value would otherwise sit in the footer as a mode no wheel
493
+ * slot can step away from.
494
+ *
495
+ * `fromSnapshot` separates the two kinds of frame, and they are **not** interchangeable:
496
+ *
497
+ * - a `mode` frame is the daemon **answering** — either a `set_mode` this client (or
498
+ * another) sent, or a live change. It is newer than anything we sent, so it replaces the
499
+ * request; that is the channel a clamp travels back on;
500
+ * - `session_state` is sent the moment the socket attaches (`addClient` → `sendState`),
501
+ * so it describes the gate as of connect — i.e. **before** a `set_mode` this client had
502
+ * already sent could have been handled. Letting it retract the request would drop the
503
+ * standing instruction, and the very next prompt is what would do the dropping: the
504
+ * re-assert reads `modeChosen`, now holding the pre-request value, and helpfully pushes
505
+ * it — so `--permission plan` (or a `Shift+Tab` made just before a reconnect) would be
506
+ * silently **cancelled** rather than narrowed. It is not worth displaying either, since
507
+ * it is already superseded by a request that is in flight; the answer is what the footer
508
+ * needs, and the daemon always sends one for a `set_mode` it understood.
509
+ */
510
+ private absorbMode(mode: unknown, fromSnapshot = false): void {
511
+ if (typeof mode !== 'string' || !ALL_MODES.includes(mode as PermissionMode)) return
512
+ const effective = mode as PermissionMode
513
+ this.modeConfirmed = effective
514
+ if (this.modeChosen) {
515
+ if (fromSnapshot) return
516
+ this.modeChosen = effective
517
+ }
518
+ for (const listener of this.modeListeners) listener(effective)
519
+ }
520
+
367
521
  /**
368
522
  * Map a ServerMessage from the daemon to a StreamChunk.
369
- * Returns null for message types that should be silently consumed
370
- * (e.g., session_state which is only informative for attach).
523
+ * Returns null for message types that carry no prompt output. (`session_state` and
524
+ * `mode` never reach here — `onMessage` absorbs them as session facts.)
371
525
  */
372
526
  private mapMessageToChunk(msg: ServerMessage): StreamChunk | null {
373
527
  switch (msg.type) {
@@ -424,11 +578,6 @@ export class RemoteEngine {
424
578
  return { type: 'error', error: msg.message }
425
579
  }
426
580
 
427
- // session_state is an informational message sent when a client
428
- // first attaches. It is not part of the prompt stream.
429
- case 'session_state':
430
- return null
431
-
432
581
  default:
433
582
  return null
434
583
  }
@@ -67,6 +67,13 @@ interface WsData {
67
67
  * Values `MIPHAM_DAEMON_PERMISSION` accepts. The **default stays `'default'`**;
68
68
  * every widening is an explicit operator choice.
69
69
  *
70
+ * Also the whitelist an attached client's `set_mode` is measured against: a mode the
71
+ * daemon does not understand must not be applied (fail-closed) — the client's footer
72
+ * is a *display* of this gate, never its input. Keeping both uses on one set means a
73
+ * mode the daemon declines via env is declined via `set_mode` too; a second hand-kept
74
+ * list would let the two drift (`test/integrity/permission-status-parity.test.ts` P7
75
+ * pins this set against `ALL_MODES`).
76
+ *
70
77
  * `'auto'` is accepted, and today it means **every tool call is refused**: the
71
78
  * mode's static baseline answers `'ask'` for all of them, and the daemon has no
72
79
  * classifier to rule on the `'ask'` (the classifier is wired in the CLI's
@@ -77,7 +84,7 @@ interface WsData {
77
84
  * than what was asked for; a silent widening is the worse of the two ways to be
78
85
  * wrong. This entry becomes useful the day the daemon builds a classifier.
79
86
  */
80
- const DAEMON_PERMISSION_MODES: ReadonlySet<PermissionMode> = new Set<PermissionMode>([
87
+ export const DAEMON_PERMISSION_MODES: ReadonlySet<PermissionMode> = new Set<PermissionMode>([
81
88
  'default',
82
89
  'acceptEdits',
83
90
  'plan',
@@ -207,6 +214,19 @@ export function createServer(config: ServerConfig): Server<WsData> {
207
214
  let sharedRegistry: ProviderRegistry | null = null
208
215
  let sharedTools: Map<string, ToolDefinition> | null = null
209
216
 
217
+ /**
218
+ * The mode each session's client last asked for (`set_mode`), so a **rebuilt** engine
219
+ * comes back on the user's mode instead of the env default.
220
+ *
221
+ * Why not just read it back off the live engine: engines are not permanent. `WorkerPool`
222
+ * evicts an idle worker, which drops `engineCache`'s entry too, and the next prompt
223
+ * rebuilds from `resolveDaemonPermission()` — the env value. Without this map the gate
224
+ * silently reverts (narrower *or* wider than the footer says) and nothing announces it.
225
+ * Stored as **requested**, not effective: `setMode` re-clamps on the way in, so the
226
+ * rebuilt engine lands on the same value the first one did.
227
+ */
228
+ const sessionModes = new Map<string, PermissionMode>()
229
+
210
230
  function getOrCreateEngine(
211
231
  sessionId: string,
212
232
  cwd: string,
@@ -260,6 +280,10 @@ export function createServer(config: ServerConfig): Server<WsData> {
260
280
  daemonConfig.permissionRestrictions,
261
281
  daemonConfig.permissionRules,
262
282
  )
283
+ // After `setRestrictions` (inside the builder) so this goes through the same clamp the
284
+ // first engine did — see `sessionModes`. Absent entry ⇒ env mode, unchanged.
285
+ const requestedMode = sessionModes.get(sessionId)
286
+ if (requestedMode) permission.setMode(requestedMode)
263
287
  const engine = new QueryEngine(sharedRegistry, context, sharedTools, permission)
264
288
  engine.setSessionId(sessionId)
265
289
  // Same engine capabilities as the interactive CLI — see engine-capabilities.ts.
@@ -876,6 +900,32 @@ export function createServer(config: ServerConfig): Server<WsData> {
876
900
  break
877
901
  }
878
902
 
903
+ case 'set_mode': {
904
+ // The client pressed Shift+Tab; the gate moves **here**, not in the client's
905
+ // footer. `sessionId` comes from the socket, never from the payload: the same
906
+ // message must not be able to move another session's gate.
907
+ const requested: unknown = parsed.mode
908
+ const known =
909
+ typeof requested === 'string' &&
910
+ DAEMON_PERMISSION_MODES.has(requested as PermissionMode)
911
+
912
+ // Materialize the worker when needed — a mode with no engine behind it is not a
913
+ // mode, and without this the very first keypress (before any prompt) would fall
914
+ // through and leave the footer's value unconfirmed. Cheap after the first time.
915
+ const worker = getOrCreateWorker(sessionId, ws)
916
+ if (!worker) return
917
+
918
+ if (known) sessionModes.set(sessionId, requested as PermissionMode)
919
+ // An unrecognized value changes nothing (fail-closed) — but the client is still
920
+ // told the **effective** mode, so its footer lands back on a mode this daemon
921
+ // really will grant instead of parking on one it never accepted.
922
+ const effective = known
923
+ ? worker.setPermissionMode(requested as PermissionMode)
924
+ : worker.getPermissionMode()
925
+ broadcast(sessionId, { type: 'mode', sessionId, mode: effective })
926
+ break
927
+ }
928
+
879
929
  default:
880
930
  // Unknown message type — silently ignored
881
931
  break
@@ -22,7 +22,7 @@ import type { ServerWebSocket } from 'bun'
22
22
  import type { QueryEngine } from '../core/engine'
23
23
  import type { DaemonDatabase } from './database'
24
24
  import type { DaemonSession, MessageRecord } from './types'
25
- import type { StreamChunk } from '../shared/types'
25
+ import type { PermissionMode, StreamChunk } from '../shared/types'
26
26
  import type {
27
27
  ClientInterruptMessage,
28
28
  ServerMessage,
@@ -47,6 +47,8 @@ export interface SessionStateSnapshot {
47
47
  status: string
48
48
  /** Last N messages for context restoration on attach. */
49
49
  messages: MessageRecord[]
50
+ /** 本会话闸门当前生效的档位 —— attach 的客户端据此初始化页脚。 */
51
+ permissionMode: PermissionMode
50
52
  }
51
53
 
52
54
  // ── SessionWorker ─────────────────────────────────────────────────────────
@@ -234,6 +236,33 @@ export class SessionWorker {
234
236
  this.interrupt()
235
237
  }
236
238
 
239
+ // ── Permission Mode ────────────────────────────────────────────────────
240
+
241
+ /**
242
+ * Apply a permission mode to this session's live gate and return the mode that
243
+ * actually took effect.
244
+ *
245
+ * Through `engine.getPermission()` — the very object the engine's tool gate reads
246
+ * (`engine.ts` `this.permission.resolveApproval(…)`), so this is not a copy that
247
+ * the gate never sees.
248
+ *
249
+ * **Returns the effective mode, never the requested one.** Org-level
250
+ * `permissionRestrictions` silently rewrite what you ask for inside `setMode`, so
251
+ * echoing the request back would tell the attaching client's footer it has a
252
+ * permission the daemon will not grant — "says allowed, actually asks". The
253
+ * daemon is the only authority on this value; the client displays what it is told.
254
+ */
255
+ setPermissionMode(mode: PermissionMode): PermissionMode {
256
+ const permission = this.engine.getPermission()
257
+ permission.setMode(mode)
258
+ return permission.getMode()
259
+ }
260
+
261
+ /** The mode currently in effect for this session's gate. */
262
+ getPermissionMode(): PermissionMode {
263
+ return this.engine.getPermission().getMode()
264
+ }
265
+
237
266
  // ── Session State ──────────────────────────────────────────────────────
238
267
 
239
268
  /**
@@ -250,6 +279,7 @@ export class SessionWorker {
250
279
  turnCount: this.session.turnCount,
251
280
  status: this.session.status,
252
281
  messages,
282
+ permissionMode: this.getPermissionMode(),
253
283
  }
254
284
  }
255
285
 
@@ -413,6 +443,9 @@ export class SessionWorker {
413
443
  provider: state.provider,
414
444
  model: state.model,
415
445
  turnCount: state.turnCount,
446
+ // 带上生效档,否则新 attach 的客户端只能猜 `default` —— 在 `MIPHAM_DAEMON_PERMISSION`
447
+ // 或上一次 `set_mode` 把 daemon 定在别处时,页脚从第一帧起就与闸门不符。
448
+ mode: state.permissionMode,
416
449
  }
417
450
  try {
418
451
  ws.send(JSON.stringify(msg))
@@ -893,6 +893,7 @@
893
893
  },
894
894
  "status": {
895
895
  "esc_to_interrupt": "esc to interrupt",
896
+ "ctrl_c_again": "ctrl+c again to exit",
896
897
  "shift_tab_cycle": "shift+tab",
897
898
  "left_for_agents": "ctrl+g agents to manage",
898
899
  "mipham_modes": "Mipham",
@@ -893,6 +893,7 @@
893
893
  },
894
894
  "status": {
895
895
  "esc_to_interrupt": "Esc 中断",
896
+ "ctrl_c_again": "再按一次 Ctrl+C 退出",
896
897
  "shift_tab_cycle": "Shift+Tab",
897
898
  "left_for_agents": "ctrl+g 管理代理",
898
899
  "mipham_modes": "Mipham独有",
package/src/index.tsx CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  loadCredentialMaskingConfig,
11
11
  loadCrossSessionConfig,
12
12
  loadSettingsJson,
13
+ settingsPathFor,
13
14
  } from './config/loader'
14
15
  import {
15
16
  registerActiveSession,
@@ -30,7 +31,7 @@ import { generateSessionName } from './core/session-name'
30
31
  import { ExperienceRuleEngine } from './core/rule-engine.js'
31
32
  import { SessionLog } from './core/session-log'
32
33
  import { SessionStore } from './core/session-store'
33
- import type { PermissionLevel, MiphamConfig, McpServerConfig } from './shared/types'
34
+ import type { PermissionLevel, PermissionMode, MiphamConfig, McpServerConfig } from './shared/types'
34
35
  import { PermissionSystem } from './core/permission'
35
36
  import { LlmPermissionClassifier } from './core/permission-classifier'
36
37
  import { SkillsLoader } from './skills/loader'
@@ -78,7 +79,16 @@ interface RunOptions {
78
79
  model?: string
79
80
  provider?: string
80
81
  lang?: string
81
- permission?: string
82
+ /**
83
+ * Starting permission mode, from `--permission <mode>`.
84
+ *
85
+ * Typed as a real mode rather than a string on purpose: the flag is validated against
86
+ * `ALL_MODES` before it gets here (`shared/arg-validation.ts`), and this field used to
87
+ * be a `string` no caller ever filled in — the "written but unreachable" shape
88
+ * `--resume` had. Under `mipham attach` the daemon is still the authority: this becomes
89
+ * a `set_mode` request and its answer is what the footer shows.
90
+ */
91
+ permission?: PermissionMode
82
92
  resume?: string
83
93
  version?: string
84
94
  /** When set, launch TUI in remote mode connected to a daemon session. */
@@ -326,6 +336,13 @@ export async function runApp(options: RunOptions): Promise<void> {
326
336
  const { RemoteEngine } = await import('./daemon/remote-engine')
327
337
  const engine = new RemoteEngine(options.remoteSession)
328
338
 
339
+ // `--permission <mode>` under `attach`: this is a request, not an assignment — the
340
+ // gate is the daemon's, and `setMode` sends `set_mode` and takes the daemon's answer
341
+ // back (it clamps, and the answer is what the footer shows). Set before the TUI
342
+ // renders so the first render already reflects the user's intent rather than
343
+ // `default`; the frame itself is re-asserted ahead of every prompt.
344
+ if (options.permission) engine.getPermission().setMode(options.permission)
345
+
329
346
  // Detect locale and create translation function (same as local path)
330
347
  const locale = detectLocale({ lang: options.lang })
331
348
  const t = createT(localeBundles[locale] || enUS, enUS)
@@ -346,6 +363,10 @@ export async function runApp(options: RunOptions): Promise<void> {
346
363
  sessionId: options.remoteSession.sessionId,
347
364
  }),
348
365
  }),
366
+ // Ink 默认 exitOnCtrlC: true,它在**任何 handler 拿到按键之前**就退进程
367
+ // (App.js `input === '\x03'`,且 useInput 那条路还会跳过全部监听器)。
368
+ // 于是 Ctrl+C 的语义只能由我们自己定 —— 见 App 里的「再按一次才退」。
369
+ { exitOnCtrlC: false },
349
370
  )
350
371
  await waitUntilExit()
351
372
 
@@ -364,6 +385,7 @@ export async function runApp(options: RunOptions): Promise<void> {
364
385
  onAttach={() => {}}
365
386
  onExit={() => process.exit(0)}
366
387
  />,
388
+ { exitOnCtrlC: false },
367
389
  )
368
390
  await waitUntilExit()
369
391
  process.exit(0)
@@ -377,16 +399,49 @@ export async function runApp(options: RunOptions): Promise<void> {
377
399
  // Load configuration
378
400
  const config = loadConfig()
379
401
 
380
- // Permission policy is built **here**, before the system prompt exists, so the prompt
381
- // can describe the mode the engine will actually run in. Building the prompt from
382
- // `config.permission` handed the model a value that org restrictions can silently
383
- // rewrite (and that may not even be a mode name — `bypass`/`auto`/`ask` inject nothing
384
- // at all). The engine is created much later, after tools/hooks/vajra are mounted, so
385
- // this instance is what gets handed to it rather than a second one built inside.
402
+ // Permission policy is built **here**, before anything can describe it. Neither the
403
+ // prompt nor the engine may read `config.permission`: org restrictions silently
404
+ // rewrite it, and it may not even be a mode name — `bypass`/`ask`/a typo inject
405
+ // nothing at all. The engine is created much later, after tools/hooks/vajra are
406
+ // mounted, so this instance is what gets handed to it rather than a second one built
407
+ // inside; the system prompt's permission section reads its `getMode()` per request
408
+ // (wired at `setPermissionContextSource` below), so it cannot drift from execution.
386
409
  const permission = new PermissionSystem('default')
387
- // Sync with config (fix: UI shows "auto" but engine defaulted to bypass-legacy)
388
- if (config.permission) {
389
- permission.setDefaultLevel(config.permission as PermissionLevel)
410
+ // Read settings.json **here**, ahead of the mode, because it is one of the mode's
411
+ // sources (the hook registration further down consumes the same object — one read,
412
+ // two readers, so the two can never disagree about which file said what).
413
+ const projectHooksTrusted = getWorkspaceTrust().isTrusted(process.cwd())
414
+ const settingsJson = loadSettingsJson(process.cwd(), {
415
+ includeProjectHooks: projectHooksTrusted,
416
+ })
417
+ // Origin order, most specific first:
418
+ // 1. `--permission <mode>` — the operator's word **for this invocation**, typed
419
+ // seconds ago. Nothing beats that.
420
+ // 2. user-level `settings.json` `permissions.defaultMode` — the adopted upstream
421
+ // key. It outranks `config.yml` because a `config.yml` value **cannot be told
422
+ // apart from the first-run wizard's**: the wizard writes `permission: default`
423
+ // into that file, so ranking config first would let a line nobody chose
424
+ // permanently shadow the key the user did write. (Both are the user's own
425
+ // file; a project-level one never gets this far — see `loadSettingsJson`.)
426
+ // 3. `config.yml permission` — the native key, and the older of the two.
427
+ // 4. the built-in `default`.
428
+ // Every source lands on the **same** applier (`setDefaultLevel`), so there is one
429
+ // accepted value domain and one warning channel, whichever door the value came
430
+ // through. Org restrictions are applied just below, after all of them.
431
+ const configuredMode =
432
+ options.permission ?? settingsJson.permissions.defaultMode ?? config.permission
433
+ if (configuredMode) {
434
+ permission.setDefaultLevel(configuredMode as PermissionLevel)
435
+ }
436
+ // A withheld project-level mode is announced, for the same reason a withheld
437
+ // project-level hook is: from the outside, "ignored on purpose" and "never read
438
+ // your file" are the same silence — and this one is the user's own editing,
439
+ // made in a file they can still see the key in.
440
+ if (settingsJson.projectModeSkipped) {
441
+ process.stderr.write(
442
+ `⚠ Mipham Code: ignored permissions.defaultMode from ${settingsPathFor('project', process.cwd())}\n` +
443
+ ` (a repository must not choose the approval gate — set it in ${settingsPathFor('user', process.cwd())})\n`,
444
+ )
390
445
  }
391
446
  // Apply org-level permission restrictions (P0: bypassPermissions policy gap)
392
447
  if (config.permissionRestrictions) {
@@ -495,6 +550,11 @@ export async function runApp(options: RunOptions): Promise<void> {
495
550
  // Cache-aware microcompaction: track the provider's prompt-cache prefix.
496
551
  context.setCacheTracker(new PrefixCacheTracker())
497
552
 
553
+ // 权限段**读时派生**,不在组装时烘进提示。烘进去的是模式的一份拷贝,而模式会在会话中途
554
+ // 变(Shift+Tab):往窄切是自纠正的,**往宽切**则让模型拿着旧指令拒绝做它已被允许做的事。
555
+ // 这里接的是 live `permission` —— 同一实例也交给引擎去执行,故下一次请求就与新档一致。
556
+ context.setPermissionContextSource(() => instructions.buildPermissionBlock(permission.getMode()))
557
+
498
558
  // Adaptive memory budget: scale with model's context window
499
559
  getMemoryManager().setContextWindow(modelContextWindow)
500
560
 
@@ -512,12 +572,12 @@ export async function runApp(options: RunOptions): Promise<void> {
512
572
  }
513
573
  }
514
574
  context.restoreLog(log)
515
- context.setSystemPrompt(instructions.buildSystemPrompt(permission.getMode()))
575
+ context.setSystemPrompt(instructions.buildSystemPrompt())
516
576
  }
517
577
  }
518
578
 
519
579
  if (context.getMessageCount() === 0) {
520
- const basePrompt = instructions.buildSystemPrompt(permission.getMode())
580
+ const basePrompt = instructions.buildSystemPrompt()
521
581
  const memoryReminder = loadSessionMemories(basePrompt)
522
582
  const skillsReminder = skillsLoader.buildSystemReminder(5000, config.skills?.reminder ?? 'full')
523
583
 
@@ -633,10 +693,7 @@ export async function runApp(options: RunOptions): Promise<void> {
633
693
  // already asked — and exited the process on "no". With no TTY it *cannot* ask, so
634
694
  // the answer is "no" and the hooks stay out rather than running unasked; that
635
695
  // skip is announced, since silence would look the same as having passed.
636
- const projectHooksTrusted = getWorkspaceTrust().isTrusted(process.cwd())
637
- const settingsJson = loadSettingsJson(process.cwd(), {
638
- includeProjectHooks: projectHooksTrusted,
639
- })
696
+ // `settingsJson` itself was read up top, next to the permission it also feeds.
640
697
  if (settingsJson.projectHooksSkipped) warnProjectHooksSkipped(process.cwd())
641
698
  for (const def of loadHookConfigs(settingsJson.hooks)) {
642
699
  hookEngine.register(def)
@@ -868,6 +925,7 @@ export async function runApp(options: RunOptions): Promise<void> {
868
925
  t,
869
926
  }),
870
927
  }),
928
+ { exitOnCtrlC: false },
871
929
  )
872
930
  await waitUntilExit()
873
931
  saveAndExit()