@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
package/src/commands/project.ts
CHANGED
|
@@ -174,57 +174,108 @@ Run /setup for the full wizard, or /config to view current settings.`,
|
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Split the rule argument(s) out of the whitespace-split slash-command args.
|
|
179
|
+
*
|
|
180
|
+
* Every surface that advertises this command prints the rule **quoted** — the denial
|
|
181
|
+
* messages (`i18n-core/locales/{en-US,zh-CN}.json`), this command's usage line and its
|
|
182
|
+
* examples, and the `config.yml` sample — and the narrower form the message recommends
|
|
183
|
+
* (`Bash(npm test)`) carries a space. Args arrive split on whitespace with the quotes
|
|
184
|
+
* still in them, so the rule is re-joined and split on quotes here instead. Without
|
|
185
|
+
* this the command rejects the exact spelling it tells the user to type:
|
|
186
|
+
* `/permissions allow "Git"` → `Invalid rule ""Git"": not a single tool name.`
|
|
187
|
+
*/
|
|
188
|
+
function parseRuleArgs(args: string[]): { rules: string[]; unbalanced: boolean } {
|
|
189
|
+
const rules: string[] = []
|
|
190
|
+
let current = ''
|
|
191
|
+
let quote: string | null = null
|
|
192
|
+
|
|
193
|
+
for (const ch of args.join(' ')) {
|
|
194
|
+
if (quote) {
|
|
195
|
+
if (ch === quote) quote = null
|
|
196
|
+
else current += ch
|
|
197
|
+
} else if (ch === '"' || ch === "'") {
|
|
198
|
+
quote = ch
|
|
199
|
+
} else if (/\s/.test(ch)) {
|
|
200
|
+
if (current) rules.push(current)
|
|
201
|
+
current = ''
|
|
202
|
+
} else {
|
|
203
|
+
current += ch
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (current) rules.push(current)
|
|
207
|
+
|
|
208
|
+
return { rules, unbalanced: quote !== null }
|
|
209
|
+
}
|
|
210
|
+
|
|
177
211
|
const permissionsCmd: CommandHandler = async (ctx, args) => {
|
|
178
212
|
const c = ctx.engine.getContext()
|
|
179
213
|
const msgs = c.getMessages()
|
|
180
214
|
|
|
181
|
-
// ── Rule persistence: allow/deny/remove <rule
|
|
182
|
-
|
|
215
|
+
// ── Rule persistence: allow/deny/remove <rule>... [--user] ──
|
|
216
|
+
// `--user` is matched exactly: a rule fragment may legitimately begin with `--`
|
|
217
|
+
// (`Bash(--version)`), and dropping it as "a flag" would corrupt the rule.
|
|
218
|
+
const rest = args.filter((a) => a !== '--user')
|
|
183
219
|
const scope: 'project' | 'user' = args.includes('--user') ? 'user' : 'project'
|
|
184
|
-
const verb =
|
|
185
|
-
const rule = positional[1]
|
|
220
|
+
const verb = rest[0]
|
|
186
221
|
|
|
187
222
|
if (verb === 'allow' || verb === 'deny' || verb === 'remove') {
|
|
188
223
|
const { validateRulePattern } = await import('../core/permission-rules')
|
|
189
224
|
const { addSettingsRule, removeSettingsRule, settingsPathFor } =
|
|
190
225
|
await import('../config/loader')
|
|
191
226
|
|
|
192
|
-
|
|
193
|
-
// that isn't there. Validate before writing.
|
|
194
|
-
const invalid = validateRulePattern(rule ?? '')
|
|
227
|
+
const { rules, unbalanced } = parseRuleArgs(rest.slice(1))
|
|
195
228
|
const usage =
|
|
196
|
-
`Usage: /permissions <allow|deny|remove> <rule
|
|
197
|
-
` rule Tool pattern — "Bash" or "Bash(npm test)".\n` +
|
|
229
|
+
`Usage: /permissions <allow|deny|remove> <rule>... [--user]\n\n` +
|
|
230
|
+
` rule Tool pattern — "Bash" or "Bash(npm test)". Quote it if it has spaces.\n` +
|
|
198
231
|
` --user Write to ~/.mipham/settings.json instead of .mipham/settings.json.`
|
|
199
232
|
|
|
200
|
-
if (
|
|
233
|
+
if (unbalanced) {
|
|
234
|
+
return { content: `Unbalanced quote in rule.\n\n${usage}` }
|
|
235
|
+
}
|
|
236
|
+
if (rules.length === 0) {
|
|
201
237
|
return { content: `Missing rule.\n\n${usage}` }
|
|
202
238
|
}
|
|
203
|
-
|
|
204
|
-
|
|
239
|
+
// A rule that can't match is worse than no rule: it reads as protection
|
|
240
|
+
// that isn't there. Validate every rule before writing any of them.
|
|
241
|
+
if (verb !== 'remove') {
|
|
242
|
+
for (const rule of rules) {
|
|
243
|
+
const invalid = validateRulePattern(rule)
|
|
244
|
+
if (invalid) {
|
|
245
|
+
return { content: `Invalid rule "${rule}": ${invalid}.\n\n${usage}` }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
205
248
|
}
|
|
206
249
|
|
|
207
250
|
const perm = ctx.engine.getPermission()
|
|
208
251
|
|
|
209
252
|
if (verb === 'remove') {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
253
|
+
const parts: string[] = []
|
|
254
|
+
for (const rule of rules) {
|
|
255
|
+
const removed = removeSettingsRule(rule, scope)
|
|
256
|
+
if (!removed) {
|
|
257
|
+
parts.push(`No rule "${rule}" in ${settingsPathFor(scope)}.`)
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
perm.removeRule(rule)
|
|
261
|
+
parts.push(`Removed from ${removed.path}\n\npermissions.${removed.key}:\n ${rule}`)
|
|
217
262
|
}
|
|
263
|
+
return { content: parts.join('\n\n') }
|
|
218
264
|
}
|
|
219
265
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
266
|
+
let path = ''
|
|
267
|
+
for (const rule of rules) {
|
|
268
|
+
path = addSettingsRule(verb, rule, scope)
|
|
269
|
+
if (verb === 'allow') perm.allow(rule)
|
|
270
|
+
else perm.deny(rule)
|
|
271
|
+
}
|
|
223
272
|
return {
|
|
224
273
|
content:
|
|
225
274
|
`Added to ${path}\n\n` +
|
|
226
|
-
`permissions.${verb}:\n ${
|
|
227
|
-
|
|
275
|
+
`permissions.${verb}:\n${rules.map((r) => ` ${r}`).join('\n')}\n\n` +
|
|
276
|
+
(rules.length === 1
|
|
277
|
+
? `This rule persists across sessions and applies from now on.`
|
|
278
|
+
: `These rules persist across sessions and apply from now on.`),
|
|
228
279
|
}
|
|
229
280
|
}
|
|
230
281
|
|
|
@@ -240,8 +291,10 @@ Messages: ${msgs.length} in context
|
|
|
240
291
|
Tools: ${ctx.engine.getTools().size} available
|
|
241
292
|
|
|
242
293
|
Shift+Tab cycles: ${renderCycleLine()} (this session only).
|
|
243
|
-
To persist a mode,
|
|
244
|
-
|
|
294
|
+
To persist a mode, name it in ~/.mipham/config.yml (\`permission:\`) or in
|
|
295
|
+
~/.mipham/settings.json (\`permissions.defaultMode\`) — every mode below is accepted
|
|
296
|
+
in either. A **project-level** file cannot set a mode: it arrives with the code, so
|
|
297
|
+
whoever wrote the repository would be choosing the approval gate.
|
|
245
298
|
|
|
246
299
|
Modes (least → most permissive):
|
|
247
300
|
${renderModeTable()}
|
|
@@ -476,7 +529,7 @@ const setupCmd: CommandHandler = async (ctx, args) => {
|
|
|
476
529
|
${skills.length} loaded (${standardSkills} standard + ${miphamSkills} mipham)
|
|
477
530
|
|
|
478
531
|
Permissions
|
|
479
|
-
Mode: ${ctx.
|
|
532
|
+
Mode: ${ctx.engine.getPermission().getMode()} · Tools: ${ctx.engine.getTools().size}
|
|
480
533
|
|
|
481
534
|
|
|
482
535
|
── Setup Steps ──
|
package/src/config/loader.ts
CHANGED
|
@@ -147,6 +147,35 @@ function mergeConfig(
|
|
|
147
147
|
return merged
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Drop `permission` from a **project-level** `config.yml` before it is merged,
|
|
152
|
+
* reporting it instead of applying it.
|
|
153
|
+
*
|
|
154
|
+
* `permission` is a ceiling, not a rule: it decides what the approval gate lets
|
|
155
|
+
* through without asking. Every other project-level key is a preference that a
|
|
156
|
+
* repository may reasonably commit; this one means "and do not ask me about the
|
|
157
|
+
* commands in here" — which is a decision about the operator, made by whoever
|
|
158
|
+
* wrote the repository. The same reasoning admits project-level `permissions.deny`
|
|
159
|
+
* (it narrows) and withholds `permissions.defaultMode` in `settings.json` (it
|
|
160
|
+
* widens) — the two files are the same door, so both are closed here.
|
|
161
|
+
*
|
|
162
|
+
* `mergeConfig` has no per-key allowlist, so this has to happen at the two points
|
|
163
|
+
* the project file enters. Reported rather than dropped silently: a repo whose
|
|
164
|
+
* setting stopped working should say so out loud, and silence is exactly what
|
|
165
|
+
* "your config was ignored" looks like from the outside.
|
|
166
|
+
*/
|
|
167
|
+
function stripProjectPermission(cfg: Partial<MiphamConfig>, path: string): Partial<MiphamConfig> {
|
|
168
|
+
if (cfg.permission === undefined) return cfg
|
|
169
|
+
const { permission, ...rest } = cfg
|
|
170
|
+
const shown = typeof permission === 'string' ? permission : JSON.stringify(permission)
|
|
171
|
+
process.stderr.write(
|
|
172
|
+
`⚠ Mipham Code: ignored permission: ${shown} from project config ${path}\n` +
|
|
173
|
+
` (a repository must not choose the approval gate — set it in ~/.mipham/config.yml,\n` +
|
|
174
|
+
` or pass --permission <mode> for this invocation)\n`,
|
|
175
|
+
)
|
|
176
|
+
return rest as Partial<MiphamConfig>
|
|
177
|
+
}
|
|
178
|
+
|
|
150
179
|
/**
|
|
151
180
|
* Save a timestamped backup of config.yml to ~/.mipham/.
|
|
152
181
|
* Keeps at most 5 backups; older ones are pruned.
|
|
@@ -254,11 +283,21 @@ function loadMcpJson(cwd: string): McpServerConfig[] {
|
|
|
254
283
|
|
|
255
284
|
/**
|
|
256
285
|
* Parsed `settings.json` (Claude Code convention): hooks + permissions.
|
|
257
|
-
* Hooks are additive across levels; permissions allow/deny are deduped unions
|
|
286
|
+
* Hooks are additive across levels; permissions allow/deny are deduped unions —
|
|
287
|
+
* and `defaultMode` is the one member that is **not** merged, because it is a
|
|
288
|
+
* ceiling rather than a rule (see `loadSettingsJson`).
|
|
258
289
|
*/
|
|
259
290
|
export interface SettingsJson {
|
|
260
291
|
hooks: SettingsHooks
|
|
261
|
-
|
|
292
|
+
/**
|
|
293
|
+
* `defaultMode` is present only when the **user-level** file named one, and it
|
|
294
|
+
* is passed through **raw** (not validated here): the accepted spelling is
|
|
295
|
+
* `ALL_MODES`, and the single place that enforces it — plus the warning for a
|
|
296
|
+
* value that is not a mode — is `PermissionSystem.setDefaultLevel`. A second
|
|
297
|
+
* validator here would be a second value domain, and the two would drift the
|
|
298
|
+
* day a mode is added.
|
|
299
|
+
*/
|
|
300
|
+
permissions: { allow: string[]; deny: string[]; defaultMode?: string }
|
|
262
301
|
/**
|
|
263
302
|
* Present (and `true`) only when the project-level file really did declare
|
|
264
303
|
* hooks and they were withheld because the caller did not vouch for the
|
|
@@ -266,6 +305,13 @@ export interface SettingsJson {
|
|
|
266
305
|
* so a caller announcing the skip cannot announce one that never happened.
|
|
267
306
|
*/
|
|
268
307
|
projectHooksSkipped?: true
|
|
308
|
+
/**
|
|
309
|
+
* Same shape, for `permissions.defaultMode`: present only when the
|
|
310
|
+
* project-level file really declared a mode and it was **withheld**. Unlike
|
|
311
|
+
* `projectHooksSkipped` this is not conditioned on trust — the ceiling does not
|
|
312
|
+
* move for a file that arrives with the code, trusted workspace or not.
|
|
313
|
+
*/
|
|
314
|
+
projectModeSkipped?: true
|
|
269
315
|
/**
|
|
270
316
|
* The subset of `hooks` that came from the project-level file — the entries
|
|
271
317
|
* the workspace-trust gate governs. Absent unless the caller vouched for the
|
|
@@ -289,14 +335,35 @@ export interface SettingsJson {
|
|
|
289
335
|
* established trust (or that only *display* the configured list) opt in
|
|
290
336
|
* explicitly. The flag gates hooks only: `permissions` still merge from both
|
|
291
337
|
* levels, since that question is answered by the mode ceiling, not by trust.
|
|
338
|
+
*
|
|
339
|
+
* **That last sentence is the reason `defaultMode` is the exception.** The
|
|
340
|
+
* ceiling only answers the trust question while repository-controlled files
|
|
341
|
+
* cannot move it; a repo that ships `.mipham/settings.json` with
|
|
342
|
+
* `permissions.defaultMode: "auto"` would otherwise hand itself the approval
|
|
343
|
+
* gate. So `defaultMode` is read from the **user-level file only**, and a
|
|
344
|
+
* project-level one is withheld and *reported* (`projectModeSkipped`) rather
|
|
345
|
+
* than dropped in silence — same treatment as a `plan`/`acceptEdits` value that
|
|
346
|
+
* used to be dropped entirely, except that the drop there fell back to the
|
|
347
|
+
* *wider* `default`. The upstream convention says the same thing in its own
|
|
348
|
+
* words: repo-level settings cannot grant `defaultMode`; adopt it in user
|
|
349
|
+
* settings instead.
|
|
350
|
+
*
|
|
351
|
+
* One consequence to keep in mind when reading a merged result: `permissions`
|
|
352
|
+
* is still provenance-free for allow/deny, so a caller cannot tell which of
|
|
353
|
+
* those two rules came from the repository. That is deliberate (above), and it
|
|
354
|
+
* is why the ceiling has to be the thing that repels the repo-controlled half.
|
|
292
355
|
*/
|
|
293
356
|
export function loadSettingsJson(
|
|
294
357
|
cwd: string = process.cwd(),
|
|
295
358
|
options: { includeProjectHooks?: boolean } = {},
|
|
296
359
|
): SettingsJson {
|
|
297
360
|
const hooks: SettingsHooks = {}
|
|
298
|
-
const permissions
|
|
361
|
+
const permissions: { allow: string[]; deny: string[]; defaultMode?: string } = {
|
|
362
|
+
allow: [],
|
|
363
|
+
deny: [],
|
|
364
|
+
}
|
|
299
365
|
let projectHooksSkipped = false
|
|
366
|
+
let projectModeSkipped = false
|
|
300
367
|
// The project file's entries, kept out of the merge so provenance survives it.
|
|
301
368
|
const projectHooks: SettingsHooks = {}
|
|
302
369
|
|
|
@@ -315,7 +382,7 @@ export function loadSettingsJson(
|
|
|
315
382
|
if (raw === null) continue
|
|
316
383
|
const parsed = JSON.parse(raw) as {
|
|
317
384
|
hooks?: Record<string, unknown>
|
|
318
|
-
permissions?: { allow?: unknown; deny?: unknown }
|
|
385
|
+
permissions?: { allow?: unknown; deny?: unknown; defaultMode?: unknown }
|
|
319
386
|
}
|
|
320
387
|
|
|
321
388
|
if (!readHooks) {
|
|
@@ -343,6 +410,20 @@ export function loadSettingsJson(
|
|
|
343
410
|
}
|
|
344
411
|
|
|
345
412
|
if (parsed.permissions) {
|
|
413
|
+
// A mode counts as *declared* only when it is a non-empty string — the
|
|
414
|
+
// same test the hooks branch above uses, so a marker cannot outrun the
|
|
415
|
+
// fact it reports. What the string says is not checked here (see the
|
|
416
|
+
// `permissions` field doc): a typo must reach the applier, which is the
|
|
417
|
+
// one place that knows the accepted spellings and issues the warning.
|
|
418
|
+
const declaredMode =
|
|
419
|
+
typeof parsed.permissions.defaultMode === 'string' &&
|
|
420
|
+
parsed.permissions.defaultMode.trim() !== ''
|
|
421
|
+
? parsed.permissions.defaultMode
|
|
422
|
+
: undefined
|
|
423
|
+
if (declaredMode !== undefined) {
|
|
424
|
+
if (isProject) projectModeSkipped = true
|
|
425
|
+
else permissions.defaultMode = declaredMode
|
|
426
|
+
}
|
|
346
427
|
for (const key of ['allow', 'deny'] as const) {
|
|
347
428
|
const list = parsed.permissions[key]
|
|
348
429
|
if (!Array.isArray(list)) continue
|
|
@@ -362,6 +443,7 @@ export function loadSettingsJson(
|
|
|
362
443
|
// gated on the strength of a file with nothing in it.
|
|
363
444
|
const result: SettingsJson = { hooks, permissions }
|
|
364
445
|
if (projectHooksSkipped) result.projectHooksSkipped = true
|
|
446
|
+
if (projectModeSkipped) result.projectModeSkipped = true
|
|
365
447
|
if (Object.values(projectHooks).some((entries) => Array.isArray(entries) && entries.length > 0)) {
|
|
366
448
|
result.projectHooks = projectHooks
|
|
367
449
|
}
|
|
@@ -456,10 +538,20 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
|
|
|
456
538
|
|
|
457
539
|
let config = { ...DEFAULT_CONFIG }
|
|
458
540
|
|
|
541
|
+
// The project file enters `loadConfig` at **two** points (fresh parse, and the
|
|
542
|
+
// parse that follows a restore from backup). Both go through here, so the guard
|
|
543
|
+
// on `permission` cannot be present on one path and missing on the other — a
|
|
544
|
+
// corrupted config that recovers from a backup is still the same repository's
|
|
545
|
+
// file, and "corrupt it once, get the setting honored" would be a bypass of the
|
|
546
|
+
// one key that is refused.
|
|
547
|
+
const applyProjectConfig = (parsed: Partial<MiphamConfig>): void => {
|
|
548
|
+
config = mergeConfig(config, stripProjectPermission(parsed, configPath), false)
|
|
549
|
+
}
|
|
550
|
+
|
|
459
551
|
// ── Load project-level config ──
|
|
460
552
|
const projectConfig = safeParseYaml(configPath, 'project config')
|
|
461
553
|
if (projectConfig) {
|
|
462
|
-
|
|
554
|
+
applyProjectConfig(projectConfig)
|
|
463
555
|
} else if (isRegularFile(configPath)) {
|
|
464
556
|
// A real file is there but failed to parse — try to restore from backup.
|
|
465
557
|
// 非普通文件走不到这里:那不是「损坏的配置」,而是根本不该当配置读的东西
|
|
@@ -471,7 +563,7 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
|
|
|
471
563
|
// Retry parsing after restore
|
|
472
564
|
const restored = safeParseYaml(configPath, 'restored project config')
|
|
473
565
|
if (restored) {
|
|
474
|
-
|
|
566
|
+
applyProjectConfig(restored)
|
|
475
567
|
}
|
|
476
568
|
}
|
|
477
569
|
}
|
package/src/core/context.ts
CHANGED
|
@@ -37,6 +37,14 @@ interface Checkpoint {
|
|
|
37
37
|
export class ContextManager {
|
|
38
38
|
private messages: Message[] = []
|
|
39
39
|
private systemPrompt = ''
|
|
40
|
+
/**
|
|
41
|
+
* 系统提示里的**权限段**是读时派生的,不是组装时烘进 `systemPrompt` 的一份拷贝。
|
|
42
|
+
*
|
|
43
|
+
* 烘进去的那份拷贝会与执行分叉:Shift+Tab 之后闸门与页脚都变了,模型手里还是旧指令
|
|
44
|
+
* —— 往窄切是自纠正的(模型比闸门更保守),**往宽切**则让模型拒绝做它已经被允许做的事。
|
|
45
|
+
* `index.tsx` 把它接到 live `PermissionSystem.getMode()` 上,于是切一次档,下一次请求就变。
|
|
46
|
+
*/
|
|
47
|
+
private permissionContextSource: (() => string) | null = null
|
|
40
48
|
private estimatedTokens = 0
|
|
41
49
|
private checkpoints: Checkpoint[] = []
|
|
42
50
|
private checkpointCounter = 0
|
|
@@ -116,11 +124,32 @@ export class ContextManager {
|
|
|
116
124
|
|
|
117
125
|
setSystemPrompt(prompt: string): void {
|
|
118
126
|
this.systemPrompt = prompt
|
|
119
|
-
this.estimatedTokens = this.estimateTokens(
|
|
127
|
+
this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 接线点(`index.tsx`):把权限段接到 live `PermissionSystem.getMode()` 上。
|
|
132
|
+
*
|
|
133
|
+
* 传 `null` 撤销。**不接**时系统提示里没有权限段(这里是唯一的施加点,故
|
|
134
|
+
* `test/integrity/permission-status-parity.test.ts` 从源码侧断这一行在场)。
|
|
135
|
+
*/
|
|
136
|
+
setPermissionContextSource(fn: (() => string) | null): void {
|
|
137
|
+
this.permissionContextSource = fn
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 存储的提示 + 读时派生的权限段。
|
|
142
|
+
*
|
|
143
|
+
* 段尾追加(而非插回原来的中段位置)是刻意的:只切档时**前缀保持不变**,
|
|
144
|
+
* 提供方的 prefix cache 仍能命中到权限段之前的部分。
|
|
145
|
+
*/
|
|
146
|
+
private composedSystemPrompt(): string {
|
|
147
|
+
const block = this.permissionContextSource?.() ?? ''
|
|
148
|
+
return block ? `${this.systemPrompt}\n\n---\n\n${block}` : this.systemPrompt
|
|
120
149
|
}
|
|
121
150
|
|
|
122
151
|
getSystemPrompt(): string {
|
|
123
|
-
return this.
|
|
152
|
+
return this.composedSystemPrompt()
|
|
124
153
|
}
|
|
125
154
|
|
|
126
155
|
addMessage(msg: Message): void {
|
|
@@ -239,7 +268,7 @@ export class ContextManager {
|
|
|
239
268
|
}
|
|
240
269
|
|
|
241
270
|
// Re-estimate tokens
|
|
242
|
-
this.estimatedTokens = this.estimateTokens(this.
|
|
271
|
+
this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
|
|
243
272
|
for (const msg of this.messages) {
|
|
244
273
|
this.estimatedTokens += this.estimateTokens(
|
|
245
274
|
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
@@ -257,7 +286,7 @@ export class ContextManager {
|
|
|
257
286
|
this.messages = []
|
|
258
287
|
this.checkpoints = []
|
|
259
288
|
this.checkpointCounter = 0
|
|
260
|
-
this.estimatedTokens = this.estimateTokens(this.
|
|
289
|
+
this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
|
|
261
290
|
}
|
|
262
291
|
|
|
263
292
|
getMessageCount(): number {
|
|
@@ -273,7 +302,7 @@ export class ContextManager {
|
|
|
273
302
|
replaceMessages(messages: Message[]): void {
|
|
274
303
|
this.messages = messages
|
|
275
304
|
// Re-estimate tokens
|
|
276
|
-
this.estimatedTokens = this.estimateTokens(this.
|
|
305
|
+
this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
|
|
277
306
|
for (const msg of messages) {
|
|
278
307
|
this.estimatedTokens += this.estimateTokens(
|
|
279
308
|
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
@@ -418,7 +447,7 @@ export class ContextManager {
|
|
|
418
447
|
|
|
419
448
|
/** Re-estimate tokens from system prompt + current messages. */
|
|
420
449
|
private reEstimateTokens(): void {
|
|
421
|
-
this.estimatedTokens = this.estimateTokens(this.
|
|
450
|
+
this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
|
|
422
451
|
for (const msg of this.messages) {
|
|
423
452
|
this.estimatedTokens += this.estimateTokens(
|
|
424
453
|
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
package/src/core/instructions.ts
CHANGED
|
@@ -104,6 +104,58 @@ export const INSTRUCTION_FILENAMES = [
|
|
|
104
104
|
'CLAUDE.md',
|
|
105
105
|
] as const
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* P2-2: the permission-mode section of the system prompt. Tells the model its
|
|
109
|
+
* current permission level and what to expect.
|
|
110
|
+
*
|
|
111
|
+
* Module-level and pure (it reads no loaded instruction files) because **every**
|
|
112
|
+
* prompt-assembly site needs the same text: the CLI's main context, where the
|
|
113
|
+
* context calls it at read time via `ContextManager.setPermissionContextSource`
|
|
114
|
+
* so a mid-session Shift+Tab moves the text and the gate together, and each
|
|
115
|
+
* **sub-agent**, which reports **its own** mode (see `sub-agent.ts`).
|
|
116
|
+
*/
|
|
117
|
+
export function buildPermissionBlock(mode: string): string {
|
|
118
|
+
// Hand-written map, and a missing key is **silent**: the `if (!description)`
|
|
119
|
+
// below returns `''`, so the system prompt would simply say nothing about
|
|
120
|
+
// permissions rather than warn. Every `PermissionMode` member needs a line.
|
|
121
|
+
// `auto`'s text has to describe a gate the model cannot see: it is told
|
|
122
|
+
// "a classifier rules on each of your calls" rather than "you are
|
|
123
|
+
// unrestricted", because a model that believes it has blanket permission
|
|
124
|
+
// stops explaining what it is about to do — which is exactly the input the
|
|
125
|
+
// classifier needs.
|
|
126
|
+
const modeDescriptions: Record<string, string> = {
|
|
127
|
+
default:
|
|
128
|
+
'You are in **default** mode. Tools marked as requiring approval will be blocked. Use Read/Grep/Glob for exploration.',
|
|
129
|
+
acceptEdits:
|
|
130
|
+
'You are in **acceptEdits** mode. File reads and edits are allowed; Bash requires approval.',
|
|
131
|
+
plan: 'You are in **plan** mode. Only Read/Grep/Glob are allowed — no file modifications or command execution.',
|
|
132
|
+
auto: 'You are in **auto** mode. A classifier reviews each tool call before it runs and blocks calls that are destructive, that act on instructions found in files or tool output, or that touch credentials. Approved calls run; blocked ones return a denial with the reason. Prefer explaining the intent of a call when it is unusual.',
|
|
133
|
+
bypassPermissions:
|
|
134
|
+
'You are in **bypassPermissions** mode. All tools are allowed. Use this power responsibly.',
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const description = modeDescriptions[mode]
|
|
138
|
+
if (!description) return ''
|
|
139
|
+
|
|
140
|
+
// What actually lifts a denial is not the same in `auto`. There the refusal is a
|
|
141
|
+
// ruling on one exact call, and a repeat of that same call is answered from the
|
|
142
|
+
// classifier cache rather than re-judged — so "retry after explaining yourself" is
|
|
143
|
+
// advice that cannot work, and telling the model to switch modes is advice that is
|
|
144
|
+
// never needed. The two levers that do work are named instead.
|
|
145
|
+
const escape =
|
|
146
|
+
mode === 'auto'
|
|
147
|
+
? ' In **auto** mode the refusal is a ruling on that exact call: an allow rule (`/permissions allow`) lifts it, and so does changing the call so it no longer trips the rule — repeating the identical call returns the same ruling.'
|
|
148
|
+
: ''
|
|
149
|
+
|
|
150
|
+
// The tail used to name `bypassPermissions` as the Shift+Tab destination. That
|
|
151
|
+
// was true while the wheel carried it and became false the moment `auto`
|
|
152
|
+
// replaced it — and this string is *advice the model repeats to the user*, so
|
|
153
|
+
// staying stale makes it promise a keypress that does nothing. `bypassPermissions`
|
|
154
|
+
// is still reachable, but only by naming it in config; saying so is what keeps
|
|
155
|
+
// the model from offering it as a way out.
|
|
156
|
+
return `## Permission Context\n\n${description}\n\nWhen a tool is denied, do NOT retry it or any other approval-gated tool — Bash, WebSearch, network, and Workflow are all blocked in this mode.${escape} If the task genuinely needs a blocked tool, STOP retrying and ask the user to switch modes with Shift+Tab or add an allow rule (/permissions), then wait for the user's answer. Note that Shift+Tab's wheel does not reach bypassPermissions — that mode is set in config, so do not offer it as a keypress.`
|
|
157
|
+
}
|
|
158
|
+
|
|
107
159
|
export class InstructionsLoader {
|
|
108
160
|
private instructions: InstructionFile[] = []
|
|
109
161
|
private crsiLessonSummaries: CrsiLessonSummary[] = []
|
|
@@ -133,7 +185,16 @@ export class InstructionsLoader {
|
|
|
133
185
|
this.crsiLessonSummaries = this.loadCrsiLessons(root)
|
|
134
186
|
}
|
|
135
187
|
|
|
136
|
-
|
|
188
|
+
/**
|
|
189
|
+
* The base prompt. **Deliberately takes no permission mode** — the mode is not a
|
|
190
|
+
* component of the prompt that gets assembled once, it is the answer to "which gate
|
|
191
|
+
* will run this call", and that answer changes mid-session (Shift+Tab). Baking it in
|
|
192
|
+
* here froze a copy: narrowing was self-correcting (the model obeys a gate that is now
|
|
193
|
+
* wider than it was told), but *widening* left the model refusing work it was already
|
|
194
|
+
* allowed to do. `ContextManager.setPermissionContextSource` derives the section on
|
|
195
|
+
* every read instead, so `permission.getMode()` is never sampled once and cached.
|
|
196
|
+
*/
|
|
197
|
+
buildSystemPrompt(): string {
|
|
137
198
|
const parts: string[] = []
|
|
138
199
|
|
|
139
200
|
for (const inst of this.instructions) {
|
|
@@ -156,11 +217,8 @@ export class InstructionsLoader {
|
|
|
156
217
|
parts.push(`<!-- ${levelLabel[inst.level] || inst.level} (${inst.path}) -->\n${content}`)
|
|
157
218
|
}
|
|
158
219
|
|
|
159
|
-
// P2-2
|
|
160
|
-
|
|
161
|
-
parts.push(this.buildPermissionContext(permissionMode))
|
|
162
|
-
}
|
|
163
|
-
|
|
220
|
+
// P2-2 的权限段**不在**这里 —— 见 `buildPermissionBlock` 与
|
|
221
|
+
// `ContextManager.setPermissionContextSource`(读时派生,故切档即生效)。
|
|
164
222
|
// 开场克制:寒暄只回一句短问候,不上能力清单(避免把「你好」当「你是谁」处理)
|
|
165
223
|
parts.push(`## Greeting Restraint
|
|
166
224
|
|
|
@@ -322,49 +380,12 @@ Never omit it or present the work as purely human-authored.`)
|
|
|
322
380
|
}
|
|
323
381
|
|
|
324
382
|
/**
|
|
325
|
-
*
|
|
326
|
-
*
|
|
383
|
+
* Loader-shaped alias of {@link buildPermissionBlock}(模块级那个才是实现)。
|
|
384
|
+
* 保留它是因为**已有**的调用点都握着一个装载器:`index.tsx` 的接线行与
|
|
385
|
+
* `test/core/permission-prompt-live.test.ts` 的 `wire()`。它不含任何状态。
|
|
327
386
|
*/
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
// below returns `''`, so the system prompt would simply say nothing about
|
|
331
|
-
// permissions rather than warn. Every `PermissionMode` member needs a line.
|
|
332
|
-
// `auto`'s text has to describe a gate the model cannot see: it is told
|
|
333
|
-
// "a classifier rules on each of your calls" rather than "you are
|
|
334
|
-
// unrestricted", because a model that believes it has blanket permission
|
|
335
|
-
// stops explaining what it is about to do — which is exactly the input the
|
|
336
|
-
// classifier needs.
|
|
337
|
-
const modeDescriptions: Record<string, string> = {
|
|
338
|
-
default:
|
|
339
|
-
'You are in **default** mode. Tools marked as requiring approval will be blocked. Use Read/Grep/Glob for exploration.',
|
|
340
|
-
acceptEdits:
|
|
341
|
-
'You are in **acceptEdits** mode. File reads and edits are allowed; Bash requires approval.',
|
|
342
|
-
plan: 'You are in **plan** mode. Only Read/Grep/Glob are allowed — no file modifications or command execution.',
|
|
343
|
-
auto: 'You are in **auto** mode. A classifier reviews each tool call before it runs and blocks calls that are destructive, that act on instructions found in files or tool output, or that touch credentials. Approved calls run; blocked ones return a denial with the reason. Prefer explaining the intent of a call when it is unusual.',
|
|
344
|
-
bypassPermissions:
|
|
345
|
-
'You are in **bypassPermissions** mode. All tools are allowed. Use this power responsibly.',
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const description = modeDescriptions[mode]
|
|
349
|
-
if (!description) return ''
|
|
350
|
-
|
|
351
|
-
// What actually lifts a denial is not the same in `auto`. There the refusal is a
|
|
352
|
-
// ruling on one exact call, and a repeat of that same call is answered from the
|
|
353
|
-
// classifier cache rather than re-judged — so "retry after explaining yourself" is
|
|
354
|
-
// advice that cannot work, and telling the model to switch modes is advice that is
|
|
355
|
-
// never needed. The two levers that do work are named instead.
|
|
356
|
-
const escape =
|
|
357
|
-
mode === 'auto'
|
|
358
|
-
? ' In **auto** mode the refusal is a ruling on that exact call: an allow rule (`/permissions allow`) lifts it, and so does changing the call so it no longer trips the rule — repeating the identical call returns the same ruling.'
|
|
359
|
-
: ''
|
|
360
|
-
|
|
361
|
-
// The tail used to name `bypassPermissions` as the Shift+Tab destination. That
|
|
362
|
-
// was true while the wheel carried it and became false the moment `auto`
|
|
363
|
-
// replaced it — and this string is *advice the model repeats to the user*, so
|
|
364
|
-
// staying stale makes it promise a keypress that does nothing. `bypassPermissions`
|
|
365
|
-
// is still reachable, but only by naming it in config; saying so is what keeps
|
|
366
|
-
// the model from offering it as a way out.
|
|
367
|
-
return `## Permission Context\n\n${description}\n\nWhen a tool is denied, do NOT retry it or any other approval-gated tool — Bash, WebSearch, network, and Workflow are all blocked in this mode.${escape} If the task genuinely needs a blocked tool, STOP retrying and ask the user to switch modes with Shift+Tab or add an allow rule (/permissions), then wait for the user's answer. Note that Shift+Tab's wheel does not reach bypassPermissions — that mode is set in config, so do not offer it as a keypress.`
|
|
387
|
+
buildPermissionBlock(mode: string): string {
|
|
388
|
+
return buildPermissionBlock(mode)
|
|
368
389
|
}
|
|
369
390
|
|
|
370
391
|
list(): InstructionFile[] {
|
|
@@ -399,11 +399,21 @@ export class LlmPermissionClassifier implements PermissionClassifier {
|
|
|
399
399
|
|
|
400
400
|
let text = ''
|
|
401
401
|
let streamError: string | undefined
|
|
402
|
+
let truncated = false
|
|
402
403
|
try {
|
|
403
404
|
for await (const chunk of this.llm.chat({
|
|
404
405
|
model: this.config.resolveModel(),
|
|
405
406
|
messages: [{ role: 'user', content: prompt }],
|
|
406
|
-
maxTokens
|
|
407
|
+
// NO `maxTokens`. This cap is shared with the model's **thinking**, and the
|
|
408
|
+
// configured model may be a reasoning one: `reasoning_content` is billed
|
|
409
|
+
// against `max_tokens` but is not what the loop below accumulates, so a cap
|
|
410
|
+
// sized for the 17-character reply starves the reply itself. Measured
|
|
411
|
+
// 2026-09-24 against the configured `deepseek-v4-pro` on three realistic
|
|
412
|
+
// calls: ~880 chars of reasoning consumed the whole budget, `finish_reason`
|
|
413
|
+
// came back `length`, the visible answer was empty 3/3, and every one of
|
|
414
|
+
// those calls was held back as an unreadable reply — i.e. precisely the calls
|
|
415
|
+
// worth classifying are the ones that failed. The provider's own default
|
|
416
|
+
// (`req.maxTokens || declaredMaxOutput || 8192`) is the budget now.
|
|
407
417
|
temperature: 0,
|
|
408
418
|
signal: controller.signal,
|
|
409
419
|
})) {
|
|
@@ -411,6 +421,10 @@ export class LlmPermissionClassifier implements PermissionClassifier {
|
|
|
411
421
|
// An in-stream error would otherwise look exactly like an empty reply —
|
|
412
422
|
// and an empty reply is what a *denial* looks like. Name it instead.
|
|
413
423
|
else if (chunk.type === 'error') streamError = chunk.error ?? 'provider error'
|
|
424
|
+
// The provider sets this for `finish_reason: 'length'`. Reading it is the
|
|
425
|
+
// difference between "the reply was cut off at the cap" and "the reply was
|
|
426
|
+
// unreadable" — two very different things to hand a user.
|
|
427
|
+
else if (chunk.type === 'stop' && chunk.truncated) truncated = true
|
|
414
428
|
}
|
|
415
429
|
} catch (error) {
|
|
416
430
|
return {
|
|
@@ -435,10 +449,14 @@ export class LlmPermissionClassifier implements PermissionClassifier {
|
|
|
435
449
|
return { allow: false, rule: parsed.rule, reason: parsed.reason }
|
|
436
450
|
}
|
|
437
451
|
// Unreadable reply ⇒ held back, and said to be retryable — the model did not
|
|
438
|
-
// rule, so treating this as a policy refusal would be a lie.
|
|
452
|
+
// rule, so treating this as a policy refusal would be a lie. A reply the
|
|
453
|
+
// provider flagged as cut off gets named as such: "unreadable" sends the reader
|
|
454
|
+
// hunting for a malformed response when the cause was a token ceiling.
|
|
439
455
|
return {
|
|
440
456
|
allow: false,
|
|
441
|
-
reason:
|
|
457
|
+
reason: truncated
|
|
458
|
+
? `classifier reply was cut off at the output token cap before it ruled (${parsed.detail})`
|
|
459
|
+
: `classifier response unreadable: ${parsed.detail}`,
|
|
442
460
|
retryable: true,
|
|
443
461
|
}
|
|
444
462
|
}
|