@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.
- package/bin/mipham.ts +42 -2
- package/package.json +1 -1
- package/skills/standard/mipham-code-setup.SKILL.md +9 -2
- package/src/agent/agent-experience.ts +3 -2
- package/src/agent/background-registry.ts +7 -3
- package/src/agent/cross-session/discovery.ts +5 -12
- package/src/agent/cross-session/file-inbox.ts +2 -2
- package/src/agent/effectiveness-tracker.ts +3 -2
- package/src/agent/sub-agent.ts +67 -6
- package/src/agent/types.ts +10 -0
- package/src/agent-view/agent-view-manager.ts +46 -0
- package/src/agent-view/dashboard.tsx +106 -16
- package/src/agent-view/session-view.tsx +128 -0
- package/src/commands/autoloop-journal.ts +6 -5
- package/src/commands/environment.ts +11 -8
- package/src/commands/project.ts +5 -3
- package/src/config/credential-crypto.ts +13 -1
- package/src/config/keys-manager.ts +7 -4
- package/src/config/loader.ts +144 -41
- package/src/config/preferences.ts +6 -3
- package/src/core/constitution-loader.ts +3 -2
- package/src/core/context.ts +35 -6
- package/src/core/crsi-producer.ts +4 -2
- package/src/core/crsi-sandbox.ts +2 -1
- package/src/core/dream-engine.ts +5 -11
- package/src/core/engine.ts +9 -4
- package/src/core/error-signature-db.ts +3 -2
- package/src/core/eval-harness.ts +3 -3
- package/src/core/hooks-executor.ts +60 -2
- package/src/core/instructions.ts +69 -48
- package/src/core/memory/memory-manager.ts +10 -6
- package/src/core/permission-audit.ts +13 -6
- package/src/core/permission-config.ts +28 -7
- package/src/core/permission-rules.ts +99 -5
- package/src/core/permission.ts +6 -1
- package/src/core/rule-engine.ts +3 -2
- package/src/core/session-log.ts +48 -11
- package/src/core/session-store.ts +64 -44
- package/src/daemon/attach-protocol.ts +30 -3
- package/src/daemon/auth.ts +4 -3
- package/src/daemon/index.ts +4 -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/i18n-core/locales/en-US.json +1 -0
- package/src/i18n-core/locales/zh-CN.json +1 -0
- package/src/index.tsx +75 -17
- package/src/mcp/oauth.ts +47 -5
- package/src/mcp/token-store.ts +10 -11
- package/src/providers/openai-compat.ts +11 -0
- package/src/shared/arg-validation.ts +74 -2
- package/src/shared/package-info.ts +1 -1
- package/src/shared/regular-file.ts +63 -0
- package/src/shared/sanitize.ts +14 -2
- package/src/skills/bundled-skills.ts +1 -1
- package/src/tools/agent/memory.ts +4 -2
- package/src/tools/agent/workflow.ts +6 -3
- package/src/tools/exec/task.ts +82 -30
- package/src/tools/scheduling/cron.ts +3 -9
- package/src/ui/app.tsx +78 -14
- package/src/ui/commands.ts +73 -31
- package/src/ui/ctrl-c-confirm.ts +63 -0
- package/src/workflow/journal.ts +80 -26
|
@@ -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))
|
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
|
-
|
|
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
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
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
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
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(
|
|
575
|
+
context.setSystemPrompt(instructions.buildSystemPrompt())
|
|
516
576
|
}
|
|
517
577
|
}
|
|
518
578
|
|
|
519
579
|
if (context.getMessageCount() === 0) {
|
|
520
|
-
const basePrompt = instructions.buildSystemPrompt(
|
|
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
|
-
|
|
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()
|