@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.
- 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/commands/project.ts +92 -12
- package/src/config/keys-manager.ts +3 -3
- package/src/config/loader.ts +82 -1
- package/src/core/context.ts +10 -2
- package/src/core/engine.ts +32 -4
- package/src/core/metrics.ts +8 -0
- package/src/core/paths.ts +79 -0
- package/src/core/permission-rules.ts +145 -13
- package/src/core/permission.ts +3 -0
- package/src/core/session-log.ts +11 -2
- 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 +5 -0
- package/src/daemon/server.ts +9 -0
- package/src/daemon/session-worker.ts +7 -4
- package/src/i18n-core/locales/en-US.json +6 -7
- package/src/i18n-core/locales/zh-CN.json +6 -7
- package/src/index.tsx +79 -0
- package/src/mcp/client.ts +109 -8
- package/src/providers/anthropic.ts +2 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +15 -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/exec/bash.ts +6 -4
- package/src/tools/exec/enter-worktree.ts +6 -5
- package/src/tools/exec/exit-worktree.ts +10 -5
- package/src/tools/exec/git.ts +18 -8
- package/src/tools/system/config.ts +3 -3
- package/src/ui/app.tsx +47 -11
- package/src/ui/commands.ts +159 -34
- package/src/workflow/primitives/agent.ts +4 -2
- 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
|
@@ -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 =
|
|
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 —
|
|
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,
|
package/src/tools/exec/bash.ts
CHANGED
|
@@ -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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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(
|
|
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 .
|
|
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(
|
|
60
|
-
const allowedPrefix = resolve(
|
|
60
|
+
const worktreePath = resolve(worktreeRoot(cwd), name)
|
|
61
|
+
const allowedPrefix = resolve(worktreeRoot(cwd))
|
|
61
62
|
|
|
62
|
-
// Defense-in-depth: verify resolved path
|
|
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 .
|
|
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:
|
|
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
|
|
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
|
|
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 (!
|
|
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
|
}
|
package/src/tools/exec/git.ts
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
8
|
-
const USER_CONFIG = join(
|
|
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(
|
|
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)
|
|
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)
|
|
@@ -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 />
|