@miphamai/cli 0.81.5 → 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 +32 -4
  12. package/src/core/metrics.ts +8 -0
  13. package/src/core/paths.ts +79 -0
  14. package/src/core/permission-rules.ts +145 -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 +5 -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 +109 -8
  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 +47 -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
@@ -0,0 +1,127 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { homedir } from 'node:os'
3
+
4
+ /**
5
+ * Redaction for crash reports.
6
+ *
7
+ * Nothing in this module is reusable from the existing scrubbers: the four
8
+ * families in the repo (`credential-masker/`, `security/gate.ts`,
9
+ * `shared/sanitize.ts`, `skills/sanitizer.ts`) all match *credential shapes* —
10
+ * token prefixes, JWTs, `sk-ant-…`. None of them removes a user name out of a
11
+ * path. The only home→`~` helper is `ui/chat.tsx` `displayCwd()`, which is
12
+ * unexported, has zero call sites, and only ever looks at `process.cwd()` — it
13
+ * cannot touch a path inside an error object or a stack frame.
14
+ *
15
+ * So the guarantee below has to be carried by tests, not by reuse.
16
+ */
17
+
18
+ /** Frames kept by default. `Error.stackTraceLimit` is 10; we allow headroom. */
19
+ export const MAX_STACK_FRAMES = 15
20
+
21
+ /** Marker substituted for the working directory — hides the project name. */
22
+ const CWD_MARKER = '<cwd>'
23
+
24
+ /** Marker substituted for the home directory. */
25
+ const HOME_MARKER = '~'
26
+
27
+ /**
28
+ * Marker substituted for the first directory segment under home.
29
+ *
30
+ * Home replacement alone still discloses the user's own naming: a frame in
31
+ * `~/acme-secret-merger-2026/src/a.ts` would render as exactly that. The
32
+ * data dictionary promises no project names, so the segment right after `~` is
33
+ * collapsed too. Structure and the frame's own file name survive — which is
34
+ * what makes a frame useful — but the user's label for a directory does not.
35
+ */
36
+ const DIR_MARKER = '<dir>'
37
+
38
+ function escapeRegExp(s: string): string {
39
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
40
+ }
41
+
42
+ /**
43
+ * Replace absolute home/cwd paths with their markers, anywhere in the string.
44
+ *
45
+ * Order matters and is not arbitrary: cwd is normally *inside* home, so
46
+ * replacing home first would leave `~/proj/src/a.ts` — which leaks the project
47
+ * directory name. Replacing cwd first yields `<cwd>/src/a.ts`, which does not.
48
+ * This ordering is asserted by a test.
49
+ *
50
+ * Paths that are neither under cwd nor under home are left alone. Those are
51
+ * system/package locations (e.g. `/usr/local/lib/node_modules/…`) that contain
52
+ * no user data, and keeping them is what makes a frame useful for debugging.
53
+ */
54
+ export function redactText(input: string, cwd?: string): string {
55
+ let out = input
56
+
57
+ if (cwd && cwd.length > 1) {
58
+ out = out.replace(new RegExp(escapeRegExp(cwd), 'g'), CWD_MARKER)
59
+ }
60
+
61
+ let home = ''
62
+ try {
63
+ home = homedir()
64
+ } catch {
65
+ /* homedir() can throw when neither HOME nor the passwd entry resolves */
66
+ }
67
+ if (home && home.length > 1) {
68
+ out = out.replace(new RegExp(escapeRegExp(home), 'g'), HOME_MARKER)
69
+ }
70
+
71
+ // Collapse the first segment after `~` (both separators — Windows stacks use
72
+ // backslashes) so a user-chosen directory name is not disclosed. The matched
73
+ // separator is echoed back rather than normalised, so a Windows path does not
74
+ // come out with mixed separators.
75
+ out = out.replace(
76
+ /~([\\/])[^\\/\s:)]+/g,
77
+ (_match, sep: string) => `${HOME_MARKER}${sep}${DIR_MARKER}`,
78
+ )
79
+
80
+ return out
81
+ }
82
+
83
+ export interface RedactedStack {
84
+ /** Redacted frame lines, truncated to `maxFrames`. */
85
+ frames: string[]
86
+ /** Frame count *before* truncation — truncation loses data, this bounds the loss. */
87
+ frameCount: number
88
+ }
89
+
90
+ /**
91
+ * Extract and redact the frame lines of a V8 stack.
92
+ *
93
+ * The first line of a stack is `"TypeError: <message>"` — it is dropped
94
+ * outright, never redacted, because the message routinely embeds paths and user
95
+ * data. `crash.ts` sends only `hashMessage(error.message)` instead.
96
+ */
97
+ export function redactStack(
98
+ stack: string,
99
+ opts: { cwd?: string; maxFrames?: number } = {},
100
+ ): RedactedStack {
101
+ const { cwd = process.cwd(), maxFrames = MAX_STACK_FRAMES } = opts
102
+
103
+ const frameLines = stack.split('\n').filter((line) => /^\s*at\s/.test(line))
104
+ const redacted = frameLines.map((line) => redactText(line.trim(), cwd))
105
+
106
+ return {
107
+ frames: redacted.slice(0, maxFrames),
108
+ frameCount: frameLines.length,
109
+ }
110
+ }
111
+
112
+ /**
113
+ * sha256 of the error message, first 16 hex chars.
114
+ *
115
+ * Only the digest is reportable. The plaintext message is not — same reason the
116
+ * stack's first line is dropped.
117
+ */
118
+ export function hashMessage(message: string): string {
119
+ return createHash('sha256').update(message).digest('hex').slice(0, 16)
120
+ }
121
+
122
+ /** Runtime tag for the session payload, e.g. `bun@1.2` / `node@22`. */
123
+ export function runtimeTag(): string {
124
+ const versions = process.versions as Record<string, string | undefined>
125
+ if (versions.bun) return `bun@${versions.bun}`
126
+ return `node@${(versions.node ?? '0').split('.')[0]}`
127
+ }
@@ -0,0 +1,81 @@
1
+ import { fetchWithRetry } from '../providers/fetch-utils'
2
+ import { ackQueue, readQueue, type QueuedEvent } from './queue'
3
+
4
+ /**
5
+ * Upload queued events.
6
+ *
7
+ * Runs at *startup*, not at exit: `process.on('exit')` cannot await async work,
8
+ * so collecting and sending are decoupled — the session queue is written
9
+ * synchronously at exit and drained here on the next launch. Same shape as the
10
+ * existing startup update check (`shared/update.ts`), which is likewise
11
+ * fire-and-forget.
12
+ *
13
+ * Every failure is silent and leaves the event queued for the next attempt. A
14
+ * telemetry endpoint being unreachable must never be visible to the user.
15
+ */
16
+
17
+ /** Per-request budget. Short: this runs alongside startup, not during it. */
18
+ const REQUEST_TIMEOUT_MS = 10_000
19
+
20
+ export interface FlushResult {
21
+ sent: number
22
+ failed: number
23
+ }
24
+
25
+ /**
26
+ * Drain the queue to `endpoint`.
27
+ *
28
+ * Returns without sending anything when the endpoint is empty — which is the
29
+ * shipped default, so an unconfigured install performs **zero** network calls.
30
+ */
31
+ export async function flushQueue(
32
+ endpoint: string,
33
+ opts: { fetchImpl?: typeof fetch; events?: QueuedEvent[] } = {},
34
+ ): Promise<FlushResult> {
35
+ if (!endpoint) return { sent: 0, failed: 0 }
36
+
37
+ const events = opts.events ?? readQueue()
38
+ if (events.length === 0) return { sent: 0, failed: 0 }
39
+
40
+ const sent: string[] = []
41
+ let failed = 0
42
+
43
+ for (const event of events) {
44
+ try {
45
+ const response = await fetchWithRetry(
46
+ endpoint,
47
+ {
48
+ method: 'POST',
49
+ headers: { 'Content-Type': 'application/json' },
50
+ body: JSON.stringify(event),
51
+ },
52
+ { timeout: REQUEST_TIMEOUT_MS, maxRetries: 2, baseDelay: 1000 },
53
+ )
54
+
55
+ // 4xx means we are sending something the endpoint will never accept —
56
+ // retrying forever would wedge the queue behind a permanently bad event.
57
+ // Drop it on the floor rather than block every later event.
58
+ if (response.ok || (response.status >= 400 && response.status < 500)) {
59
+ sent.push(event.id)
60
+ } else {
61
+ failed++
62
+ }
63
+ } catch {
64
+ failed++
65
+ }
66
+ }
67
+
68
+ ackQueue(sent)
69
+ return { sent: sent.length, failed }
70
+ }
71
+
72
+ /**
73
+ * Fire-and-forget startup flush. Never rejects, never blocks the caller, and
74
+ * never surfaces an error — the caller does not await it.
75
+ */
76
+ export function flushQueueInBackground(endpoint: string): void {
77
+ if (!endpoint) return
78
+ void flushQueue(endpoint).catch(() => {
79
+ /* best-effort by design */
80
+ })
81
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
2
  import { runWorkflow } from '../../workflow/runtime'
3
+ import { workflowScriptDir } from '../../core/paths.ts'
3
4
  import type { QueryEngine } from '../../core/engine'
4
5
 
5
6
  export const workflowTool: ToolDefinition = {
@@ -108,10 +109,11 @@ export const workflowTool: ToolDefinition = {
108
109
  )
109
110
 
110
111
  // Persist last-run state for /workflow save
112
+ let persistWarning = ''
111
113
  try {
112
114
  const { existsSync, mkdirSync, writeFileSync } = await import('node:fs')
113
115
  const { join } = await import('node:path')
114
- const workflowsDir = join(process.cwd(), '.claude', 'workflows')
116
+ const workflowsDir = workflowScriptDir(process.cwd())
115
117
  if (!existsSync(workflowsDir)) {
116
118
  mkdirSync(workflowsDir, { recursive: true })
117
119
  }
@@ -120,8 +122,13 @@ export const workflowTool: ToolDefinition = {
120
122
  JSON.stringify({ runId, script, timestamp: new Date().toISOString() }),
121
123
  'utf-8',
122
124
  )
123
- } catch {
124
- // best-effort — don't fail the workflow if state persistence fails
125
+ } catch (err) {
126
+ // Persistence stays best-effort — the workflow itself succeeded, so a
127
+ // disk error must not fail it. But it must not be *silent* either:
128
+ // swallowing this made the next `/workflow save` report "No recent
129
+ // workflow run found" while the script sat intact in
130
+ // ~/.mipham/workflows/<runId>/script.js.
131
+ persistWarning = `\n\n⚠️ Could not persist last-run state for /workflow save: ${String(err)}`
125
132
  }
126
133
 
127
134
  let content = `Workflow ${runId} completed.\n\n`
@@ -130,7 +137,7 @@ export const workflowTool: ToolDefinition = {
130
137
  }
131
138
  content += `Result:\n${typeof result === 'string' ? result : JSON.stringify(result, null, 2)}`
132
139
 
133
- return { success: true, content }
140
+ return { success: true, content: content + persistWarning }
134
141
  } catch (err) {
135
142
  return {
136
143
  success: false,
@@ -2,6 +2,7 @@ import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index
2
2
  import { sanitizeCommand } from '../../shared/sanitize.ts'
3
3
  import { DANGEROUS_GIT_PATTERNS } from './git.ts'
4
4
  import { isUncOrDevicePath } from '../../security/path.ts'
5
+ import { findWorktreeMarker } from '../../core/paths.ts'
5
6
  import type { Service } from '../../vajra'
6
7
  import { toolKey } from '../seam'
7
8
  import { withValidation } from '../validation'
@@ -334,9 +335,10 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
334
335
  const timeout = Math.min((params.timeout as number) || 120_000, 600_000)
335
336
 
336
337
  // P0-4: Worktree isolation — block cd escape attempts
337
- const WORKTREE_MARKER = '.claude/worktrees/'
338
- if (ctx.cwd.includes(WORKTREE_MARKER)) {
339
- const worktreeRoot = ctx.cwd.substring(0, ctx.cwd.indexOf(WORKTREE_MARKER))
338
+ // 标记取自 core/paths.ts:新目录与历史 .claude/worktrees/ 都认,
339
+ // 隔离度只增不减(只认新前缀会让旧工作树失去保护)。
340
+ const worktreeMarker = findWorktreeMarker(ctx.cwd)
341
+ if (worktreeMarker) {
340
342
  // Detect cd to absolute paths outside the worktree
341
343
  const cdEscapePattern = /\bcd\s+(?:"([^"]+)"|'([^']+)'|([^\s;|&]+))/
342
344
  const cdMatch = command.match(cdEscapePattern)
@@ -346,7 +348,7 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
346
348
  const resolved = target.startsWith('/')
347
349
  ? target
348
350
  : `${ctx.cwd}/${target}`.replace(/\/\.\//g, '/')
349
- if (!resolved.startsWith(ctx.cwd) && !resolved.startsWith(worktreeRoot + '/')) {
351
+ if (!resolved.startsWith(ctx.cwd) && !resolved.startsWith(worktreeMarker.root + '/')) {
350
352
  return {
351
353
  success: false,
352
354
  content: '',
@@ -1,10 +1,11 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
+ import { worktreeRoot } from '../../core/paths.ts'
2
3
 
3
4
  export const enterWorktreeTool: ToolDefinition = {
4
5
  name: 'EnterWorktree',
5
6
  description:
6
7
  'Create an isolated git worktree for parallel development. ' +
7
- 'Creates a new worktree at .claude/worktrees/<name> on its own branch. ' +
8
+ 'Creates a new worktree at .mipham/worktrees/<name> on its own branch. ' +
8
9
  'Use this when you need to work on a separate task without affecting the main workspace. ' +
9
10
  'Pair with ExitWorktree to clean up when done.',
10
11
  category: 'exec',
@@ -56,10 +57,10 @@ export const enterWorktreeTool: ToolDefinition = {
56
57
 
57
58
  const cwd = ctx.cwd
58
59
  const { resolve } = await import('node:path')
59
- const worktreePath = resolve(`${cwd}/.claude/worktrees/${name}`)
60
- const allowedPrefix = resolve(`${cwd}/.claude/worktrees/`)
60
+ const worktreePath = resolve(worktreeRoot(cwd), name)
61
+ const allowedPrefix = resolve(worktreeRoot(cwd))
61
62
 
62
- // Defense-in-depth: verify resolved path is within .claude/worktrees/
63
+ // Defense-in-depth: verify resolved path stays within the worktree root
63
64
  if (
64
65
  !worktreePath.startsWith(allowedPrefix + '/') &&
65
66
  worktreePath !== allowedPrefix.slice(0, -1)
@@ -67,7 +68,7 @@ export const enterWorktreeTool: ToolDefinition = {
67
68
  return {
68
69
  success: false,
69
70
  content: '',
70
- error: 'Worktree path must be within .claude/worktrees/.',
71
+ error: 'Worktree path must be within .mipham/worktrees/.',
71
72
  }
72
73
  }
73
74
 
@@ -1,4 +1,5 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
+ import { worktreeRoots } from '../../core/paths.ts'
2
3
 
3
4
  export const exitWorktreeTool: ToolDefinition = {
4
5
  name: 'ExitWorktree',
@@ -13,7 +14,8 @@ export const exitWorktreeTool: ToolDefinition = {
13
14
  properties: {
14
15
  path: {
15
16
  type: 'string',
16
- description: 'Absolute path to the worktree to exit. Must be under .claude/worktrees/.',
17
+ description:
18
+ 'Absolute path to the worktree to exit. Must be under .mipham/worktrees/ (or the legacy .claude/worktrees/).',
17
19
  },
18
20
  action: {
19
21
  type: 'string',
@@ -34,18 +36,21 @@ export const exitWorktreeTool: ToolDefinition = {
34
36
  const action = params.action as string
35
37
  const discardChanges = params.discard_changes === true
36
38
 
37
- // Validate the path is under .claude/worktrees
39
+ // Validate the path is under a worktree root(新目录优先,兼容历史 .claude/)
38
40
  const cwd = ctx.cwd
39
41
  const { resolve } = await import('node:path')
40
42
  const resolvedPath = resolve(worktreePath)
41
- const allowedPrefix = resolve(`${cwd}/.claude/worktrees/`)
43
+ const roots = worktreeRoots(cwd).map((root) => resolve(root))
44
+ const inWorktree = roots.some(
45
+ (root) => resolvedPath === root || resolvedPath.startsWith(root + '/'),
46
+ )
42
47
 
43
- if (!resolvedPath.startsWith(allowedPrefix)) {
48
+ if (!inWorktree) {
44
49
  return {
45
50
  success: false,
46
51
  content: '',
47
52
  error:
48
- `Path "${worktreePath}" is not under .claude/worktrees/. ` +
53
+ `Path "${worktreePath}" is not under .mipham/worktrees/ or .claude/worktrees/. ` +
49
54
  `Only worktrees created by EnterWorktree can be managed here.`,
50
55
  }
51
56
  }
@@ -1,4 +1,5 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
+ import { findWorktreeMarker } from '../../core/paths.ts'
2
3
 
3
4
  // P0-4 (v2.1.222 alignment): Regex-based word-boundary patterns replace
4
5
  // fragile substring matching. Each pattern describes what it blocks.
@@ -52,14 +53,23 @@ export const DANGEROUS_GIT_PATTERNS: Array<{ pattern: RegExp; description: strin
52
53
  * when operating in a worktree context.
53
54
  */
54
55
  function isOutsideWorktree(command: string, cwd: string): string | null {
55
- const WORKTREE_MARKER = '.claude/worktrees/'
56
- if (!cwd.includes(WORKTREE_MARKER)) return null
57
-
58
- // Extract the project root (everything before .claude/worktrees/)
59
- const worktreeRoot = cwd.substring(0, cwd.indexOf(WORKTREE_MARKER))
60
-
61
- // Detect git commands that reference the main checkout path
62
- const mainCheckoutPaths = [/\b--work-tree=([^\s]+)/g, /\b--git-dir=([^\s]+)/g, /\b-C\s+([^\s]+)/g]
56
+ // 标记取自 core/paths.ts:新目录与历史 .claude/worktrees/ 都认,隔离度只增不减。
57
+ const marker = findWorktreeMarker(cwd)
58
+ if (!marker) return null
59
+
60
+ // Extract the project root (everything before the worktree marker)
61
+ const worktreeRoot = marker.root
62
+
63
+ // Detect git commands that reference the main checkout path.
64
+ // NOTE: no `\b` before the leading `-` — `\b` needs a word/non-word
65
+ // transition and `-` is itself a non-word char, so `\b--work-tree=` never
66
+ // matched anything and this whole check was silently dead. `-C` needs an
67
+ // explicit boundary because without one it would match inside a path.
68
+ const mainCheckoutPaths = [
69
+ /--work-tree=([^\s]+)/g,
70
+ /--git-dir=([^\s]+)/g,
71
+ /(?:^|\s)-C\s+([^\s]+)/g,
72
+ ]
63
73
 
64
74
  for (const pathPattern of mainCheckoutPaths) {
65
75
  let match: RegExpExecArray | null
@@ -4,8 +4,8 @@ import { homedir } from 'node:os'
4
4
  import { parse as parseYaml, stringify } from 'yaml'
5
5
  import type { ToolDefinition } from '../../shared/index.ts'
6
6
 
7
- const MIPHAM_DIR = join(homedir(), '.mipham')
8
- const USER_CONFIG = join(MIPHAM_DIR, 'config.yml')
7
+ const MIPHAM_HOME = join(homedir(), '.mipham')
8
+ const USER_CONFIG = join(MIPHAM_HOME, 'config.yml')
9
9
 
10
10
  export const configTool: ToolDefinition = {
11
11
  name: 'Config',
@@ -26,7 +26,7 @@ export const configTool: ToolDefinition = {
26
26
  required: ['action'],
27
27
  },
28
28
  async execute(params, _ctx) {
29
- mkdirSync(MIPHAM_DIR, { recursive: true })
29
+ mkdirSync(MIPHAM_HOME, { recursive: true })
30
30
  const action = params.action as string
31
31
 
32
32
  let config: Record<string, unknown> = {}
package/src/ui/app.tsx CHANGED
@@ -14,6 +14,8 @@ import type { SkillsLoader } from '../skills/loader'
14
14
  import type { PluginManager } from '../plugin/plugin-manager'
15
15
  import { setPreference } from '../config/preferences'
16
16
  import { saveProviderApiKey } from '../config/loader'
17
+ import { recordCommand } from '../telemetry/index'
18
+ import { recordCrash } from '../telemetry/crash'
17
19
  import { AgentRegistry } from '../agent/agent-registry'
18
20
  import { getBackgroundAgentRegistry } from '../agent/background-registry'
19
21
  import { getMessageRouter, parseMention, resolveRecipientSession } from '../agent/message-router'
@@ -46,6 +48,7 @@ import type { AgentViewManager } from '../agent-view/agent-view-manager'
46
48
  import { WorkflowProgress } from './workflow-progress.js'
47
49
  import { GoalProgress } from './goal-progress.js'
48
50
  import {
51
+ commandLabelFor,
49
52
  getCommand,
50
53
  looksLikeSlashCommand,
51
54
  parseSlashCommand,
@@ -206,9 +209,13 @@ export function App({
206
209
  useEffect(() => {
207
210
  if (!gitBranch) return
208
211
  let cancelled = false
209
- resolveGitPr(gitBranch).then((pr) => {
210
- if (!cancelled) setGitPr(pr)
211
- })
212
+ resolveGitPr(gitBranch)
213
+ .then((pr) => {
214
+ if (!cancelled) setGitPr(pr)
215
+ })
216
+ .catch(() => {
217
+ /* best effort — the PR badge is decoration, never worth surfacing */
218
+ })
212
219
  return () => {
213
220
  cancelled = true
214
221
  }
@@ -217,11 +224,15 @@ export function App({
217
224
  // 启动后台查新版(非阻塞;离线静默失败)
218
225
  useEffect(() => {
219
226
  let cancelled = false
220
- checkForUpdatesAsync().then((update) => {
221
- if (!cancelled && update.available) {
222
- setUpdateStatus({ state: 'available', latest: update.latest })
223
- }
224
- })
227
+ checkForUpdatesAsync()
228
+ .then((update) => {
229
+ if (!cancelled && update.available) {
230
+ setUpdateStatus({ state: 'available', latest: update.latest })
231
+ }
232
+ })
233
+ .catch(() => {
234
+ /* offline — silent, per the comment above */
235
+ })
225
236
  return () => {
226
237
  cancelled = true
227
238
  }
@@ -750,7 +761,7 @@ export function App({
750
761
  }
751
762
 
752
763
  // Turn finished — drain any /loop wakeup queued while we were running.
753
- drainLoopQueueRef.current?.(turnId)
764
+ void drainLoopQueueRef.current?.(turnId)
754
765
  },
755
766
  [engine, syncBgAgents, config, t],
756
767
  )
@@ -794,7 +805,7 @@ export function App({
794
805
  useEffect(() => {
795
806
  if (wakeupTick === 0) return
796
807
  if (isLoading) return
797
- drainLoopQueueRef.current?.(turnIdRef.current)
808
+ void drainLoopQueueRef.current?.(turnIdRef.current)
798
809
  }, [wakeupTick, isLoading])
799
810
 
800
811
  // ── cron poller ──
@@ -850,6 +861,15 @@ export function App({
850
861
  if (looksLikeSlashCommand(input)) {
851
862
  const { command, args } = parseSlashCommand(input)
852
863
 
864
+ // Counted here rather than at the registry lookup below: /switch, /pick,
865
+ // /model-picker, /exit, /quit and /focus are special-cased and return
866
+ // before ever reaching it, so counting there would silently under-report
867
+ // six of the most-used commands.
868
+ //
869
+ // `commandLabelFor` collapses unrecognised names into `/unknown` — see it
870
+ // for why an unbounded `command_name` is not a cosmetic problem.
871
+ recordCommand(commandLabelFor(command))
872
+
853
873
  // /switch takes args, handled separately
854
874
  if (command === '/switch') {
855
875
  const result = await handleSwitch(mkCtx(), args)
@@ -927,6 +947,13 @@ export function App({
927
947
  if (result.nextProvider) setProviderId(result.nextProvider)
928
948
  if (result.nextModel) setModelId(result.nextModel)
929
949
  if (result.exit) process.exit(0)
950
+ if (result.clearMessages || (result.forwardedMessages?.length ?? 0) > 0) {
951
+ // The conversation this session is looking at just changed (/clear,
952
+ // /resume) — the read-before-write record belongs to the old one.
953
+ // Clearing is the fail-closed direction: an unread file must be
954
+ // re-read rather than silently overwritten.
955
+ engine.resetFileTracking()
956
+ }
930
957
  if (result.forwardedMessages && result.forwardedMessages.length > 0) {
931
958
  const restored: ChatMessage[] = result.forwardedMessages.map((msg) => ({
932
959
  role: msg.role,
@@ -1092,7 +1119,16 @@ export function App({
1092
1119
  }
1093
1120
 
1094
1121
  return (
1095
- <ErrorBoundary>
1122
+ <ErrorBoundary
1123
+ onError={(error) => {
1124
+ // The boundary's own job is *surviving* a render error — it renders a
1125
+ // fallback and the session continues. But this is the exact failure the
1126
+ // boundary was written for (a frozen layout with a live process, i.e. a
1127
+ // silent hang), so record it as a crash signal rather than letting it
1128
+ // vanish once the fallback paints over the evidence.
1129
+ recordCrash(error, 'render')
1130
+ }}
1131
+ >
1096
1132
  <Box flexDirection="column" padding={1} height="100%">
1097
1133
  {/* Workflow progress — auto-detects active workflows, renders nothing when idle */}
1098
1134
  <WorkflowProgress />