@miphamai/cli 0.85.2 → 0.85.4

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.
@@ -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
@@ -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))