@miphamai/cli 0.81.6 → 0.81.8
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/artifacts/manifest.ts +90 -34
- package/src/artifacts/paths.ts +19 -0
- package/src/artifacts/server.ts +48 -8
- package/src/commands/project.ts +92 -12
- package/src/config/keys-manager.ts +10 -11
- package/src/config/loader.ts +82 -1
- package/src/config/preferences.ts +5 -2
- package/src/core/context.ts +10 -2
- package/src/core/cron-poller.ts +30 -6
- 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 +261 -17
- package/src/core/permission.ts +3 -0
- package/src/core/session-log.ts +55 -3
- package/src/core/session-store.ts +11 -1
- 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 +21 -3
- package/src/i18n-core/locales/en-US.json +6 -7
- package/src/i18n-core/locales/zh-CN.json +6 -7
- package/src/index.tsx +82 -2
- package/src/mcp/client.ts +4 -2
- package/src/plugin/plugin-manager.ts +17 -6
- package/src/providers/anthropic.ts +28 -2
- package/src/providers/openai-compat.ts +14 -1
- package/src/security/path.ts +6 -1
- package/src/shared/atomic-write.ts +28 -5
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +24 -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/artifact/artifact.ts +14 -4
- package/src/tools/exec/bash.ts +45 -21
- package/src/tools/exec/enter-worktree.ts +6 -5
- package/src/tools/exec/exit-worktree.ts +10 -5
- package/src/tools/exec/git.ts +25 -10
- package/src/tools/file/grep.ts +37 -13
- package/src/tools/file/read.ts +151 -45
- package/src/tools/scheduling/cron.ts +34 -5
- package/src/tools/system/config.ts +9 -5
- package/src/ui/app.tsx +40 -11
- package/src/ui/commands.ts +186 -45
- package/src/workflow/primitives/agent.ts +4 -2
- package/src/artifacts/versioning.ts +0 -127
- 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/commands/project.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { CommandHandler, CommandContext, CommandResult } from '../ui/commands.js'
|
|
9
9
|
import { getWorkspaceTrust } from '../core/workspace-trust'
|
|
10
10
|
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
11
|
+
import { CLOUD_PROVIDERS } from '../config/wizard-config'
|
|
11
12
|
import { homedir } from 'node:os'
|
|
12
13
|
|
|
13
14
|
export {
|
|
@@ -21,6 +22,18 @@ export {
|
|
|
21
22
|
trustCmd,
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* provider id → 环境变量名,例如 `minimax-global` → `MINIMAX_GLOBAL_API_KEY`。
|
|
27
|
+
*
|
|
28
|
+
* 非字母数字字符必须归一为 `_`:生成的模板里写着 `export <名字>=...`,
|
|
29
|
+
* 而带连字符的 `export MINIMAX-GLOBAL_API_KEY=…` 是 shell 语法错误
|
|
30
|
+
* (`openai-compat.ts` 的 `${VAR}` 解析器另一条 `$VAR` 分支也只认
|
|
31
|
+
* `[A-Z_][A-Z0-9_]*`)。`/init` 过去只输出无连字符的 mipham,所以这条一直不可达。
|
|
32
|
+
*/
|
|
33
|
+
function envVarFor(providerId: string): string {
|
|
34
|
+
return `${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
const initCmd: CommandHandler = async (ctx) => {
|
|
25
38
|
const { existsSync, mkdirSync } = await import('node:fs')
|
|
26
39
|
const { join } = await import('node:path')
|
|
@@ -33,8 +46,11 @@ const initCmd: CommandHandler = async (ctx) => {
|
|
|
33
46
|
if (!existsSync(userConfigPath)) {
|
|
34
47
|
mkdirSync(join(home, '.mipham'), { recursive: true })
|
|
35
48
|
|
|
36
|
-
|
|
37
|
-
|
|
49
|
+
// 生成物必须来自随包发布的清单,不能来自 ctx.config —— 后者的 status 表达
|
|
50
|
+
// 的是「用户停用了哪家」这个**运行时**状态,而 12 家里只有 mipham 声明了它
|
|
51
|
+
// (其余 status 全在 model 层),拿它筛会恒得 1 家。
|
|
52
|
+
const presetProviders = CLOUD_PROVIDERS
|
|
53
|
+
const providerYaml = presetProviders
|
|
38
54
|
.map((p) => {
|
|
39
55
|
const tips: Record<string, string> = {
|
|
40
56
|
anthropic: '# Get key: https://console.anthropic.com/',
|
|
@@ -51,7 +67,7 @@ const initCmd: CommandHandler = async (ctx) => {
|
|
|
51
67
|
return ` ${comment}
|
|
52
68
|
- id: ${p.id}
|
|
53
69
|
name: "${p.name}"${baseUrlLine}
|
|
54
|
-
apiKey: "\${${p.id
|
|
70
|
+
apiKey: "\${${envVarFor(p.id)}}"`
|
|
55
71
|
})
|
|
56
72
|
.join('\n\n')
|
|
57
73
|
|
|
@@ -75,7 +91,7 @@ defaultProvider: ${ctx.providerId}
|
|
|
75
91
|
defaultModel: ${ctx.modelId}
|
|
76
92
|
permission: ask
|
|
77
93
|
|
|
78
|
-
# ── Providers (${
|
|
94
|
+
# ── Providers (${presetProviders.length} pre-configured — just add your API keys) ──
|
|
79
95
|
providers:
|
|
80
96
|
${providerYaml}
|
|
81
97
|
`
|
|
@@ -84,14 +100,14 @@ ${providerYaml}
|
|
|
84
100
|
return {
|
|
85
101
|
content: `✅ Mipham Code initialized!
|
|
86
102
|
|
|
87
|
-
Created: ~/.mipham/config.yml (${
|
|
103
|
+
Created: ~/.mipham/config.yml (${presetProviders.length} providers pre-configured)
|
|
88
104
|
|
|
89
105
|
Next steps:
|
|
90
106
|
1. Edit ~/.mipham/config.yml — replace API key placeholders with your real keys
|
|
91
107
|
2. Run mipham to start
|
|
92
108
|
|
|
93
109
|
Providers configured:
|
|
94
|
-
${
|
|
110
|
+
${presetProviders.map((p) => ` • ${p.name} — ${envVarFor(p.id)}`).join('\n')}
|
|
95
111
|
|
|
96
112
|
Tip: /setup for the full 6-step wizard.`,
|
|
97
113
|
}
|
|
@@ -105,10 +121,64 @@ Run /setup for the full wizard, or /config to view current settings.`,
|
|
|
105
121
|
}
|
|
106
122
|
}
|
|
107
123
|
|
|
108
|
-
const permissionsCmd: CommandHandler = (ctx) => {
|
|
124
|
+
const permissionsCmd: CommandHandler = async (ctx, args) => {
|
|
109
125
|
const c = ctx.engine.getContext()
|
|
110
126
|
const msgs = c.getMessages()
|
|
111
127
|
|
|
128
|
+
// ── Rule persistence: allow/deny/remove <rule> [--user] ──
|
|
129
|
+
const positional = args.filter((a) => !a.startsWith('--'))
|
|
130
|
+
const scope: 'project' | 'user' = args.includes('--user') ? 'user' : 'project'
|
|
131
|
+
const verb = positional[0]
|
|
132
|
+
const rule = positional[1]
|
|
133
|
+
|
|
134
|
+
if (verb === 'allow' || verb === 'deny' || verb === 'remove') {
|
|
135
|
+
const { validateRulePattern } = await import('../core/permission-rules')
|
|
136
|
+
const { addSettingsRule, removeSettingsRule, settingsPathFor } =
|
|
137
|
+
await import('../config/loader')
|
|
138
|
+
|
|
139
|
+
// A rule that can't match is worse than no rule: it reads as protection
|
|
140
|
+
// that isn't there. Validate before writing.
|
|
141
|
+
const invalid = validateRulePattern(rule ?? '')
|
|
142
|
+
const usage =
|
|
143
|
+
`Usage: /permissions <allow|deny|remove> <rule> [--user]\n\n` +
|
|
144
|
+
` rule Tool pattern — "Bash" or "Bash(npm test)".\n` +
|
|
145
|
+
` --user Write to ~/.mipham/settings.json instead of .mipham/settings.json.`
|
|
146
|
+
|
|
147
|
+
if (verb !== 'remove' && !rule) {
|
|
148
|
+
return { content: `Missing rule.\n\n${usage}` }
|
|
149
|
+
}
|
|
150
|
+
if (invalid && verb !== 'remove') {
|
|
151
|
+
return { content: `Invalid rule "${rule}": ${invalid}.\n\n${usage}` }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const perm = ctx.engine.getPermission()
|
|
155
|
+
|
|
156
|
+
if (verb === 'remove') {
|
|
157
|
+
const removed = removeSettingsRule(rule!, scope)
|
|
158
|
+
if (!removed) {
|
|
159
|
+
return { content: `No rule "${rule}" in ${settingsPathFor(scope)}.` }
|
|
160
|
+
}
|
|
161
|
+
perm.removeRule(rule!)
|
|
162
|
+
return {
|
|
163
|
+
content: `Removed from ${removed.path}\n\npermissions.${removed.key}:\n ${rule}`,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const path = addSettingsRule(verb, rule!, scope)
|
|
168
|
+
if (verb === 'allow') perm.allow(rule!)
|
|
169
|
+
else perm.deny(rule!)
|
|
170
|
+
return {
|
|
171
|
+
content:
|
|
172
|
+
`Added to ${path}\n\n` +
|
|
173
|
+
`permissions.${verb}:\n ${rule}\n\n` +
|
|
174
|
+
`This rule persists across sessions and applies from now on.`,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const settings = await import('../config/loader').then((m) => m.loadSettingsJson())
|
|
179
|
+
const ruleLines = (label: string, rules: string[]) =>
|
|
180
|
+
rules.length > 0 ? ` ${label}\n${rules.map((r) => ` ${r}`).join('\n')}` : ` no ${label}`
|
|
181
|
+
|
|
112
182
|
return {
|
|
113
183
|
content: `─ Permission Settings ─
|
|
114
184
|
|
|
@@ -125,6 +195,15 @@ Switch mode with Shift+Tab. Modes (least → most permissive):
|
|
|
125
195
|
To let Bash run without asking: press Shift+Tab until the status line shows
|
|
126
196
|
"acceptEdits", then send your message again.
|
|
127
197
|
|
|
198
|
+
Persisted rules (settings.json):
|
|
199
|
+
${ruleLines('allow', settings.permissions.allow)}
|
|
200
|
+
${ruleLines('deny', settings.permissions.deny)}
|
|
201
|
+
|
|
202
|
+
Persist a rule with:
|
|
203
|
+
/permissions allow "Bash(npm test)" → .mipham/settings.json
|
|
204
|
+
/permissions deny "Bash(rm *)" --user → ~/.mipham/settings.json
|
|
205
|
+
/permissions remove "Bash(npm test)"
|
|
206
|
+
|
|
128
207
|
Current directory permissions:
|
|
129
208
|
CWD: ${process.cwd()}
|
|
130
209
|
|
|
@@ -384,8 +463,9 @@ async function setupStep1(ctx: CommandContext): Promise<CommandResult> {
|
|
|
384
463
|
if (!existsSync(configPath)) {
|
|
385
464
|
// Generate a user-friendly config with all providers pre-populated.
|
|
386
465
|
// Users just need to replace the API key placeholders with their real keys.
|
|
387
|
-
|
|
388
|
-
const
|
|
466
|
+
// 同 /init:清单来自 CLOUD_PROVIDERS,与运行时 status 无关(见上方注释)。
|
|
467
|
+
const presetProviders = CLOUD_PROVIDERS
|
|
468
|
+
const providerYaml = presetProviders
|
|
389
469
|
.map((p) => {
|
|
390
470
|
const comment =
|
|
391
471
|
p.id === 'anthropic'
|
|
@@ -409,12 +489,12 @@ async function setupStep1(ctx: CommandContext): Promise<CommandResult> {
|
|
|
409
489
|
return ` ${comment}
|
|
410
490
|
- id: ${p.id}
|
|
411
491
|
name: "${p.name}"${baseUrlLine}
|
|
412
|
-
apiKey: "\${${p.id
|
|
492
|
+
apiKey: "\${${envVarFor(p.id)}}"`
|
|
413
493
|
})
|
|
414
494
|
.join('\n\n')
|
|
415
495
|
|
|
416
496
|
const defaultConfig = `# Mipham Code — User Configuration
|
|
417
|
-
# Location: ~/.mipham/config.yml
|
|
497
|
+
# Location: <project>/.mipham/config.yml (project-level; ~/.mipham/config.yml is the user-level file — both are read)
|
|
418
498
|
# Docs: https://mipham.ai/code/docs/config
|
|
419
499
|
#
|
|
420
500
|
# ═══ Quick Start ═══
|
|
@@ -433,7 +513,7 @@ defaultProvider: ${ctx.providerId}
|
|
|
433
513
|
defaultModel: ${ctx.modelId}
|
|
434
514
|
permission: ask
|
|
435
515
|
|
|
436
|
-
# ── Providers (
|
|
516
|
+
# ── Providers (${presetProviders.length} pre-configured — just add your API keys) ──
|
|
437
517
|
providers:
|
|
438
518
|
${providerYaml}
|
|
439
519
|
`
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'
|
|
2
2
|
import { join, dirname } from 'node:path'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
4
5
|
import { saveProviderApiKey } from './loader'
|
|
5
6
|
|
|
6
|
-
const
|
|
7
|
-
const KEYS_FILE = join(
|
|
7
|
+
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
8
|
+
const KEYS_FILE = join(MIPHAM_HOME, 'keys.json')
|
|
8
9
|
const EXPIRY_DAYS = 90
|
|
9
10
|
|
|
10
11
|
export interface KeyEntry {
|
|
@@ -39,14 +40,12 @@ function loadKeys(): KeysData {
|
|
|
39
40
|
|
|
40
41
|
function saveKeys(data: KeysData): void {
|
|
41
42
|
mkdirSync(dirname(KEYS_FILE), { recursive: true })
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// chmod on Windows is a no-op
|
|
49
|
-
}
|
|
43
|
+
// 从前这里「转了一半」:先写一个固定名的 `.tmp`,**然后不 rename、直接再写一遍
|
|
44
|
+
// 目标**。留在磁盘上的 `.tmp` 是废物,目标仍然非原子 —— 并发 `/keys rotate` 撞进
|
|
45
|
+
// 同一个临时名,或写到一半被打断,`loadKeys` 把不可解析的 JSON 吞成 `{}`
|
|
46
|
+
// (见上面的 catch)⇒ 全部轮换元数据静默消失。atomicWriteFileSync 自己写唯一名
|
|
47
|
+
// 临时文件再 rename,两者一起解决。
|
|
48
|
+
atomicWriteFileSync(KEYS_FILE, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
function daysSince(iso: string): number {
|
|
@@ -84,7 +83,7 @@ export class KeyManager {
|
|
|
84
83
|
|
|
85
84
|
// Backup old entry if it exists
|
|
86
85
|
if (existing) {
|
|
87
|
-
const backupDir = join(
|
|
86
|
+
const backupDir = join(MIPHAM_HOME, 'keys')
|
|
88
87
|
mkdirSync(backupDir, { recursive: true })
|
|
89
88
|
const backupPath = join(backupDir, `${provider}.backup`)
|
|
90
89
|
writeFileSync(backupPath, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 })
|
package/src/config/loader.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
unlinkSync,
|
|
8
8
|
chmodSync,
|
|
9
9
|
} from 'node:fs'
|
|
10
|
-
import { join } from 'node:path'
|
|
10
|
+
import { join, dirname } from 'node:path'
|
|
11
11
|
import { homedir } from 'node:os'
|
|
12
12
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
13
13
|
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
@@ -271,6 +271,87 @@ export function loadSettingsJson(cwd: string = process.cwd()): SettingsJson {
|
|
|
271
271
|
return { hooks, permissions }
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/** Which settings.json a permission rule is persisted to. */
|
|
275
|
+
export type SettingsScope = 'project' | 'user'
|
|
276
|
+
|
|
277
|
+
export function settingsPathFor(scope: SettingsScope, cwd: string = process.cwd()): string {
|
|
278
|
+
return scope === 'user'
|
|
279
|
+
? join(MIPHAM_HOME, 'settings.json')
|
|
280
|
+
: join(cwd, '.mipham', 'settings.json')
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Read a settings.json as a plain object, preserving any key we don't model
|
|
285
|
+
* (hooks, and anything a future version adds). A malformed file is an error,
|
|
286
|
+
* not something to clobber — the user's other settings live in the same file.
|
|
287
|
+
*/
|
|
288
|
+
export function readSettingsDoc(path: string): Record<string, unknown> {
|
|
289
|
+
if (!existsSync(path)) return {}
|
|
290
|
+
let parsed: unknown
|
|
291
|
+
try {
|
|
292
|
+
parsed = JSON.parse(readFileSync(path, 'utf-8'))
|
|
293
|
+
} catch {
|
|
294
|
+
throw new Error(`${path} is not valid JSON. Fix or remove it, then retry.`)
|
|
295
|
+
}
|
|
296
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
297
|
+
throw new Error(`${path} does not contain a JSON object. Fix or remove it, then retry.`)
|
|
298
|
+
}
|
|
299
|
+
return parsed as Record<string, unknown>
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function writeSettingsDoc(path: string, doc: Record<string, unknown>): void {
|
|
303
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
304
|
+
atomicWriteFileSync(path, JSON.stringify(doc, null, 2) + '\n')
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Persist one rule into `permissions.<key>` of a scope's settings.json.
|
|
309
|
+
* Idempotent: re-adding an existing rule leaves the file unchanged.
|
|
310
|
+
* Returns the path written.
|
|
311
|
+
*/
|
|
312
|
+
export function addSettingsRule(
|
|
313
|
+
key: 'allow' | 'deny',
|
|
314
|
+
rule: string,
|
|
315
|
+
scope: SettingsScope = 'project',
|
|
316
|
+
cwd: string = process.cwd(),
|
|
317
|
+
): string {
|
|
318
|
+
const path = settingsPathFor(scope, cwd)
|
|
319
|
+
const doc = readSettingsDoc(path)
|
|
320
|
+
const perms = (doc.permissions ?? {}) as Record<string, unknown>
|
|
321
|
+
const list = Array.isArray(perms[key]) ? perms[key] : []
|
|
322
|
+
const strings = (list as unknown[]).filter((r): r is string => typeof r === 'string')
|
|
323
|
+
if (!strings.includes(rule)) strings.push(rule)
|
|
324
|
+
perms[key] = strings
|
|
325
|
+
doc.permissions = perms
|
|
326
|
+
writeSettingsDoc(path, doc)
|
|
327
|
+
return path
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Remove a rule from whichever `permissions` list holds it. Returns the path
|
|
332
|
+
* and key it was removed from, or null when the rule was not present.
|
|
333
|
+
*/
|
|
334
|
+
export function removeSettingsRule(
|
|
335
|
+
rule: string,
|
|
336
|
+
scope: SettingsScope = 'project',
|
|
337
|
+
cwd: string = process.cwd(),
|
|
338
|
+
): { path: string; key: 'allow' | 'deny' } | null {
|
|
339
|
+
const path = settingsPathFor(scope, cwd)
|
|
340
|
+
const doc = readSettingsDoc(path)
|
|
341
|
+
const perms = (doc.permissions ?? {}) as Record<string, unknown>
|
|
342
|
+
|
|
343
|
+
for (const key of ['allow', 'deny'] as const) {
|
|
344
|
+
if (!Array.isArray(perms[key])) continue
|
|
345
|
+
const list = (perms[key] as unknown[]).filter((r): r is string => typeof r === 'string')
|
|
346
|
+
if (!list.includes(rule)) continue
|
|
347
|
+
perms[key] = list.filter((r) => r !== rule)
|
|
348
|
+
doc.permissions = perms
|
|
349
|
+
writeSettingsDoc(path, doc)
|
|
350
|
+
return { path, key }
|
|
351
|
+
}
|
|
352
|
+
return null
|
|
353
|
+
}
|
|
354
|
+
|
|
274
355
|
export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
|
|
275
356
|
const configPath = join(cwd, '.mipham', 'config.yml')
|
|
276
357
|
const userConfigPath = join(MIPHAM_HOME, 'config.yml')
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* NOT for config.yml settings — those belong in the YAML config system.
|
|
6
6
|
* NOT for secrets — this file is plain JSON, not encrypted.
|
|
7
7
|
*/
|
|
8
|
-
import { readFileSync,
|
|
8
|
+
import { readFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
import { homedir } from 'node:os'
|
|
11
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
11
12
|
|
|
12
13
|
const PREFS_PATH = join(homedir(), '.mipham', 'preferences.json')
|
|
13
14
|
|
|
@@ -27,7 +28,9 @@ function writePrefs(prefs: Record<string, string>): void {
|
|
|
27
28
|
try {
|
|
28
29
|
const dir = join(homedir(), '.mipham')
|
|
29
30
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
30
|
-
|
|
31
|
+
// 原子写:裸 writeFileSync 原地截断,崩在写中途就留下一份不可解析的文件,
|
|
32
|
+
// 而 readPrefs 把不可解析吞成「空」⇒ **全部**偏好静默消失(不是丢一项)。
|
|
33
|
+
atomicWriteFileSync(PREFS_PATH, JSON.stringify(prefs, null, 2), { mode: 0o600 })
|
|
31
34
|
} catch {
|
|
32
35
|
// best-effort; never crash because preferences failed to save
|
|
33
36
|
}
|
package/src/core/context.ts
CHANGED
|
@@ -146,7 +146,15 @@ export class ContextManager {
|
|
|
146
146
|
const content = result.success ? result.content : result.error || result.content
|
|
147
147
|
const msg: Message = {
|
|
148
148
|
role: 'user',
|
|
149
|
-
content: [
|
|
149
|
+
content: [
|
|
150
|
+
{
|
|
151
|
+
type: 'tool_result',
|
|
152
|
+
tool_use_id: toolUseId,
|
|
153
|
+
content,
|
|
154
|
+
// 成功不写该键 —— 与 session-log.deriveMessages 的展平式对称(保字节级互逆)
|
|
155
|
+
...(result.success ? {} : { is_error: true }),
|
|
156
|
+
},
|
|
157
|
+
],
|
|
150
158
|
}
|
|
151
159
|
this.messages.push(msg)
|
|
152
160
|
this.estimatedTokens += this.estimateTokens(JSON.stringify(msg.content))
|
|
@@ -369,7 +377,7 @@ export class ContextManager {
|
|
|
369
377
|
if (usage > microThreshold) {
|
|
370
378
|
this.compressionPending = true
|
|
371
379
|
// Schedule microcompact asynchronously (fire-and-forget)
|
|
372
|
-
Promise.resolve().then(() => {
|
|
380
|
+
void Promise.resolve().then(() => {
|
|
373
381
|
this.runMicrocompact()
|
|
374
382
|
this.compressionPending = false
|
|
375
383
|
})
|
package/src/core/cron-poller.ts
CHANGED
|
@@ -9,9 +9,22 @@ import { computeNextFire } from './cron'
|
|
|
9
9
|
import type { CronJob } from '../tools/scheduling/cron'
|
|
10
10
|
import { readAllJobs, writeJob, deleteJobFile } from '../tools/scheduling/cron'
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Whether a job belongs to `cwd`.
|
|
14
|
+
*
|
|
15
|
+
* A job with no `cwd` is from a file written before jobs carried one; it matches
|
|
16
|
+
* anywhere so an existing user's schedule keeps firing instead of going silent.
|
|
17
|
+
* `cwd === undefined` means the caller did not ask for scoping at all (the pure
|
|
18
|
+
* helpers' existing callers), so nothing is filtered.
|
|
19
|
+
*/
|
|
20
|
+
function matchesCwd(job: CronJob, cwd?: string): boolean {
|
|
21
|
+
if (job.cwd === undefined || cwd === undefined) return true
|
|
22
|
+
return job.cwd === cwd
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Jobs whose nextFire is at or before `now` — and which belong to `cwd`. */
|
|
26
|
+
export function findDueJobs(jobs: CronJob[], now: Date, cwd?: string): CronJob[] {
|
|
27
|
+
return jobs.filter((j) => new Date(j.nextFire).getTime() <= now.getTime() && matchesCwd(j, cwd))
|
|
15
28
|
}
|
|
16
29
|
|
|
17
30
|
/** Next state after firing a due job: recurring advances; one-shot → null (delete). */
|
|
@@ -24,9 +37,20 @@ export function advanceJob(job: CronJob, now: Date): CronJob | null {
|
|
|
24
37
|
}
|
|
25
38
|
}
|
|
26
39
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Read due jobs, enqueue their prompts, and advance/delete. Returns fired count.
|
|
42
|
+
*
|
|
43
|
+
* `cwd` defaults to the process's working directory — the same source
|
|
44
|
+
* `ToolContext.cwd` comes from — because the enqueued prompt lands in *this*
|
|
45
|
+
* session and is executed here. Without the filter, a schedule created in one
|
|
46
|
+
* project would be run by whatever session happened to be open in another.
|
|
47
|
+
*/
|
|
48
|
+
export function checkCronJobs(
|
|
49
|
+
enqueue: (prompt: string) => void,
|
|
50
|
+
now = new Date(),
|
|
51
|
+
cwd = process.cwd(),
|
|
52
|
+
): number {
|
|
53
|
+
const due = findDueJobs(readAllJobs(), now, cwd)
|
|
30
54
|
for (const job of due) {
|
|
31
55
|
enqueue(job.prompt)
|
|
32
56
|
const next = advanceJob(job, now)
|
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
|
+
}
|