@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/tools/file/read.ts
CHANGED
|
@@ -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
|
-
|
|
46
|
-
if (stat.isDirectory()) {
|
|
153
|
+
if (fstatSync(fd).isDirectory()) {
|
|
47
154
|
return { success: false, content: '', error: `Path is a directory: ${filePath}` }
|
|
48
155
|
}
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
8
|
-
const USER_CONFIG = join(
|
|
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(
|
|
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
|
|
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)
|
|
210
|
-
|
|
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()
|
|
221
|
-
|
|
222
|
-
|
|
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 />
|