@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.
- package/bin/mipham.ts +42 -2
- package/package.json +1 -1
- package/skills/standard/mipham-code-setup.SKILL.md +9 -2
- package/src/agent/sub-agent.ts +23 -2
- package/src/agent/types.ts +10 -0
- package/src/agent-view/agent-view-manager.ts +46 -0
- package/src/agent-view/dashboard.tsx +84 -16
- package/src/agent-view/session-view.tsx +128 -0
- package/src/commands/project.ts +80 -27
- package/src/config/loader.ts +98 -6
- package/src/core/context.ts +35 -6
- package/src/core/instructions.ts +69 -48
- package/src/core/permission-classifier.ts +21 -3
- package/src/core/permission-config.ts +28 -7
- package/src/daemon/attach-protocol.ts +30 -3
- package/src/daemon/remote-engine.ts +173 -24
- package/src/daemon/server.ts +51 -1
- package/src/daemon/session-worker.ts +34 -1
- package/src/index.tsx +69 -17
- package/src/shared/arg-validation.ts +74 -2
- package/src/shared/package-info.ts +1 -1
- package/src/skills/bundled-skills.ts +1 -1
- package/src/ui/app.tsx +30 -1
- package/src/ui/commands.ts +68 -29
|
@@ -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
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
-
*
|
|
120
|
-
*
|
|
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,
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
219
|
-
//
|
|
220
|
-
//
|
|
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
|
-
/**
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
370
|
-
*
|
|
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
|
}
|
package/src/daemon/server.ts
CHANGED
|
@@ -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))
|