@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.
Files changed (66) 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/artifacts/manifest.ts +90 -34
  8. package/src/artifacts/paths.ts +19 -0
  9. package/src/artifacts/server.ts +48 -8
  10. package/src/commands/project.ts +92 -12
  11. package/src/config/keys-manager.ts +10 -11
  12. package/src/config/loader.ts +82 -1
  13. package/src/config/preferences.ts +5 -2
  14. package/src/core/context.ts +10 -2
  15. package/src/core/cron-poller.ts +30 -6
  16. package/src/core/engine.ts +19 -4
  17. package/src/core/metrics.ts +8 -0
  18. package/src/core/paths.ts +79 -0
  19. package/src/core/permission-rules.ts +261 -17
  20. package/src/core/permission.ts +3 -0
  21. package/src/core/session-log.ts +55 -3
  22. package/src/core/session-store.ts +11 -1
  23. package/src/daemon/engine-capabilities.ts +131 -0
  24. package/src/daemon/index.ts +4 -1
  25. package/src/daemon/launch.ts +287 -0
  26. package/src/daemon/remote-engine.ts +2 -0
  27. package/src/daemon/server.ts +9 -0
  28. package/src/daemon/session-worker.ts +21 -3
  29. package/src/i18n-core/locales/en-US.json +6 -7
  30. package/src/i18n-core/locales/zh-CN.json +6 -7
  31. package/src/index.tsx +82 -2
  32. package/src/mcp/client.ts +4 -2
  33. package/src/plugin/plugin-manager.ts +17 -6
  34. package/src/providers/anthropic.ts +28 -2
  35. package/src/providers/openai-compat.ts +14 -1
  36. package/src/security/path.ts +6 -1
  37. package/src/shared/atomic-write.ts +28 -5
  38. package/src/shared/package-info.ts +1 -1
  39. package/src/shared/types.ts +24 -0
  40. package/src/skills/bundled-skills.ts +1 -1
  41. package/src/telemetry/consent.ts +209 -0
  42. package/src/telemetry/crash.ts +197 -0
  43. package/src/telemetry/endpoint.ts +82 -0
  44. package/src/telemetry/index.ts +153 -0
  45. package/src/telemetry/payload.ts +141 -0
  46. package/src/telemetry/queue.ts +95 -0
  47. package/src/telemetry/redact.ts +127 -0
  48. package/src/telemetry/transport.ts +81 -0
  49. package/src/tools/agent/workflow.ts +11 -4
  50. package/src/tools/artifact/artifact.ts +14 -4
  51. package/src/tools/exec/bash.ts +45 -21
  52. package/src/tools/exec/enter-worktree.ts +6 -5
  53. package/src/tools/exec/exit-worktree.ts +10 -5
  54. package/src/tools/exec/git.ts +25 -10
  55. package/src/tools/file/grep.ts +37 -13
  56. package/src/tools/file/read.ts +151 -45
  57. package/src/tools/scheduling/cron.ts +34 -5
  58. package/src/tools/system/config.ts +9 -5
  59. package/src/ui/app.tsx +40 -11
  60. package/src/ui/commands.ts +186 -45
  61. package/src/workflow/primitives/agent.ts +4 -2
  62. package/src/artifacts/versioning.ts +0 -127
  63. package/src/core/task-runner-tasks.json +0 -14
  64. package/src/core/task-runner.ts +0 -163
  65. package/src/skills/mipham/runtime.ts +0 -66
  66. package/src/skills/standard/runtime.ts +0 -62
@@ -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
- const activeProviders = ctx.config.providers.filter((p) => p.status === 'active')
37
- const providerYaml = activeProviders
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.toUpperCase()}_API_KEY}"`
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 (${activeProviders.length} pre-configured — just add your API keys) ──
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 (${activeProviders.length} providers pre-configured)
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
- ${activeProviders.map((p) => ` • ${p.name} — ${p.id.toUpperCase()}_API_KEY`).join('\n')}
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
- const activeProviders = ctx.config.providers.filter((p) => p.status === 'active')
388
- const providerYaml = activeProviders
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.toUpperCase()}_API_KEY}"`
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 (8 configured, just add your API keys) ──
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 MIPHAM_DIR = join(homedir(), '.mipham')
7
- const KEYS_FILE = join(MIPHAM_DIR, 'keys.json')
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
- const tmp = KEYS_FILE + '.tmp'
43
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
44
- writeFileSync(KEYS_FILE, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
45
- try {
46
- chmodSync(KEYS_FILE, 0o600)
47
- } catch {
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(MIPHAM_DIR, 'keys')
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 })
@@ -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, writeFileSync, existsSync, mkdirSync } from 'node:fs'
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
- writeFileSync(PREFS_PATH, JSON.stringify(prefs, null, 2), { mode: 0o600, encoding: 'utf-8' })
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
  }
@@ -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: [{ type: 'tool_result', tool_use_id: toolUseId, 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
  })
@@ -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
- /** Jobs whose nextFire is at or before `now`. Pure — separated for tests. */
13
- export function findDueJobs(jobs: CronJob[], now: Date): CronJob[] {
14
- return jobs.filter((j) => new Date(j.nextFire).getTime() <= now.getTime())
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
- /** Read due jobs, enqueue their prompts, and advance/delete. Returns fired count. */
28
- export function checkCronJobs(enqueue: (prompt: string) => void, now = new Date()): number {
29
- const due = findDueJobs(readAllJobs(), now)
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)
@@ -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
+ }