@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.
- package/README.md +9 -9
- package/bin/daemon.ts +7 -32
- package/bin/mipham.ts +43 -29
- package/package.json +5 -2
- package/skills/standard/mipham-code-setup.SKILL.md +3 -3
- package/src/agent/sub-agent.ts +12 -1
- package/src/commands/project.ts +92 -12
- package/src/config/keys-manager.ts +3 -3
- package/src/config/loader.ts +82 -1
- package/src/core/context.ts +10 -2
- package/src/core/engine.ts +19 -4
- package/src/core/metrics.ts +8 -0
- package/src/core/paths.ts +79 -0
- package/src/core/permission-rules.ts +121 -13
- package/src/core/permission.ts +3 -0
- package/src/core/session-log.ts +11 -2
- package/src/daemon/engine-capabilities.ts +131 -0
- package/src/daemon/index.ts +4 -1
- package/src/daemon/launch.ts +287 -0
- package/src/daemon/remote-engine.ts +2 -0
- package/src/daemon/server.ts +9 -0
- package/src/daemon/session-worker.ts +7 -4
- package/src/i18n-core/locales/en-US.json +6 -7
- package/src/i18n-core/locales/zh-CN.json +6 -7
- package/src/index.tsx +79 -0
- package/src/mcp/client.ts +4 -2
- package/src/providers/anthropic.ts +2 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +15 -0
- package/src/skills/bundled-skills.ts +1 -1
- package/src/telemetry/consent.ts +209 -0
- package/src/telemetry/crash.ts +197 -0
- package/src/telemetry/endpoint.ts +82 -0
- package/src/telemetry/index.ts +153 -0
- package/src/telemetry/payload.ts +141 -0
- package/src/telemetry/queue.ts +95 -0
- package/src/telemetry/redact.ts +127 -0
- package/src/telemetry/transport.ts +81 -0
- package/src/tools/agent/workflow.ts +11 -4
- package/src/tools/exec/bash.ts +6 -4
- package/src/tools/exec/enter-worktree.ts +6 -5
- package/src/tools/exec/exit-worktree.ts +10 -5
- package/src/tools/exec/git.ts +18 -8
- package/src/tools/system/config.ts +3 -3
- package/src/ui/app.tsx +40 -11
- package/src/ui/commands.ts +159 -34
- package/src/workflow/primitives/agent.ts +4 -2
- package/src/core/task-runner-tasks.json +0 -14
- package/src/core/task-runner.ts +0 -163
- package/src/skills/mipham/runtime.ts +0 -66
- package/src/skills/standard/runtime.ts +0 -62
package/src/core/engine.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
1388
|
-
|
|
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
|
}
|
package/src/core/metrics.ts
CHANGED
|
@@ -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
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
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[]): {
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
}
|
package/src/core/permission.ts
CHANGED
|
@@ -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: {
|
package/src/core/session-log.ts
CHANGED
|
@@ -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
|
-
|
|
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: [
|
|
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
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|