@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
@@ -1,4 +1,4 @@
1
- import { readFileSync, fstatSync, closeSync, constants } from 'node:fs'
1
+ import { readFileSync, readSync, fstatSync, closeSync, constants } from 'node:fs'
2
2
  import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
3
3
  import { resolveSafe } from '../../security/path'
4
4
  import { openNoFollow, isSymlinkLoop } from '../../security/fd'
@@ -6,6 +6,115 @@ import type { Service } from '../../vajra'
6
6
  import { toolKey } from '../seam'
7
7
  import { withValidation } from '../validation'
8
8
 
9
+ /** Lines returned when the caller passes no `limit`. */
10
+ const DEFAULT_LIMIT = 2000
11
+
12
+ /** Bytes per syscall while scanning for newlines. */
13
+ const CHUNK_BYTES = 64 * 1024
14
+
15
+ /**
16
+ * How far a single Read may scan to locate the requested lines. Lines are
17
+ * addressed by index, so finding line N means scanning everything before it;
18
+ * this is what keeps that scan from turning a request for two lines of a 2 GB
19
+ * file into a 2 GB read.
20
+ */
21
+ const MAX_SCAN_BYTES = 50_000_000
22
+
23
+ /** Render a line window the way the tool reports it: ` 123\ttext`. */
24
+ function formatLines(lines: string[], offset: number): string {
25
+ return lines.map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`).join('\n')
26
+ }
27
+
28
+ /**
29
+ * Lines `[offset, offset + limit)` of an already-decoded string.
30
+ *
31
+ * Scans newlines instead of `content.split('\n')`, which materializes one JS
32
+ * string for *every* line of the file in order to return `limit` of them. On a
33
+ * 20 MB source, returning 2000 lines that way cost +34 MB of heap for 60-char
34
+ * lines and +100 MB for 3-char lines (333k vs 10M lines — it tracks line count,
35
+ * not bytes); scanning costs ~0 on top of the string it is handed.
36
+ */
37
+ function windowFromString(content: string, offset: number, limit: number): string[] {
38
+ const out: string[] = []
39
+ let lineNo = 0
40
+ let start = 0
41
+ while (out.length < limit) {
42
+ const nl = content.indexOf('\n', start)
43
+ const end = nl === -1 ? content.length : nl
44
+ if (lineNo >= offset) out.push(content.slice(start, end))
45
+ lineNo++
46
+ if (nl === -1) break
47
+ start = nl + 1
48
+ }
49
+ return out
50
+ }
51
+
52
+ /**
53
+ * Lines `[offset, offset + limit)` read from `fd` — only those lines are ever
54
+ * materialized, so cost tracks the window rather than the file: a 2000-line
55
+ * window of a 20 MB file leaves RSS at the process baseline (~38 MB), where
56
+ * reading the file whole and slicing it cost 142–255 MB.
57
+ *
58
+ * Two passes over the same bytes: the first only *looks* for newline bytes to
59
+ * learn where the window starts and ends, the second reads exactly that byte
60
+ * range and decodes it once. Scanning raw bytes is safe because `0x0a` never
61
+ * occurs inside a multi-byte UTF-8 sequence (continuation bytes are >= 0x80),
62
+ * and the decoded range starts and ends on a newline — so neither pass can split
63
+ * a character. Returns null when the window cannot be located within
64
+ * `MAX_SCAN_BYTES`.
65
+ */
66
+ function windowFromFd(fd: number, offset: number, limit: number): string[] | null {
67
+ if (limit <= 0) return []
68
+
69
+ const buf = Buffer.allocUnsafe(CHUNK_BYTES)
70
+ let scanned = 0 // bytes consumed by the scan
71
+ let lineNo = 0
72
+ let windowStart = offset <= 0 ? 0 : -1
73
+ let windowEnd = -1
74
+ let eof = false
75
+
76
+ while (windowEnd === -1 && scanned < MAX_SCAN_BYTES) {
77
+ const n = readSync(fd, buf, 0, buf.length, scanned)
78
+ if (n <= 0) {
79
+ eof = true
80
+ break
81
+ }
82
+ const lastNl = buf.lastIndexOf(0x0a, n - 1)
83
+ if (lastNl !== -1) {
84
+ let from = 0
85
+ for (;;) {
86
+ const nl = buf.indexOf(0x0a, from)
87
+ if (nl === -1 || nl > lastNl) break
88
+ lineNo++ // line `lineNo - 1` ends at this newline
89
+ if (lineNo === offset) windowStart = scanned + nl + 1
90
+ if (lineNo === offset + limit) {
91
+ windowEnd = scanned + nl
92
+ break
93
+ }
94
+ from = nl + 1
95
+ }
96
+ }
97
+ scanned += n
98
+ }
99
+
100
+ if (windowEnd === -1) {
101
+ // Either the file ended (the window is everything that is left) or the scan
102
+ // budget ran out before the window's last line was reached.
103
+ if (!eof) return null
104
+ windowEnd = scanned
105
+ }
106
+ if (windowStart === -1) return [] // `offset` is past the end of the file
107
+
108
+ const out = Buffer.allocUnsafe(windowEnd - windowStart)
109
+ let got = 0
110
+ while (got < out.length) {
111
+ const n = readSync(fd, out, got, out.length - got, windowStart + got)
112
+ if (n <= 0) break
113
+ got += n
114
+ }
115
+ return out.toString('utf-8', 0, got).split('\n')
116
+ }
117
+
9
118
  export function createReadTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
10
119
  return {
11
120
  name: 'Read',
@@ -40,59 +149,56 @@ export function createReadTool(credentialConfig?: CredentialMaskingConfig): Tool
40
149
  throw err
41
150
  }
42
151
 
43
- let content: string
44
152
  try {
45
- const stat = fstatSync(fd)
46
- if (stat.isDirectory()) {
153
+ if (fstatSync(fd).isDirectory()) {
47
154
  return { success: false, content: '', error: `Path is a directory: ${filePath}` }
48
155
  }
49
- // Prevent OOM on single-line files (e.g. 500MB JSON blob)
50
- const MAX_FILE_SIZE = 50_000_000 // 50 MB
51
- if (stat.size > MAX_FILE_SIZE) {
52
- return {
53
- success: false,
54
- content: '',
55
- error: `File too large (${(stat.size / 1e6).toFixed(1)} MB). Max: 50 MB. Use offset/limit for large files.`,
156
+
157
+ const offset = (params.offset as number) || 0
158
+ const limit = (params.limit as number) || DEFAULT_LIMIT
159
+
160
+ // ── Credential masking ──
161
+ // The rule matches on path alone, so this is decided before any read: a
162
+ // masked file needs its whole content (the mask is content-shaped), while
163
+ // every other file is served straight from the fd. Reading the file
164
+ // *first* is what used to make `offset`/`limit` useless on large files —
165
+ // the size check ran before the window was ever applied.
166
+ let result: string | null = null
167
+ if (credentialConfig) {
168
+ const { matchCredentialFile, maskContent, CREDENTIAL_SENTINEL } =
169
+ await import('../../core/credential-masker')
170
+ const rule = matchCredentialFile(filePath, credentialConfig)
171
+ if (rule) {
172
+ const masked = maskContent(readFileSync(fd, 'utf-8'), rule)
173
+ // Full-file mask: return the sentinel immediately (offset/limit don't apply)
174
+ result =
175
+ masked === CREDENTIAL_SENTINEL
176
+ ? masked
177
+ : formatLines(windowFromString(masked, offset, limit), offset)
56
178
  }
57
179
  }
58
- content = readFileSync(fd, 'utf-8')
59
- } finally {
60
- closeSync(fd)
61
- }
62
180
 
63
- const offset = (params.offset as number) || 0
64
- const limit = (params.limit as number) || 2000
65
-
66
- // ── Read tracking: mark file as read for Write tool safety ──
67
- ctx.readFiles?.add(filePath)
68
-
69
- // ── Credential masking ──
70
- if (credentialConfig) {
71
- const { matchCredentialFile, maskContent, CREDENTIAL_SENTINEL } =
72
- await import('../../core/credential-masker')
73
- const rule = matchCredentialFile(filePath, credentialConfig)
74
- if (rule) {
75
- const masked = maskContent(content, rule)
76
- // Full-file mask: return sentinel immediately (offset/limit don't apply)
77
- if (masked === CREDENTIAL_SENTINEL) {
78
- return { success: true, content: masked }
181
+ if (result === null) {
182
+ const lines = windowFromFd(fd, offset, limit)
183
+ if (lines === null) {
184
+ return {
185
+ success: false,
186
+ content: '',
187
+ error:
188
+ `File too large: scanned ${MAX_SCAN_BYTES / 1e6} MB without reaching the end of lines ` +
189
+ `${offset}–${offset + limit - 1}. Narrow the range (smaller offset or limit), or use a ` +
190
+ `shell tool to slice the file.`,
191
+ }
79
192
  }
80
- // Extract mode: apply offset/limit on masked content
81
- const maskedLines = masked.split('\n')
82
- const maskedSlice = maskedLines.slice(offset, offset + limit)
83
- const maskedResult = maskedSlice
84
- .map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`)
85
- .join('\n')
86
- return { success: true, content: maskedResult }
193
+ result = formatLines(lines, offset)
87
194
  }
88
- }
89
195
 
90
- const lines = content.split('\n')
91
- const slice = lines.slice(offset, offset + limit)
92
- const result = slice
93
- .map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`)
94
- .join('\n')
95
- return { success: true, content: result }
196
+ // ── Read tracking: mark file as read for Write tool safety ──
197
+ ctx.readFiles?.add(filePath)
198
+ return { success: true, content: result }
199
+ } finally {
200
+ closeSync(fd)
201
+ }
96
202
  },
97
203
  }
98
204
  }
@@ -26,14 +26,31 @@ export interface CronJob {
26
26
  createdAt: string
27
27
  nextFire: string
28
28
  lastFired: string | null
29
+ /**
30
+ * The directory this job belongs to. The store is global (`~/.mipham/cron/`)
31
+ * and every session's poller reads all of it, so without this a job created in
32
+ * project A gets executed by whichever session in project B happens to be
33
+ * running — its prompt expands against the wrong codebase.
34
+ *
35
+ * Optional because files written before this field existed genuinely lack it;
36
+ * the poller treats a missing `cwd` as "any directory" so those keep firing.
37
+ */
38
+ cwd?: string
39
+ /** Session that created the job. Informational — the poller keys on `cwd`. */
40
+ sessionId?: string
29
41
  }
30
42
 
31
43
  function jobPath(id: string): string {
32
44
  return join(CRON_DIR, `${id}.json`)
33
45
  }
34
46
 
35
- function generateId(cron: string, prompt: string): string {
36
- return createHash('sha256').update(`${cron}:${prompt}`).digest('hex').slice(0, 12)
47
+ /**
48
+ * Job id includes `cwd`: keyed on `cron:prompt` alone, creating the same schedule
49
+ * in two directories wrote to the same file and the second silently replaced the
50
+ * first.
51
+ */
52
+ function generateId(cron: string, prompt: string, cwd: string): string {
53
+ return createHash('sha256').update(`${cwd}:${cron}:${prompt}`).digest('hex').slice(0, 12)
37
54
  }
38
55
 
39
56
  /**
@@ -101,11 +118,11 @@ export const cronCreateTool: ToolDefinition = {
101
118
  },
102
119
  required: ['cron', 'prompt'],
103
120
  },
104
- async execute(params, _ctx) {
121
+ async execute(params, ctx) {
105
122
  const cron = params.cron as string
106
123
  const prompt = params.prompt as string
107
124
  const recurring = params.recurring !== false
108
- const id = generateId(cron, prompt)
125
+ const id = generateId(cron, prompt, ctx.cwd)
109
126
 
110
127
  const now = new Date()
111
128
  const job: CronJob = {
@@ -116,6 +133,8 @@ export const cronCreateTool: ToolDefinition = {
116
133
  createdAt: now.toISOString(),
117
134
  nextFire: computeNextFire(cron, now),
118
135
  lastFired: null,
136
+ cwd: ctx.cwd,
137
+ sessionId: ctx.sessionId,
119
138
  }
120
139
 
121
140
  writeJob(job)
@@ -127,6 +146,7 @@ export const cronCreateTool: ToolDefinition = {
127
146
  `Created ${type} cron job.\n` +
128
147
  `ID: ${id}\n` +
129
148
  `Schedule: ${cron}\n` +
149
+ `Scoped to: ${ctx.cwd}\n` +
130
150
  `Prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? '...' : ''}"`,
131
151
  }
132
152
  },
@@ -182,11 +202,20 @@ export const cronListTool: ToolDefinition = {
182
202
  const lines = [`── Scheduled Cron Jobs (${jobs.length}) ──`, '']
183
203
  for (const j of jobs) {
184
204
  const type = j.recurring ? 'recurring' : 'one-shot'
185
- lines.push(`${j.id} ${j.cron} ${type}`)
205
+ // 目录要看得见:任务被限定在建立它的那个目录,从别的项目 `/schedule` 列出来时
206
+ // 用户得能看出它为什么不在自己这里跑。**没有 cwd 的旧文件恰恰相反** —— 它在任何
207
+ // 目录都会执行,而这里是唯一能看见那件事的地方,所以必须显式标出来(留空等于
208
+ // 把「无归属」显示成「和别的任务一样」)。
209
+ lines.push(`${j.id} ${j.cron} ${type} ${j.cwd ? `[${j.cwd}]` : '[无归属]'}`)
186
210
  lines.push(` ${j.prompt.slice(0, 100)}`)
187
211
  lines.push('')
188
212
  }
189
213
 
214
+ if (jobs.some((j) => !j.cwd)) {
215
+ lines.push('⚠️ [无归属] 是加 cwd 字段之前写的任务:没有目录可判,任何项目里都会执行。')
216
+ lines.push(' 消除办法:在目标目录里用 CronCreate 重建,或让 Mipham 用 CronDelete 删掉。')
217
+ }
218
+
190
219
  return { success: true, content: lines.join('\n') }
191
220
  },
192
221
  }
@@ -1,11 +1,12 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
1
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
4
  import { parse as parseYaml, stringify } from 'yaml'
5
+ import { atomicWriteFileSync } from '../../shared/atomic-write'
5
6
  import type { ToolDefinition } from '../../shared/index.ts'
6
7
 
7
- const MIPHAM_DIR = join(homedir(), '.mipham')
8
- const USER_CONFIG = join(MIPHAM_DIR, 'config.yml')
8
+ const MIPHAM_HOME = join(homedir(), '.mipham')
9
+ const USER_CONFIG = join(MIPHAM_HOME, 'config.yml')
9
10
 
10
11
  export const configTool: ToolDefinition = {
11
12
  name: 'Config',
@@ -26,7 +27,7 @@ export const configTool: ToolDefinition = {
26
27
  required: ['action'],
27
28
  },
28
29
  async execute(params, _ctx) {
29
- mkdirSync(MIPHAM_DIR, { recursive: true })
30
+ mkdirSync(MIPHAM_HOME, { recursive: true })
30
31
  const action = params.action as string
31
32
 
32
33
  let config: Record<string, unknown> = {}
@@ -60,7 +61,10 @@ export const configTool: ToolDefinition = {
60
61
  obj = obj[k] as Record<string, unknown>
61
62
  }
62
63
  obj[keys[keys.length - 1]!] = params.value
63
- writeFileSync(USER_CONFIG, stringify(config), 'utf-8')
64
+ // 原子写 + 0o600:这是整份 read-modify-write,裸 writeFileSync 原地截断 ——
65
+ // 崩在写中途就留下半截 YAML,而权限由 umask 决定(典型 0644),比同一份配置的
66
+ // 另一个写者 saveProviderApiKey(loader.ts,0600 原子写)更松。
67
+ atomicWriteFileSync(USER_CONFIG, stringify(config), { mode: 0o600 })
64
68
  return { success: true, content: `Set ${key} = ${params.value}` }
65
69
  }
66
70
 
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)
@@ -1099,7 +1119,16 @@ export function App({
1099
1119
  }
1100
1120
 
1101
1121
  return (
1102
- <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
+ >
1103
1132
  <Box flexDirection="column" padding={1} height="100%">
1104
1133
  {/* Workflow progress — auto-detects active workflows, renders nothing when idle */}
1105
1134
  <WorkflowProgress />