@miphamai/cli 0.85.4 → 0.85.5

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.
@@ -156,12 +156,86 @@ export function buildPermissionBlock(mode: string): string {
156
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
157
  }
158
158
 
159
+ /** Level label used in each prompt part's provenance comment. */
160
+ const LEVEL_LABELS: Record<string, string> = {
161
+ group: 'Group Policy',
162
+ company: 'Company Policy',
163
+ project: 'Project Rules',
164
+ directory: 'Directory Rules',
165
+ user: 'User Preferences',
166
+ }
167
+
168
+ /**
169
+ * The text one loaded file contributes to the system prompt — `prompt-exclude`
170
+ * sections stripped, `privacy: private` files omitted (`null`).
171
+ *
172
+ * Single source for the prompt **and** the size report. A report that measured
173
+ * the file on disk instead would overcount exactly the files this repository
174
+ * writes (its own `prompt-exclude` hides tens of thousands of characters), and
175
+ * the two numbers would drift apart with nothing saying which one is sent.
176
+ */
177
+ function instructionPartText(inst: InstructionFile): string | null {
178
+ if (inst.privacy === 'private') return null
179
+ const content = stripSections(
180
+ inst.content,
181
+ parsePromptExclude(inst.frontmatter['prompt-exclude']),
182
+ )
183
+ return `<!-- ${LEVEL_LABELS[inst.level] || inst.level} (${inst.path}) -->\n${content}`
184
+ }
185
+
186
+ /** One file's share of the instruction payload. */
187
+ export interface InstructionSize {
188
+ path: string
189
+ chars: number
190
+ }
191
+
192
+ export interface InstructionSizeReport {
193
+ totalChars: number
194
+ /** Descending by size — the largest contributor first. */
195
+ files: InstructionSize[]
196
+ }
197
+
198
+ /**
199
+ * Characters of file-derived instruction text a session sends with **every**
200
+ * request, before the conversation starts. 40,000 is the budget this
201
+ * organisation already writes a single governance file against (the parent
202
+ * `CLAUDE.md`), so the notice fires when everything loaded together has grown
203
+ * past one such file.
204
+ */
205
+ export const INSTRUCTION_BUDGET_CHARS = 40_000
206
+
207
+ /**
208
+ * The startup notice, or `null` while the payload is within budget.
209
+ *
210
+ * The **total** is the point: no file has to be large for the instruction
211
+ * payload to crowd out the work, so a per-file check cannot see a dozen
212
+ * mid-sized rule files and a lessons block adding up. Naming the largest few
213
+ * is what makes the number actionable.
214
+ */
215
+ export function formatInstructionSizeNotice(
216
+ report: InstructionSizeReport,
217
+ budget: number = INSTRUCTION_BUDGET_CHARS,
218
+ ): string | null {
219
+ if (report.totalChars <= budget) return null
220
+ const num = (n: number) => n.toLocaleString('en-US')
221
+ const shown = report.files.slice(0, 3).map((f) => `${f.path} — ${num(f.chars)}`)
222
+ if (report.files.length > shown.length) shown.push(`+${report.files.length - shown.length} more`)
223
+ return (
224
+ `⚠ Instruction files total ${num(report.totalChars)} characters (budget ${num(budget)}), ` +
225
+ `sent with every request.\n` +
226
+ ` Largest: ${shown.join(' · ')}\n` +
227
+ ` Trim them, or move doc-only sections under a \`prompt-exclude\` frontmatter key.`
228
+ )
229
+ }
230
+
159
231
  export class InstructionsLoader {
160
232
  private instructions: InstructionFile[] = []
161
233
  private crsiLessonSummaries: CrsiLessonSummary[] = []
234
+ private lessonsPath: string | null = null
162
235
 
163
236
  loadAll(cwd: string): void {
164
237
  this.instructions = []
238
+ this.lessonsPath = null
165
239
  const root = gitRoot(cwd)
166
240
 
167
241
  // Tier 1: 集团/公司策略(锚定仓库根,从任意子目录启动都正确;不读 AGENTS.md)
@@ -198,23 +272,12 @@ export class InstructionsLoader {
198
272
  const parts: string[] = []
199
273
 
200
274
  for (const inst of this.instructions) {
201
- // Honor `privacy: private` — such instructions are never sent to the model.
202
- if (inst.privacy === 'private') continue
203
-
204
- const levelLabel: Record<string, string> = {
205
- group: 'Group Policy',
206
- company: 'Company Policy',
207
- project: 'Project Rules',
208
- directory: 'Directory Rules',
209
- user: 'User Preferences',
210
- }
211
- // Strip doc-only sections declared via `prompt-exclude` frontmatter
275
+ // `instructionPartText` honors `privacy: private` (never sent) and strips
276
+ // doc-only sections declared via `prompt-exclude` frontmatter
212
277
  // (changelog/roadmap/catalog are human-facing, not machine rules).
213
- const content = stripSections(
214
- inst.content,
215
- parsePromptExclude(inst.frontmatter['prompt-exclude']),
216
- )
217
- parts.push(`<!-- ${levelLabel[inst.level] || inst.level} (${inst.path}) -->\n${content}`)
278
+ const text = instructionPartText(inst)
279
+ if (text === null) continue
280
+ parts.push(text)
218
281
  }
219
282
 
220
283
  // P2-2 的权限段**不在**这里 —— 见 `buildPermissionBlock` 与
@@ -371,8 +434,11 @@ Never omit it or present the work as purely human-authored.`)
371
434
 
372
435
  /** 读 crsi-lessons.md(按仓库根定位)提取教训精华。读不到则返回空。 */
373
436
  private loadCrsiLessons(root: string): CrsiLessonSummary[] {
437
+ const path = join(root, LESSONS_FILE)
374
438
  try {
375
- const content = readFileSync(join(root, LESSONS_FILE), 'utf-8')
439
+ const content = readFileSync(path, 'utf-8')
440
+ // Remember where the recalled text came from — `sizeReport` names it.
441
+ this.lessonsPath = path
376
442
  return extractCrsiLessonSummaries(content)
377
443
  } catch {
378
444
  return []
@@ -392,6 +458,28 @@ Never omit it or present the work as purely human-authored.`)
392
458
  return [...this.instructions]
393
459
  }
394
460
 
461
+ /**
462
+ * How much instruction text this loader puts in the system prompt, per file.
463
+ *
464
+ * Read through `instructionPartText` — the same projection `buildSystemPrompt`
465
+ * uses — so the report cannot describe something other than what is sent. The
466
+ * CRSI lessons block counts too: it is rendered from `crsi-lessons.md` and
467
+ * carried on every request like any other rule file.
468
+ */
469
+ sizeReport(): InstructionSizeReport {
470
+ const files: InstructionSize[] = []
471
+ for (const inst of this.instructions) {
472
+ const text = instructionPartText(inst)
473
+ if (text !== null) files.push({ path: inst.path, chars: text.length })
474
+ }
475
+ if (this.lessonsPath) {
476
+ const lessons = buildCrsiLessonsBlock(this.crsiLessonSummaries)
477
+ if (lessons) files.push({ path: this.lessonsPath, chars: lessons.length })
478
+ }
479
+ files.sort((a, b) => b.chars - a.chars)
480
+ return { totalChars: files.reduce((n, f) => n + f.chars, 0), files }
481
+ }
482
+
395
483
  private tryLoad(path: string, level: InstructionFile['level']): void {
396
484
  if (!existsSync(path)) return
397
485
 
@@ -362,7 +362,7 @@ function extractSubstitutions(command: string): string[] {
362
362
  * REPORTTIME/REPORTMEMORY/DIRSTACKSIZE assignments immediately — and
363
363
  * `bash -c 'rm -rf /'`. Over-matching is the safe direction for a deny rule.
364
364
  */
365
- function flattenCommand(command: string, depth = 0): string[] {
365
+ export function flattenCommand(command: string, depth = 0): string[] {
366
366
  const out: string[] = []
367
367
  for (const seg of splitShellSegments(command)) {
368
368
  out.push(seg)
@@ -7,6 +7,8 @@ import type {
7
7
  } from '../shared/index.ts'
8
8
  import type { PermissionRuleEntry } from '../shared/index.ts'
9
9
  import { matchBashRule, compileRule } from './permission-rules'
10
+ import { detectDangerousRm } from '../security/dangerous-rm'
11
+ import type { DangerousRm } from '../security/dangerous-rm'
10
12
  import {
11
13
  loadPermissionConfig,
12
14
  nextMode,
@@ -123,6 +125,7 @@ export type PermissionDenialReason =
123
125
  | 'tool-default' // tool.permission === 'ask'
124
126
  | 'system-default' // no rule, no tool permission → fallback ask
125
127
  | 'classifier-deny' // `auto` mode's classifier ruled against the call
128
+ | 'dangerous-rm' // recursive rm whose target is not a path in the command text
126
129
 
127
130
  /**
128
131
  * Which denial reasons `auto` mode's classifier is allowed to rule on — an
@@ -138,6 +141,11 @@ export type PermissionDenialReason =
138
141
  * `legacy-rule` is absent for the same reason as the rules: it is an explicit
139
142
  * per-tool decision from `setRule()`. `classifier-deny` is absent because it is not
140
143
  * a *static* reason at all — `explainDenial()` never returns it.
144
+ *
145
+ * `dangerous-rm` is absent because a classifier cannot be *asked* the question this
146
+ * reason answers. Every other reason here is "the mode was not sure, let a second
147
+ * opinion decide"; this one is "the command does not say what it will delete", and
148
+ * a second opinion reading the same command is reading the same missing text.
141
149
  */
142
150
  const CLASSIFIABLE: ReadonlySet<PermissionDenialReason> = new Set<PermissionDenialReason>([
143
151
  'mode-baseline',
@@ -463,6 +471,21 @@ export class PermissionSystem {
463
471
  return 'ask' // absent tool → safest default
464
472
  }
465
473
 
474
+ // ── A recursive `rm` whose target is not a path written in the command ──
475
+ // First, and deliberately **uncached**. Every other branch below reasons about
476
+ // the command's text; this is the one case where the text does not name what
477
+ // gets deleted, so it has to sit ahead of the allow rules and ahead of every
478
+ // mode baseline (`auto`, `bypassPermissions`) rather than inside them.
479
+ //
480
+ // Not cached because its answer depends on the environment opt-out, and the
481
+ // cache is keyed on tool+input alone. Caching here would mean an opt-out set
482
+ // before launch could never be observed being *un*set — the decision would
483
+ // outlive the input that produced it. Re-running the string check on each Bash
484
+ // call is cheaper than a cache that can contradict its own inputs.
485
+ if (this.dangerousRm(tool, input)) {
486
+ return 'ask'
487
+ }
488
+
466
489
  // ── Cache lookup (P2): reuse decision for same tool+mode+input ──
467
490
  const cacheKey = this.cacheKey(tool, input)
468
491
  if (this.cacheMode === this.mode) {
@@ -551,7 +574,7 @@ export class PermissionSystem {
551
574
  explainDenial(
552
575
  tool: ToolDefinition,
553
576
  input: Record<string, unknown>,
554
- ): { reason: PermissionDenialReason; rulePattern?: string } {
577
+ ): { reason: PermissionDenialReason; rulePattern?: string; target?: string } {
555
578
  for (const rule of this.denyRules) {
556
579
  if (this.ruleMatches(rule, tool, input)) {
557
580
  return { reason: 'deny-rule', rulePattern: rule.pattern }
@@ -562,6 +585,10 @@ export class PermissionSystem {
562
585
  return { reason: 'ask-rule', rulePattern: rule.pattern }
563
586
  }
564
587
  }
588
+ const dangerous = this.dangerousRm(tool, input)
589
+ if (dangerous) {
590
+ return { reason: 'dangerous-rm', target: dangerous.target }
591
+ }
565
592
  if (this.legacyRules.has(tool.name)) {
566
593
  return { reason: 'legacy-rule' }
567
594
  }
@@ -583,6 +610,28 @@ export class PermissionSystem {
583
610
  return tool.name + '|' + JSON.stringify(input, Object.keys(input).sort())
584
611
  }
585
612
 
613
+ /**
614
+ * A recursive `rm` whose target is not a path written in the command.
615
+ *
616
+ * Only the Bash tool: the question is about a *shell command line*, and a tool
617
+ * that merely happens to take a `command` parameter is not making a claim about
618
+ * what it will delete.
619
+ *
620
+ * The escape hatch is read from the **environment**, not from tool parameters.
621
+ * That is the difference between an operator switch and a model switch: a
622
+ * parameter the call can set is a guard the call can turn off, and
623
+ * `dangerouslyDisableSandbox` already shows how that ends. Read at call time
624
+ * rather than captured at construction so that a process which sets it before
625
+ * its first matching call gets the documented behaviour.
626
+ */
627
+ private dangerousRm(tool: ToolDefinition, input: Record<string, unknown>): DangerousRm | null {
628
+ if (tool.name !== 'Bash') return null
629
+ if (process.env.MIPHAM_DISABLE_DANGEROUS_RM_PROMPT === '1') return null
630
+ const command = input.command
631
+ if (typeof command !== 'string') return null
632
+ return detectDangerousRm(command)
633
+ }
634
+
586
635
  /**
587
636
  * Resolve a call to a decision, consulting `auto` mode's classifier when — and
588
637
  * only when — the static chain answered `'ask'` for a reason a classifier is
@@ -118,6 +118,66 @@ export function deriveMessages(events: SessionEvent[]): Message[] {
118
118
 
119
119
  const LOG_DIR = miphamHome('sessions')
120
120
 
121
+ /** 补上的那条结果的正文:说明事情本身,并给出下一步,而不是只报一个状态。 */
122
+ function interruptedCallNotice(): string {
123
+ return (
124
+ `This tool call was in flight when the session ended, so its outcome is unknown — ` +
125
+ `the result was never recorded.\n` +
126
+ `Do not assume it succeeded or failed: check the actual state (read the files, ` +
127
+ `re-run the command) and re-issue the call if it did not take effect.`
128
+ )
129
+ }
130
+
131
+ /**
132
+ * 恢复会话时收尾:给日志里**没有结果**的调用补一条「结果未知」的 `tool/result`
133
+ * 事件,返回补了几条。
134
+ *
135
+ * 为什么非补不可:助手消息里挂着 `tool_calls` 而没有任何结果回应,OpenAI / DeepSeek
136
+ * 会整条请求拒收,Anthropic 还要求 `tool_result` 紧跟在那条 `tool_use` 之后。于是
137
+ * 「这次的调用没收尾」在用户那里表现成「恢复之后说的第一句话就报协议错」。
138
+ *
139
+ * 这个形状**是从盘上读来的,不是引擎写出来的**:`engine.ts` 先落调用消息、紧接着
140
+ * 落结果(同一同步块),而这批事件只在退出时整份刷盘 —— 跑到一半被杀根本留不下那条
141
+ * 调用。够得着的是**读侧**:`save()` 逐行追加,`open()` 把读不动的行静默丢掉(半截
142
+ * JSON 过不了 `JSON.parse`),于是写盘写到一半被打断时,末尾那条结果被丢、调用留在
143
+ * 盘上。修在恢复这一步,是因为读侧的入口只有这一个(`ContextManager.restoreLog`)。
144
+ *
145
+ * 为什么补成**事件**而不是往投影里塞一条消息:本仓库的不变量是「模型看得见的必须已
146
+ * 记录」(`assertModelVisible`),凭空出现的消息正好违反它;`messageToEvents` /
147
+ * `deriveMessages` 的字节级互逆也不能被动过。补事件两边都成立 —— 模型**看得见那次
148
+ * 调用**(它本来就在历史里),也知道**结果未知**,于是它先去查证,而不是当成没发生过、
149
+ * 也不是猜成功或失败。
150
+ *
151
+ * 幂等:已经有结果的 id 不会再补第二条。
152
+ */
153
+ export function closeInterruptedToolCalls(log: SessionLog): number {
154
+ const events = log.events()
155
+ const answered = new Set<string>()
156
+ for (const e of events) {
157
+ if (e.type === 'tool/result') answered.add(e.id)
158
+ else if (e.type === 'user/message' && Array.isArray(e.message.content)) {
159
+ // 结果也可能整条嵌在 user/message 里(多块消息不拆事件),一样算「已回答」。
160
+ for (const b of e.message.content) {
161
+ if (b.type === 'tool_result') answered.add(b.tool_use_id)
162
+ }
163
+ }
164
+ }
165
+
166
+ const pending: string[] = []
167
+ for (const e of events) {
168
+ if (e.type === 'tool/call' && !answered.has(e.id)) pending.push(e.id)
169
+ }
170
+
171
+ const at = Date.now()
172
+ for (const id of pending) {
173
+ // 失败结果在投影里被读成 `error || content`(见 `deriveMessages`),两个字段同写;
174
+ // 这一段与 `deleted-cwd` 那次是同一个教训。
175
+ const content = interruptedCallNotice()
176
+ log.append({ type: 'tool/result', at, id, result: { success: false, content, error: content } })
177
+ }
178
+ return pending.length
179
+ }
180
+
121
181
  /** 一次性告警:日志路径不是普通文件(写不进去),每个进程只说一句。 */
122
182
  let warnedNotAppendable = false
123
183
 
@@ -13,7 +13,6 @@ import type { Server } from 'bun'
13
13
  import { DaemonDatabase } from './database'
14
14
  import { SessionManager } from './session-manager'
15
15
  import { AgentManager } from './agent-manager'
16
- import { MessageBus } from './message-bus'
17
16
  import { GoalManager } from './goal-manager'
18
17
  import { ScheduleManager } from './schedule-manager'
19
18
  import { createServer } from './server'
@@ -118,7 +117,7 @@ export function getPort(): number {
118
117
  * 1. Ensures ~/.mipham exists (mode 0o700)
119
118
  * 2. Loads or creates the auth token
120
119
  * 3. Initializes the SQLite database and runs JSONL migration on first start
121
- * 4. Creates a SessionManager, AgentManager, and MessageBus
120
+ * 4. Creates a SessionManager and AgentManager
122
121
  * 5. Starts the HTTP server on an available port
123
122
  * 6. Writes PID and port files to disk
124
123
  *
@@ -152,9 +151,8 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
152
151
  const pool = new WorkerPool(db)
153
152
  activePool = pool
154
153
 
155
- // Create agent manager and message bus (Phase 3)
154
+ // Create agent manager (Phase 3)
156
155
  const agentManager = new AgentManager(db)
157
- const messageBus = new MessageBus()
158
156
 
159
157
  // Create goal manager and schedule manager (Phase 4)
160
158
  const goalManager = new GoalManager(db)
@@ -236,7 +234,6 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
236
234
  port,
237
235
  hostname,
238
236
  agentManager,
239
- messageBus,
240
237
  goalManager,
241
238
  scheduleManager,
242
239
  rateLimiter,
@@ -3,7 +3,6 @@ import type { Server, ServerWebSocket } from 'bun'
3
3
  import type { DaemonDatabase } from './database'
4
4
  import type { SessionManager } from './session-manager'
5
5
  import type { AgentManager } from './agent-manager'
6
- import type { MessageBus } from './message-bus'
7
6
  import type { DaemonGoal, AgentKind } from './types'
8
7
  import type { GoalManager } from './goal-manager'
9
8
  import type { ScheduleManager } from './schedule-manager'
@@ -49,7 +48,6 @@ interface ServerConfig {
49
48
  port: number
50
49
  hostname: string
51
50
  agentManager: AgentManager
52
- messageBus: MessageBus
53
51
  goalManager: GoalManager
54
52
  scheduleManager: ScheduleManager
55
53
  rateLimiter: RateLimiter
@@ -156,7 +154,6 @@ export function createServer(config: ServerConfig): Server<WsData> {
156
154
  port,
157
155
  hostname,
158
156
  agentManager,
159
- messageBus,
160
157
  goalManager,
161
158
  scheduleManager,
162
159
  rateLimiter,
@@ -192,15 +189,8 @@ export function createServer(config: ServerConfig): Server<WsData> {
192
189
  }
193
190
  }
194
191
 
195
- // ── Agent lifecycle → WebSocket broadcast + MessageBus registration ──
192
+ // ── Agent lifecycle → WebSocket broadcast ──
196
193
  agentManager.onLifecycleEvent((event) => {
197
- // Register / unregister in the message bus for broadcastToSession routing
198
- if (event.type === 'created') {
199
- messageBus.registerAgent(event.agent.sessionId, event.agent.id)
200
- } else if (event.type === 'completed' || event.type === 'failed') {
201
- messageBus.unregisterAgent(event.agent.id)
202
- }
203
-
204
194
  // Broadcast lifecycle events to all WebSocket clients in the agent's session
205
195
  broadcast(event.agent.sessionId, {
206
196
  type: 'agent_lifecycle',
@@ -670,8 +660,23 @@ export function createServer(config: ServerConfig): Server<WsData> {
670
660
  return json({ ok: false, error: 'Agent not found' }, { status: 404 })
671
661
  }
672
662
 
673
- messageBus.send('user', agentId, content)
674
- return json({ ok: true }, { status: 202 })
663
+ // 从前这里 `messageBus.send(...)` 后回 **202 + `{ok:true}`** —— 一张兑现不了的收条。
664
+ // daemon 侧**不存在 agent 执行循环**(`AgentManager` 纯持久化,`src/daemon/*.ts` 里
665
+ // `SubAgent`/`spawn` 零命中),所以进程内没有任何东西会去读这条消息;而真正在用的
666
+ // 那条总线(`src/agent/message-bus.ts`)由子代理 / workflow 用 `bg-…` 那套 id 投递,
667
+ // 与这里的 `agent-<uuid8>` **不同一个 id 空间**,改投它也找不到人。
668
+ // 202 在这里是有害的:调用方据此认为话已送达,于是不再重试、也不再报错。
669
+ // 报 501 并给出替代路径 —— 只说「不支持」会让调用方反复重试同一件事。
670
+ return json(
671
+ {
672
+ ok: false,
673
+ error:
674
+ 'Agent messaging is not implemented on the daemon: this build has no agent ' +
675
+ 'execution loop, so nothing would receive the message. Use ' +
676
+ 'POST /api/v1/sessions/:id/prompt to send work to a session instead.',
677
+ },
678
+ { status: 501 },
679
+ )
675
680
  }
676
681
 
677
682
  // ── Goals (Phase 4 — service-backed) ────────────
@@ -806,7 +811,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
806
811
  {
807
812
  method: 'POST',
808
813
  path: '/api/v1/agents/:id/message',
809
- description: 'Send message to an agent',
814
+ description: 'Not implemented — always 501; use POST /api/v1/sessions/:id/prompt',
810
815
  },
811
816
  { method: 'GET', path: '/api/v1/goals', description: 'List goals for a session' },
812
817
  { method: 'POST', path: '/api/v1/goals', description: 'Create a goal' },
@@ -996,8 +996,9 @@
996
996
  "errors": {
997
997
  "tool_denied_deny_rule": "Tool \"{name}\" blocked by a deny rule (\"{pattern}\"). Deny rules override permission mode — try a different approach, or drop the rule with: /permissions remove \"{pattern}\"",
998
998
  "tool_denied_ask_rule": "Tool \"{name}\" requires approval (ask rule: \"{pattern}\"). Approve when prompted, or stop being asked with: /permissions allow \"{pattern}\"",
999
- "tool_denied_mode": "Tool \"{name}\" requires approval under \"{mode}\" mode. Press Shift+Tab to switch permission mode, or stop being asked with: /permissions allow \"{name}\" (or a narrower \"{name}(arg)\")",
1000
- "tool_denied_classifier": "Tool \"{name}\" was blocked in auto mode by the permission classifier ({reason}). This is a policy decision, not a failure — switching modes will not change it, and repeating the identical call returns the same ruling. Change the call so it no longer trips the rule, or allow it outright with: /permissions allow \"{name}\"",
999
+ "tool_denied_mode": "Tool \"{name}\" requires approval under \"{mode}\" mode. Press Shift+Tab to switch permission mode, or stop being asked with: /permissions allow \"{name}\" (or a narrower \"{name}(arg)\") Treat this denial as covering the outcome, not only this exact command.",
1000
+ "tool_denied_classifier": "Tool \"{name}\" was blocked in auto mode by the permission classifier ({reason}). This is a policy decision, not a failure — switching modes will not change it, and repeating the identical call returns the same ruling. Change the call so it no longer trips the rule, or allow it outright with: /permissions allow \"{name}\" Treat this denial as covering the outcome, not only this exact command.",
1001
+ "tool_denied_dangerous_rm": "Tool \"{name}\" was refused: this is a recursive rm whose target is not a path written in the command (\"{target}\"). The target is produced when the command runs, so neither an allow rule nor the permission mode can bound what it deletes. Name the directory literally instead — `rm -rf node_modules` rather than `rm -rf \"$(pwd)\"` — and treat this refusal as covering the outcome, not only this exact spelling. To turn this check off: MIPHAM_DISABLE_DANGEROUS_RM_PROMPT=1",
1001
1002
  "tool_denied_classifier_unavailable": "Tool \"{name}\" was held back because the auto-mode classifier could not be reached or its answer could not be read ({reason}). This is NOT a policy decision — the call was never judged, and retrying is appropriate. If this keeps happening, switch modes with Shift+Tab.",
1002
1003
  "tool_blocked": "Tool \"{name}\" blocked by hook",
1003
1004
  "user_input_blocked": "User input blocked by hook.",
@@ -1020,6 +1021,8 @@
1020
1021
  "max_tool_turns": "Max tool-calling turns ({max}) reached. Some tool calls were not executed.",
1021
1022
  "max_tool_turns_warning": "You've reached the maximum of {max} tool-calling rounds. {pending} tool call(s) were not executed. Please summarize what you found so far and any next steps the user should take.",
1022
1023
  "turn_timeout": "Task produced no substantial output for {minutes} minutes and was stopped. Break the task into smaller steps or clarify the next step, then retry.",
1024
+ "turn_truncated": "The response was cut off mid-turn, not finished — the provider stopped at its output limit or the connection closed.",
1025
+ "stop_hook_budget_spent": "The Stop hook asked for more work (\"{reason}\"), but this turn has used all {max} of its tool-calling rounds — stopping here. Send another message to continue.",
1023
1026
  "mcp_not_connected": "Error: MCP server \"{server}\" not connected",
1024
1027
  "mcp_tool_error": "MCP tool error: {error}",
1025
1028
  "mcp_no_connection": "No connection for \"{name}\""
@@ -996,8 +996,9 @@
996
996
  "errors": {
997
997
  "tool_denied_deny_rule": "工具 \"{name}\" 被拒绝规则(\"{pattern}\")阻止。拒绝规则优先于权限模式 — 请改用其他方式,或移除该规则:/permissions remove \"{pattern}\"",
998
998
  "tool_denied_ask_rule": "工具 \"{name}\" 需要批准(ask 规则:\"{pattern}\")。请在提示时批准,或不再询问:/permissions allow \"{pattern}\"",
999
- "tool_denied_mode": "工具 \"{name}\" 在 \"{mode}\" 模式下需要批准。按 Shift+Tab 切换权限模式,或不再询问:/permissions allow \"{name}\"(更窄的 \"{name}(arg)\" 亦可)",
1000
- "tool_denied_classifier": "工具 \"{name}\" 在 auto 模式下被权限分类器拦下({reason})。这是策略裁决、不是故障 —— 切换模式不会改变它,把同一次调用原样重发也只会得到同一个裁决。请改变这次调用使其不再触犯该规则,或直接放行:/permissions allow \"{name}\"",
999
+ "tool_denied_mode": "工具 \"{name}\" 在 \"{mode}\" 模式下需要批准。按 Shift+Tab 切换权限模式,或不再询问:/permissions allow \"{name}\"(更窄的 \"{name}(arg)\" 亦可) 请把这次拒绝理解为对结果本身的禁止,而不只是这一条命令。",
1000
+ "tool_denied_classifier": "工具 \"{name}\" 在 auto 模式下被权限分类器拦下({reason})。这是策略裁决、不是故障 —— 切换模式不会改变它,把同一次调用原样重发也只会得到同一个裁决。请改变这次调用使其不再触犯该规则,或直接放行:/permissions allow \"{name}\" 请把这次拒绝理解为对结果本身的禁止,而不只是这一条命令。",
1001
+ "tool_denied_dangerous_rm": "工具 \"{name}\" 被拒绝:这是一条递归 rm,其目标并非命令里写出的路径(\"{target}\")。该目标要到命令运行时才产生,所以任何放行规则与权限模式都无法界定它会删掉什么。请改为字面写出目录名 —— 用 `rm -rf node_modules` 而不是 `rm -rf \"$(pwd)\"` —— 并把这次拒绝理解为对结果本身的禁止,而不只是这一种写法。关闭此检查:MIPHAM_DISABLE_DANGEROUS_RM_PROMPT=1",
1001
1002
  "tool_denied_classifier_unavailable": "工具 \"{name}\" 被暂缓:auto 模式的分类器不可达,或其回复无法解析({reason})。这**不是**策略裁决 —— 该调用从未被裁决,重试是恰当的。若持续发生,按 Shift+Tab 切换模式。",
1002
1003
  "tool_blocked": "工具 \"{name}\" 被钩子拦截",
1003
1004
  "user_input_blocked": "用户输入被钩子拦截。",
@@ -1020,6 +1021,8 @@
1020
1021
  "max_tool_turns": "超出最大工具调用轮次({max})。部分工具调用未执行。",
1021
1022
  "max_tool_turns_warning": "已达到最大 {max} 轮工具调用。{pending} 个工具调用未执行。请总结当前发现及后续步骤。",
1022
1023
  "turn_timeout": "任务已超过 {minutes} 分钟无实质产出,已自动停止。建议拆分任务或明确下一步后重新发起。",
1024
+ "turn_truncated": "回复写到一半就断了,不是正常写完 —— 提供商到了输出上限,或连接被关闭。",
1025
+ "stop_hook_budget_spent": "Stop 钩子要求继续({reason}),但本轮 {max} 轮工具调用已用尽 —— 就此停下。再发一条消息即可继续。",
1023
1026
  "mcp_not_connected": "错误:MCP 服务器 \"{server}\" 未连接",
1024
1027
  "mcp_tool_error": "MCP 工具错误:{error}",
1025
1028
  "mcp_no_connection": "未找到 \"{name}\" 的连接"
package/src/index.tsx CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  discoverSessions,
22
22
  } from './agent/cross-session/discovery'
23
23
  import { bootstrapProviders } from './providers/bootstrap'
24
- import { InstructionsLoader } from './core/instructions'
24
+ import { InstructionsLoader, formatInstructionSizeNotice } from './core/instructions'
25
25
  import { loadSessionMemories, getMemoryManager } from './core/memory/memory-loader'
26
26
  import { ContextManager } from './core/context'
27
27
  import { PrefixCacheTracker } from './core/context-token'
@@ -44,6 +44,7 @@ import { mountLlm, LLM_KEY } from './providers/llm'
44
44
  import { mountConstitution, createConstitution } from './core/constitution-seam'
45
45
  import { ConstitutionLoader } from './core/constitution-loader'
46
46
  import { McpClient } from './mcp/client'
47
+ import { buildMcpInstructionsBlock } from './mcp/instructions'
47
48
  import { registerMcpServerTools, syncMcpToolsOnChange } from './mcp/registry'
48
49
  import { formatMcpConnectFailures, type McpConnectFailure } from './mcp/connect-failures'
49
50
  import { AgentRegistry } from './agent/agent-registry'
@@ -58,7 +59,7 @@ import { getWorkspaceTrust, warnProjectHooksSkipped } from './core/workspace-tru
58
59
  import { ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
59
60
  import { artifactsRoot } from './artifacts/paths'
60
61
  import { AgentViewManager } from './agent-view/agent-view-manager'
61
- import { AgentViewDashboard } from './agent-view/dashboard'
62
+ import { AgentsStandalone } from './agent-view/agents-standalone'
62
63
  import { createT } from './i18n-core/t'
63
64
  import { detectLocale } from './i18n-core/detect'
64
65
  import { I18nProvider } from './i18n-context'
@@ -380,11 +381,7 @@ export async function runApp(options: RunOptions): Promise<void> {
380
381
  if (args[0] === 'agents') {
381
382
  const agentViewManager = new AgentViewManager()
382
383
  const { waitUntilExit } = render(
383
- <AgentViewDashboard
384
- manager={agentViewManager}
385
- onAttach={() => {}}
386
- onExit={() => process.exit(0)}
387
- />,
384
+ <AgentsStandalone manager={agentViewManager} onExit={() => process.exit(0)} />,
388
385
  { exitOnCtrlC: false },
389
386
  )
390
387
  await waitUntilExit()
@@ -476,6 +473,11 @@ export async function runApp(options: RunOptions): Promise<void> {
476
473
  const instructions = new InstructionsLoader()
477
474
  instructions.loadAll(process.cwd())
478
475
 
476
+ // 指令体积提示:**按总量**说话(单文件都不大也可能一起挤占上下文)。
477
+ // 计数读的是真正会被发出去的那份投影(见 `instructionPartText`),不是磁盘上的字节。
478
+ const sizeNotice = formatInstructionSizeNotice(instructions.sizeReport())
479
+ if (sizeNotice) console.log(`\n${sizeNotice}\n`)
480
+
479
481
  // 空目录提示:愣建文件夹时温和提醒走 mipham init,而非默默开始(寒暄克制——一句即可)
480
482
  const { isEmptyProject } = await import('./core/project-scaffold')
481
483
  if (isEmptyProject(process.cwd())) {
@@ -555,6 +557,14 @@ export async function runApp(options: RunOptions): Promise<void> {
555
557
  // 这里接的是 live `permission` —— 同一实例也交给引擎去执行,故下一次请求就与新档一致。
556
558
  context.setPermissionContextSource(() => instructions.buildPermissionBlock(permission.getMode()))
557
559
 
560
+ // MCP server 自带的 `instructions` 走同一条读时派生的路,理由比权限段更硬:server 是**启动后
561
+ // 异步连上**的(见下方 `connectMcpServers`),而提示在这一刻就建好了。组装时烘进去的话,
562
+ // 本次会话里后连上的 server 永远进不了提示 —— 用户只能重启。读 `/listConnections()` 是
563
+ // live 的,连上一个下一次请求就带上。
564
+ context.setMcpInstructionsSource(() =>
565
+ buildMcpInstructionsBlock(McpClient.getInstance().listConnections()),
566
+ )
567
+
558
568
  // Adaptive memory budget: scale with model's context window
559
569
  getMemoryManager().setContextWindow(modelContextWindow)
560
570