@miphamai/cli 0.81.6 → 0.81.7

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.
Files changed (51) hide show
  1. package/README.md +9 -9
  2. package/bin/daemon.ts +7 -32
  3. package/bin/mipham.ts +43 -29
  4. package/package.json +5 -2
  5. package/skills/standard/mipham-code-setup.SKILL.md +3 -3
  6. package/src/agent/sub-agent.ts +12 -1
  7. package/src/commands/project.ts +92 -12
  8. package/src/config/keys-manager.ts +3 -3
  9. package/src/config/loader.ts +82 -1
  10. package/src/core/context.ts +10 -2
  11. package/src/core/engine.ts +19 -4
  12. package/src/core/metrics.ts +8 -0
  13. package/src/core/paths.ts +79 -0
  14. package/src/core/permission-rules.ts +121 -13
  15. package/src/core/permission.ts +3 -0
  16. package/src/core/session-log.ts +11 -2
  17. package/src/daemon/engine-capabilities.ts +131 -0
  18. package/src/daemon/index.ts +4 -1
  19. package/src/daemon/launch.ts +287 -0
  20. package/src/daemon/remote-engine.ts +2 -0
  21. package/src/daemon/server.ts +9 -0
  22. package/src/daemon/session-worker.ts +7 -4
  23. package/src/i18n-core/locales/en-US.json +6 -7
  24. package/src/i18n-core/locales/zh-CN.json +6 -7
  25. package/src/index.tsx +79 -0
  26. package/src/mcp/client.ts +4 -2
  27. package/src/providers/anthropic.ts +2 -0
  28. package/src/shared/package-info.ts +1 -1
  29. package/src/shared/types.ts +15 -0
  30. package/src/skills/bundled-skills.ts +1 -1
  31. package/src/telemetry/consent.ts +209 -0
  32. package/src/telemetry/crash.ts +197 -0
  33. package/src/telemetry/endpoint.ts +82 -0
  34. package/src/telemetry/index.ts +153 -0
  35. package/src/telemetry/payload.ts +141 -0
  36. package/src/telemetry/queue.ts +95 -0
  37. package/src/telemetry/redact.ts +127 -0
  38. package/src/telemetry/transport.ts +81 -0
  39. package/src/tools/agent/workflow.ts +11 -4
  40. package/src/tools/exec/bash.ts +6 -4
  41. package/src/tools/exec/enter-worktree.ts +6 -5
  42. package/src/tools/exec/exit-worktree.ts +10 -5
  43. package/src/tools/exec/git.ts +18 -8
  44. package/src/tools/system/config.ts +3 -3
  45. package/src/ui/app.tsx +40 -11
  46. package/src/ui/commands.ts +159 -34
  47. package/src/workflow/primitives/agent.ts +4 -2
  48. package/src/core/task-runner-tasks.json +0 -14
  49. package/src/core/task-runner.ts +0 -163
  50. package/src/skills/mipham/runtime.ts +0 -66
  51. package/src/skills/standard/runtime.ts +0 -62
@@ -701,6 +701,7 @@ export class QueryEngine {
701
701
  type: 'tool_result',
702
702
  tool_use_id: toolUse.id,
703
703
  content: result.success ? result.content : result.error || result.content,
704
+ isError: !result.success,
704
705
  }
705
706
 
706
707
  // Collect tool call record for CRSI auto-reflection
@@ -1040,7 +1041,10 @@ export class QueryEngine {
1040
1041
  yield {
1041
1042
  type: 'tool_result',
1042
1043
  tool_use_id: toolUse.id,
1043
- content: result.content,
1044
+ // 失败结果的 `content` 是空串(错误在 `error` 里,见 executeTool 的拒绝分支)
1045
+ // —— 直接发 `content` 会让模型收到一个**空** tool_result,错误文案整个丢失。
1046
+ content: result.success ? result.content : result.error || result.content,
1047
+ isError: !result.success,
1044
1048
  }
1045
1049
 
1046
1050
  // DeepSeek V4 thinking mode requires reasoning_content on every assistant message
@@ -1333,7 +1337,15 @@ export class QueryEngine {
1333
1337
  return this.llm
1334
1338
  }
1335
1339
 
1336
- /** 注入 LLM 适配缝(换 chat 实现)。 */
1340
+ /**
1341
+ * 注入 LLM 适配缝(换 chat 实现)。
1342
+ *
1343
+ * 不变量:**缝 = 与 `registry` 不同的对象**。`chatWithFallback` 靠
1344
+ * `this.llm !== this.registry` 判定「这个缝是否拥有整个 chat 流程」——
1345
+ * 而生产路径注入的恰恰就是 registry 本身(`providers/llm.ts` 的 `mountLlm`
1346
+ * 是原样 `provide(LLM_KEY, llm)`,`index.tsx` 把 `registry` 传了进去)。
1347
+ * 若改成「非空即缝」,provider 回退分支就永远走不到,回退只活在测试里。
1348
+ */
1337
1349
  setLlm(llm: Llm): void {
1338
1350
  this.llm = llm
1339
1351
  }
@@ -1384,8 +1396,11 @@ export class QueryEngine {
1384
1396
  }
1385
1397
 
1386
1398
  // ── Fallback: configured default provider, once ──
1387
- // 若已注入 Llm 缝,缝拥有整个 chat 流程——不回退(避免切 registry 状态 + 二次调用)
1388
- if (this.llm) {
1399
+ // 若注入了**异己**的 Llm 缝,缝拥有整个 chat 流程——不回退(避免切 registry 状态 + 二次调用)。
1400
+ // `!== this.registry` 不可省:生产路径注入的正是 registry 自己(`index.tsx` →
1401
+ // `mountLlm(vajraContext, registry)` → `setLlm`),按「非空即缝」判定会让本分支
1402
+ // 在生产恒不可达,而测试里构造引擎时不注入缝 ⇒ 套件全绿也发现不了。
1403
+ if (this.llm && this.llm !== this.registry) {
1389
1404
  yield { type: 'error', error: failure }
1390
1405
  return
1391
1406
  }
@@ -278,6 +278,9 @@ export class MetricsRegistry {
278
278
  /** Tool call counter, labelled by tool_name. Callers use .inc({tool_name}). */
279
279
  readonly toolCalls: Counter
280
280
 
281
+ /** Slash-command counter, labelled by command_name. Callers use .inc({command_name}). */
282
+ readonly commandCalls: Counter
283
+
281
284
  /** Model API request counter, labelled by provider and model. */
282
285
  readonly modelRequests: Counter
283
286
 
@@ -308,6 +311,11 @@ export class MetricsRegistry {
308
311
 
309
312
  this.toolCalls = this.counter('mipham_code_tool_calls_total', 'Number of tool invocations')
310
313
 
314
+ this.commandCalls = this.counter(
315
+ 'mipham_code_command_calls_total',
316
+ 'Number of slash-command invocations',
317
+ )
318
+
311
319
  this.modelRequests = this.counter(
312
320
  'mipham_code_model_requests_total',
313
321
  'Number of model API requests',
@@ -0,0 +1,79 @@
1
+ /**
2
+ * 项目内数据目录的路径单一真源。
3
+ *
4
+ * 写入一律落在 `.mipham/`(我们自己的目录);`.claude/` 只保留**只读兼容** ——
5
+ * 早期版本把 worktree 建在 `.claude/worktrees/` 下,那些工作树今天仍要可列举、
6
+ * 可退出、可隔离。因此隔离判据必须同时认两个前缀:只认新前缀会让旧工作树
7
+ * 突然失去隔离保护(隔离度只许增不许减)。
8
+ */
9
+
10
+ import { homedir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import { MIPHAM_DIR } from '../shared/constants.ts'
13
+
14
+ /** 只读兼容目录名。 */
15
+ export const LEGACY_CLAUDE_DIR = '.claude'
16
+
17
+ /**
18
+ * worktree 的识别标记(含结尾斜杠),新前缀在前。
19
+ * 用于从 `cwd` 反推 worktree 所属的项目根。
20
+ */
21
+ export const WORKTREE_MARKERS = [
22
+ `${MIPHAM_DIR}/worktrees/`,
23
+ `${LEGACY_CLAUDE_DIR}/worktrees/`,
24
+ ] as const
25
+
26
+ /** 新建 worktree 的根目录(绝对路径)。 */
27
+ export function worktreeRoot(cwd: string): string {
28
+ return join(cwd, MIPHAM_DIR, 'worktrees')
29
+ }
30
+
31
+ /** 历史与当前的全部 worktree 根目录,写入根在前。 */
32
+ export function worktreeRoots(cwd: string): string[] {
33
+ return [worktreeRoot(cwd), join(cwd, LEGACY_CLAUDE_DIR, 'worktrees')]
34
+ }
35
+
36
+ /**
37
+ * 在 `cwd` 中定位 worktree 标记,返回项目根与命中的标记。
38
+ * 不在任何 worktree 内时返回 null。
39
+ */
40
+ /**
41
+ * Locate the project root by looking for a worktree marker in `cwd`.
42
+ *
43
+ * The returned `root` has no trailing separator: callers compose it as
44
+ * `root + '/'` for a prefix compare, and a trailing slash there would make
45
+ * the pattern `${root}//` match nothing.
46
+ */
47
+ export function findWorktreeMarker(cwd: string): { root: string; marker: string } | null {
48
+ for (const marker of WORKTREE_MARKERS) {
49
+ const index = cwd.indexOf(marker)
50
+ if (index !== -1) return { root: cwd.substring(0, index).replace(/\/+$/, ''), marker }
51
+ }
52
+ return null
53
+ }
54
+
55
+ /**
56
+ * 新建 workflow 脚本的目录(绝对路径)。与 worktree 同理:写入落在 `.mipham/`。
57
+ *
58
+ * 注意别与**运行产物**目录混淆:`~/.mipham/workflows/<runId>/`(见
59
+ * `workflow/journal.ts`)同名但不同义,装的是 journal 与转录。两者靠「非递归
60
+ * readdir + 只收 .js」区分,属巧合而非设计,故本函数绝不返回那个根。
61
+ */
62
+ export function workflowScriptDir(cwd: string): string {
63
+ return join(cwd, MIPHAM_DIR, 'workflows')
64
+ }
65
+
66
+ /**
67
+ * 全部可读的 workflow 脚本目录,写入根在前。
68
+ *
69
+ * 读侧必须同时认新旧前缀,否则升级后第一次 `/workflow save` 会失败 ——
70
+ * 上一次运行的 `.last-run.json` 还在旧目录里。用户级旧前缀也保留在列,
71
+ * 但**没有**对应的 `~/.mipham/workflows` 用户级脚本根:那里是运行产物的地盘。
72
+ */
73
+ export function workflowScriptDirs(cwd: string): string[] {
74
+ return [
75
+ workflowScriptDir(cwd),
76
+ join(cwd, LEGACY_CLAUDE_DIR, 'workflows'),
77
+ join(homedir(), LEGACY_CLAUDE_DIR, 'workflows'),
78
+ ]
79
+ }
@@ -137,6 +137,34 @@ const PREFIX_VALUE_OPTIONS = new Set([
137
137
  '--max-procs', // xargs
138
138
  ])
139
139
 
140
+ /**
141
+ * Shell interpreters whose `-c` argument is itself a complete command line.
142
+ *
143
+ * Deliberately NOT added to PREFIX_COMMANDS: that table shares
144
+ * PREFIX_VALUE_OPTIONS, where `-s` means `timeout --signal` — a shell's option
145
+ * grammar (`-c`, merged clusters like `-lc`, `-o <name>`) has nothing in common
146
+ * with a wrapper's value-taking options, and its payload is a *new command line*
147
+ * to re-parse rather than "the real command".
148
+ *
149
+ * This table is a security surface, not a compatibility surface: every name
150
+ * added is another matching path. Deliberately excluded — interpreters that
151
+ * evaluate *another language* (`node -e`, `python -c`, `awk`), remote execution
152
+ * (`ssh host '…'`, `docker exec`), script files (`bash x.sh`, `source x.sh`,
153
+ * whose payload is a file rather than a command line), and `find -exec`.
154
+ */
155
+ const SHELL_COMMANDS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'ash'])
156
+
157
+ /** Shell short-option letters that consume the next token as a value (`-o pipefail`). */
158
+ const SHELL_VALUE_LETTERS = 'o'
159
+
160
+ /** Shell long options that consume the next token as a value. */
161
+ const SHELL_VALUE_OPTIONS = new Set(['--init-file', '--rcfile'])
162
+
163
+ /** How many `-c` / `$()` payload levels are re-parsed. Shared by both recursion
164
+ * paths — a single counter is what makes the bound hold; separate counters
165
+ * would let `bash -c 'bash -c "$(…)"'` alternate past it. */
166
+ const MAX_COMMAND_DEPTH = 5
167
+
140
168
  /**
141
169
  * Split a (possibly compound) shell command into simple-command segments, so a
142
170
  * Bash(pattern) rule matches any segment rather than only the whole string
@@ -221,20 +249,28 @@ function extractSubstitutions(command: string): string[] {
221
249
 
222
250
  /**
223
251
  * Flatten a command into every matchable sub-command: its shell segments plus
224
- * the commands nested inside `$(...)`/backtick substitutions (recursively). So
225
- * a `Bash(rm *)` deny rule also matches `REPORTTIME=$(rm -rf ~)` — zsh evaluates
226
- * substitutions in REPORTTIME/REPORTMEMORY/DIRSTACKSIZE assignments immediately.
227
- * Over-matching is the safe direction for a deny rule.
252
+ * the commands nested inside `$(...)`/backtick substitutions and shell `-c`
253
+ * payloads (recursively, bounded by MAX_COMMAND_DEPTH). So a `Bash(rm *)` deny
254
+ * rule also matches `REPORTTIME=$(rm -rf ~)` — zsh evaluates substitutions in
255
+ * REPORTTIME/REPORTMEMORY/DIRSTACKSIZE assignments immediately — and
256
+ * `bash -c 'rm -rf /'`. Over-matching is the safe direction for a deny rule.
228
257
  */
229
- function flattenCommand(command: string): string[] {
258
+ function flattenCommand(command: string, depth = 0): string[] {
230
259
  const out: string[] = []
231
260
  for (const seg of splitShellSegments(command)) {
232
261
  out.push(seg)
233
262
  const stripped = stripPrefixCommand(seg)
234
263
  if (stripped !== seg) out.push(stripped)
235
264
  for (const inner of extractSubstitutions(seg)) {
236
- out.push(...flattenCommand(inner))
265
+ out.push(...flattenCommand(inner, depth + 1))
237
266
  }
267
+ // A shell `-c` payload is a command line in its own right, so `Bash(rm *)`
268
+ // must also match `bash -c 'rm -rf /'`. Past the depth bound recursion stops
269
+ // outright rather than pushing the raw payload: the raw text
270
+ // (`bash -c 'rm -rf /'`) matches no `rm …` pattern anyway, and stopping
271
+ // keeps the boundary predictable.
272
+ const { payload } = effectiveCommand(seg.split(/\s+/).filter(Boolean))
273
+ if (payload && depth < MAX_COMMAND_DEPTH) out.push(...flattenCommand(payload, depth + 1))
238
274
  }
239
275
  return out
240
276
  }
@@ -248,8 +284,13 @@ function flattenCommand(command: string): string[] {
248
284
  * always treated as the command (never skipped) — which over-matches, the safe
249
285
  * direction for a deny rule.
250
286
  */
251
- function effectiveCommand(tokens: string[]): { base: string; args: string[] } {
287
+ function effectiveCommand(tokens: string[]): {
288
+ base: string
289
+ args: string[]
290
+ payload: string | null
291
+ } {
252
292
  let i = 0
293
+ let payload: string | null = null
253
294
  while (i < tokens.length) {
254
295
  const name = (tokens[i] || '').split('/').pop() || ''
255
296
  if (!PREFIX_COMMANDS.has(name)) break
@@ -263,9 +304,65 @@ function effectiveCommand(tokens: string[]): { base: string; args: string[] } {
263
304
  while (i < tokens.length && tokens[i]!.includes('=')) i++ // `VAR=value` assignments
264
305
  }
265
306
  if (name === 'timeout') i++ // positional duration
307
+ // `eval`'s remaining arguments are themselves a command line, and must be
308
+ // captured here rather than via the SHELL_COMMANDS branch below: `eval "cat
309
+ // secret"` tokenizes to ['eval', '"cat', 'secret"'], so the base degrades to
310
+ // `"cat` — a name no command set contains. The quotes land on *different*
311
+ // tokens, so de-quoting a single token cannot recover it either.
312
+ if (name === 'eval' && payload === null && i < tokens.length) {
313
+ payload = stripQuotes(tokens.slice(i).join(' '))
314
+ }
266
315
  }
267
- if (i >= tokens.length) return { base: '', args: [] }
268
- return { base: (tokens[i] || '').split('/').pop() || '', args: tokens.slice(i + 1) }
316
+ if (i >= tokens.length) return { base: '', args: [], payload }
317
+ const base = (tokens[i] || '').split('/').pop() || ''
318
+ if (payload === null && SHELL_COMMANDS.has(base)) payload = shellPayload(tokens, i)
319
+ return { base, args: tokens.slice(i + 1), payload }
320
+ }
321
+
322
+ /**
323
+ * The command string a shell runs via `-c` (`bash -c 'cat X'`). Only `-c` yields
324
+ * a nested command line — a script-file operand (`bash x.sh`) does not — so the
325
+ * scan stops at the first non-flag token, per getopt: `bash script.sh -c foo` is
326
+ * NOT a payload. Short options are walked as a cluster because shells accept
327
+ * `-lc` / `-xc` / `-euo pipefail`, which a flat option table (PREFIX_VALUE_OPTIONS)
328
+ * can never match.
329
+ *
330
+ * The payload is reassembled from every remaining token rather than taken from
331
+ * the one right after `-c`: it is a single shell word that routinely contains
332
+ * spaces (`'while read f; do cat "$f"; done'`), so whitespace tokenization has
333
+ * split it apart.
334
+ */
335
+ function shellPayload(tokens: string[], start: number): string | null {
336
+ let i = start + 1
337
+ while (i < tokens.length) {
338
+ const t = tokens[i]!
339
+ if (!/^[-+]/.test(t) || t === '-' || t === '+') return null // first operand ends option parsing
340
+ if (t === '--') return null
341
+ if (t.startsWith('--')) {
342
+ i += SHELL_VALUE_OPTIONS.has(t) ? 2 : 1
343
+ continue
344
+ }
345
+ const body = t.slice(1)
346
+ let consumed = 0
347
+ let inline: string | null = null
348
+ for (let k = 0; k < body.length; k++) {
349
+ const letter = body[k]!
350
+ if (letter === 'c') {
351
+ inline = body.slice(k + 1) // `-c'cat X'` attaches the payload to the flag
352
+ break
353
+ }
354
+ if (SHELL_VALUE_LETTERS.includes(letter)) {
355
+ if (k + 1 >= body.length) consumed = 1 // value is the next token
356
+ break
357
+ }
358
+ }
359
+ if (inline !== null) {
360
+ const tail = tokens.slice(i + 1).join(' ')
361
+ return stripQuotes([inline, tail].filter(Boolean).join(' ')) || null
362
+ }
363
+ i += 1 + consumed
364
+ }
365
+ return null
269
366
  }
270
367
 
271
368
  /** A command with any wrapper prefix commands stripped, so `sudo rm -rf /`
@@ -281,13 +378,19 @@ function stripPrefixCommand(command: string): string {
281
378
 
282
379
  /**
283
380
  * Detect reader/writer commands at the front of each shell segment and recurse
284
- * into command substitutions, so `echo $(cat .git-credentials)` is caught.
381
+ * into command substitutions and shell `-c` payloads, so both
382
+ * `echo $(cat .git-credentials)` and `bash -c 'cat .git-credentials'` are caught.
285
383
  */
286
- function scanReaderWriterCommands(command: string, read: string[], write: string[]): void {
384
+ function scanReaderWriterCommands(
385
+ command: string,
386
+ read: string[],
387
+ write: string[],
388
+ depth = 0,
389
+ ): void {
287
390
  for (const seg of splitShellSegments(command)) {
288
391
  const tokens = seg.split(/\s+/).filter(Boolean)
289
392
  if (tokens.length > 0) {
290
- const { base, args } = effectiveCommand(tokens)
393
+ const { base, args, payload } = effectiveCommand(tokens)
291
394
  if (READER_COMMANDS.has(base)) {
292
395
  // `sed -i` / `perl -i` read AND write their file args.
293
396
  const inPlace = args.some((a) => a === '-i' || a.startsWith('--in-place'))
@@ -303,9 +406,14 @@ function scanReaderWriterCommands(command: string, read: string[], write: string
303
406
  write.push(stripQuotes(arg))
304
407
  }
305
408
  }
409
+ // Re-parse a shell `-c` payload: `bash -c 'cat secret'` reads `secret`
410
+ // just as directly as `cat secret` does.
411
+ if (payload && depth < MAX_COMMAND_DEPTH) {
412
+ scanReaderWriterCommands(payload, read, write, depth + 1)
413
+ }
306
414
  }
307
415
  for (const inner of extractSubstitutions(seg)) {
308
- scanReaderWriterCommands(inner, read, write)
416
+ scanReaderWriterCommands(inner, read, write, depth + 1)
309
417
  }
310
418
  }
311
419
  }
@@ -215,14 +215,17 @@ export class PermissionSystem {
215
215
 
216
216
  allow(rule: string): void {
217
217
  this.allowRules.push(compileRule(rule, 'allow'))
218
+ this.invalidateCache()
218
219
  }
219
220
 
220
221
  deny(rule: string): void {
221
222
  this.denyRules.push(compileRule(rule, 'deny'))
223
+ this.invalidateCache()
222
224
  }
223
225
 
224
226
  ask(rule: string): void {
225
227
  this.askRules.push(compileRule(rule, 'ask'))
228
+ this.invalidateCache()
226
229
  }
227
230
 
228
231
  loadConfig(raw: {
@@ -36,7 +36,8 @@ export function messageToEvents(msg: Message, at = 0): SessionEvent[] {
36
36
  type: 'tool/result',
37
37
  at,
38
38
  id: r.tool_use_id,
39
- result: { success: true, content: r.content },
39
+ // 读真值:旧消息无 is_error ⇒ 视为成功(向后兼容,无需迁移)
40
+ result: { success: !(r.is_error === true), content: r.content },
40
41
  },
41
42
  ]
42
43
  }
@@ -80,7 +81,15 @@ export function deriveMessages(events: SessionEvent[]): Message[] {
80
81
  const content = result.success ? result.content : result.error || result.content
81
82
  out.push({
82
83
  role: 'user',
83
- content: [{ type: 'tool_result', tool_use_id: e.id, content }],
84
+ content: [
85
+ {
86
+ type: 'tool_result',
87
+ tool_use_id: e.id,
88
+ content,
89
+ // 展平式的对称边:成功不写该键(与 messageToEvents 侧对称,保字节级互逆)
90
+ ...(result.success ? {} : { is_error: true }),
91
+ },
92
+ ],
84
93
  })
85
94
  } else if (e.type === 'context/inject') {
86
95
  out.push({ role: 'user', content: e.text })
@@ -0,0 +1,131 @@
1
+ /**
2
+ * daemon 侧的引擎能力接线 —— 「两条渲染路径只接一条」的收口(ROADMAP T5)。
3
+ *
4
+ * 交互式 CLI 的装配内联在 `index.tsx`(约 300 行,含 TUI 专属件),而 daemon 这条
5
+ * 路径此前**一个 setter 都没接**。后果分三档,且都不报错:
6
+ *
7
+ * - `setSkills` 缺 ⇒ `createToolRegistry()` 无参调用照样把 `Skill` 工具挂进了注册表,
8
+ * 但工具上下文里没有 loader ⇒ 那个工具**每次调用都返回错误**(模型被广告了一个
9
+ * 永远失败的工具)。
10
+ * - `setRulesLoader` / `setHookEngine` / `setAgentRegistry` 缺 ⇒ 静默退化:
11
+ * `injectRules()` 永远早退、hooks 全不跑、自定义 agent 解析不到(`agent.ts` 里是
12
+ * `?.` 可选链,连警告都没有)。
13
+ * - `setLlm` 是**反例**:daemon 原本没有缝,`llmChat` 回退 registry、provider 回退
14
+ * 照常工作。所以顺序不可颠倒 —— 先修 `chatWithFallback` 的判据(见该处注释),
15
+ * 再接,否则「对等」会削掉 daemon 唯一还活着的回退。
16
+ *
17
+ * 这类缺口 lint / typecheck / 覆盖率 / 安全审计**全部看不见**(上一次同类事故是
18
+ * `rules-loader`,定义后潜伏数月,靠覆盖率实测才挖出来)。所以这里不只接线,还把
19
+ * daemon 该有的能力集中到**一个**装配点,让「再加一个能力忘了接 daemon」变成
20
+ * `test/integrity/daemon-capability-parity.test.ts` 能咬住的源码差异,而不是靠人记得。
21
+ *
22
+ * 不搬 `index.tsx` 的整套装配:ArtifactServer 是 TUI 画廊的 localhost 监听器、
23
+ * AgentViewManager 的唯一消费者是 TUI slash 命令。这里只接**有后果的四条**加
24
+ * `setLlm`;其余按后果分级进守卫的具名豁免表,逐条写理由。
25
+ */
26
+
27
+ import type { QueryEngine } from '../core/engine'
28
+ import type { ProviderRegistry } from '../providers/registry'
29
+ import { SkillsLoader } from '../skills/loader'
30
+ import { RulesLoader } from '../core/rules-loader'
31
+ import { HookEngine } from '../core/hooks'
32
+ import { loadHookConfigs } from '../core/hooks-config'
33
+ import { loadSettingsJson } from '../config/loader'
34
+ import { AgentRegistry } from '../agent/agent-registry'
35
+
36
+ export interface DaemonEngineCapabilities {
37
+ /** 会话工作目录 —— 三者的派生源(外部 skill 路径 / hooks / agents 都从它算)。 */
38
+ cwd: string
39
+ /** daemon 共享的 provider registry,注入为 LLM 缝。 */
40
+ registry: ProviderRegistry
41
+ /** `config.skills.paths`(外部 skill 目录)。省略即只加载内置与用户 skill。 */
42
+ skillsPaths?: string[]
43
+ }
44
+
45
+ /**
46
+ * 按 cwd 记忆化。
47
+ *
48
+ * daemon 是一个长命进程服务多个会话、cwd 各不相同,而 skills / hooks / agents 三者
49
+ * 都从 cwd 派生(`config.skills.paths` / `<cwd>/.mipham/settings.json` /
50
+ * `<cwd>/.mipham/agents`)。每次 `getOrCreateEngine` 都重建会重复读盘与解析。
51
+ *
52
+ * 缓存是**有界**的:cwd 必须先过 `isCwdAllowed`(在 daemon 根之内,或在用户信任
53
+ * 列表里 —— 见 `workspace-guard.ts`),不是调用方随便给的路径。
54
+ *
55
+ * 代价已认下:表活到进程结束,所以改 `~/.mipham/settings.json` 或项目 agents 要
56
+ * **重启 daemon** 才生效。CLI 是一次性进程,没这个问题 —— 不做 per-session rebuild,
57
+ * 真要失效化另立条目。
58
+ */
59
+ const skillsCache = new Map<string, SkillsLoader>()
60
+ const hookCache = new Map<string, HookEngine>()
61
+ const agentCache = new Map<string, AgentRegistry>()
62
+
63
+ function skillsFor(cwd: string, paths?: string[]): SkillsLoader {
64
+ const cached = skillsCache.get(cwd)
65
+ if (cached) return cached
66
+ const loader = new SkillsLoader()
67
+ // 内置 skill 从包内解析(`import.meta.dirname`),与 cwd 无关;用户 skill 在
68
+ // `~/.mipham/skills`。三者里只有外部路径随 cwd 变。
69
+ loader.loadBuiltinFromPackage()
70
+ loader.loadUserSkills()
71
+ if (paths && paths.length > 0) loader.loadExternal(paths)
72
+ skillsCache.set(cwd, loader)
73
+ return loader
74
+ }
75
+
76
+ /**
77
+ * skill 自带的 hooks 先注册,`settings.json` 的随后 —— 与 `index.tsx:585-598` 同序。
78
+ *
79
+ * 内置 skill 当前**一个都不声明 hooks**(实测 grep 为 0),所以这条只在用户自装
80
+ * skill 上生效。留着是为了两入口的行为集合真的相等:少了它,装了带 hooks 的 skill
81
+ * 的用户在 daemon 里会静默少跑一半 hooks —— 正是本项要消灭的那类缺口。
82
+ *
83
+ * 注意 `settings.json` 是**仓库可控**的文件(`<cwd>/.mipham/settings.json`),
84
+ * 其 hooks 会 spawn shell。这与 CLI 同构,但 daemon 的会话可被远程渠道调用者驱动,
85
+ * 属本次已认下的后果,见 ROADMAP T5 落地结果。
86
+ */
87
+ function hooksFor(cwd: string, skills: SkillsLoader): HookEngine {
88
+ const cached = hookCache.get(cwd)
89
+ if (cached) return cached
90
+ const engine = new HookEngine()
91
+ for (const skill of skills.list()) {
92
+ for (const hook of skill.hooks ?? []) engine.register(hook)
93
+ }
94
+ for (const def of loadHookConfigs(loadSettingsJson(cwd).hooks)) engine.register(def)
95
+ hookCache.set(cwd, engine)
96
+ return engine
97
+ }
98
+
99
+ function agentsFor(cwd: string): AgentRegistry {
100
+ const cached = agentCache.get(cwd)
101
+ if (cached) return cached
102
+ const registry = new AgentRegistry()
103
+ registry.loadUserAgents()
104
+ registry.loadProjectAgents(cwd)
105
+ agentCache.set(cwd, registry)
106
+ return registry
107
+ }
108
+
109
+ /**
110
+ * 把一个 daemon 引擎接成「和交互式 CLI 同等能干活」的样子。
111
+ *
112
+ * 调用点在 `server.ts` 的 `getOrCreateEngine`,紧跟 `setSessionId` —— 引擎建成之后、
113
+ * 进 `engineCache` 之前,保证任何取到引擎的路径都已经接过线。
114
+ */
115
+ export function wireDaemonEngine(engine: QueryEngine, opts: DaemonEngineCapabilities): void {
116
+ const skills = skillsFor(opts.cwd, opts.skillsPaths)
117
+ engine.setSkills(skills)
118
+
119
+ // **每会话新建、不记忆化**:`setRulesLoader` 会顺手 `loader.load()`
120
+ // (`engine.ts:352-355`),而 `load()` 是同步、幂等、先清空再读几个小文件
121
+ // (`rules-loader.ts:42-61`)。共享一只的话,一个长命 daemon 会永远看不见会话
122
+ // 期间新增的规则;不共享的代价只是每次建引擎多读一次目录。
123
+ engine.setRulesLoader(new RulesLoader(opts.cwd))
124
+
125
+ engine.setHookEngine(hooksFor(opts.cwd, skills))
126
+ engine.setAgentRegistry(agentsFor(opts.cwd))
127
+
128
+ // 语义等价于不接(`llmChat` 本就是 `this.llm ?? this.registry`),接上是为了让
129
+ // 两个入口的能力集合真的相等;D2 靠它证明「先修语义再对等」这条顺序约束。
130
+ engine.setLlm(opts.registry)
131
+ }
@@ -276,7 +276,10 @@ export async function stopDaemon(force: boolean = false): Promise<void> {
276
276
 
277
277
  // Stop HTTP server (disconnects WebSocket clients)
278
278
  if (activeServer) {
279
- activeServer.stop()
279
+ // Awaited: the PID/port cleanup below is documented to run regardless of
280
+ // failure, so a rejection must not skip it — but the stop must finish before
281
+ // this function reports the daemon as stopped.
282
+ await activeServer.stop().catch(() => {})
280
283
  activeServer = null
281
284
  }
282
285