@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
@@ -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,
@@ -1,7 +1,8 @@
1
1
  import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { ToolDefinition } from '../../shared/index.ts'
4
- import { ARTIFACTS_DIR, ARTIFACT_MAX_SIZE } from '../../shared/constants'
4
+ import { ARTIFACT_MAX_SIZE } from '../../shared/constants'
5
+ import { artifactsRoot } from '../../artifacts/paths'
5
6
  import { addToManifest, readManifest, archiveVersion } from '../../artifacts/manifest'
6
7
 
7
8
  const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/
@@ -57,8 +58,10 @@ export const artifactTool: ToolDefinition = {
57
58
  }
58
59
  }
59
60
 
60
- // Determine output paths
61
- const baseDir = join(ctx.cwd, ARTIFACTS_DIR)
61
+ // Determine output paths — the server's root, the manifest's home and this
62
+ // directory are one and the same; computing it here is what made the URL a
63
+ // guaranteed 404 before.
64
+ const baseDir = artifactsRoot(ctx.cwd)
62
65
  const sessionDir = join(baseDir, ctx.sessionId)
63
66
  mkdirSync(sessionDir, { recursive: true })
64
67
 
@@ -100,7 +103,7 @@ export const artifactTool: ToolDefinition = {
100
103
  const prev = manifestPre.artifacts.find((a) => a.name === name && a.sessionId === ctx.sessionId)
101
104
  const versionCount = prev?.versionCount || (isUpdate ? 1 : undefined)
102
105
 
103
- addToManifest(
106
+ const { quarantined } = addToManifest(
104
107
  baseDir,
105
108
  {
106
109
  name,
@@ -124,6 +127,12 @@ export const artifactTool: ToolDefinition = {
124
127
  const galleryUrl = port ? `http://localhost:${port}` : undefined
125
128
  const versionLine = archivedVersion ? ` Prev archived as: ${archivedVersion}` : ''
126
129
  const galleryLine = galleryUrl ? `Gallery: ${galleryUrl}` : ''
130
+ // The index was unreadable and got moved aside, so this publish started from
131
+ // nothing: say it here, or the user reads "saved" and never learns that the
132
+ // rest of the index is now a file next to it.
133
+ const warnLine = quarantined
134
+ ? ` ⚠️ Index was unreadable; previous index kept at ${quarantined}`
135
+ : ''
127
136
 
128
137
  return {
129
138
  success: true,
@@ -132,6 +141,7 @@ export const artifactTool: ToolDefinition = {
132
141
  ` URL: ${url}`,
133
142
  ` Size: ${size.toLocaleString()} bytes`,
134
143
  versionLine,
144
+ warnLine,
135
145
  galleryLine,
136
146
  '',
137
147
  `Open in browser: /artifact open ${name}`,
@@ -1,7 +1,9 @@
1
+ import { resolve } from 'node:path'
1
2
  import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
2
3
  import { sanitizeCommand } from '../../shared/sanitize.ts'
3
4
  import { DANGEROUS_GIT_PATTERNS } from './git.ts'
4
- import { isUncOrDevicePath } from '../../security/path.ts'
5
+ import { isUncOrDevicePath, isWithin } from '../../security/path.ts'
6
+ import { findWorktreeMarker } from '../../core/paths.ts'
5
7
  import type { Service } from '../../vajra'
6
8
  import { toolKey } from '../seam'
7
9
  import { withValidation } from '../validation'
@@ -307,6 +309,36 @@ function parseErrorLocations(stderr: string): ErrorLocation[] {
307
309
  return unique.slice(0, 10)
308
310
  }
309
311
 
312
+ /**
313
+ * 找出命令里第一个 `cd` 到工作区之外的**目标原样字符串**(供错误文案用);
314
+ * 无逃逸返回 null。判定边界是 `worktreeRoot`(项目根),不是 `cwd` ——
315
+ * 既有行为即如此:`cd <项目内其它目录>` 放行(见 test/tools/exec.test.ts
316
+ * 「allows cd inside the project from a .mipham worktree」)。
317
+ *
318
+ * 此前三个缺陷,其中两个是活的绕过:
319
+ * - 相对路径用字符串拼接而非 `resolve`:`cd ../../../..` 拼出来的串仍以
320
+ * cwd 开头,于是被当成「在区内」放行 —— **活绕过**;
321
+ * - `command.match(...)` 非全局,只看第一个 `cd`,`cd sub && cd /etc` 的
322
+ * 后半段完全不检查 —— **活绕过**;
323
+ * - 归属判定用 `resolved.startsWith(cwd)` 字符串前缀比较,`/proj/w1-evil`
324
+ * 会被判成「在 /proj/w1 里」;它只被 root 那个析取项兜住才没显形,故一并
325
+ * 改成按路径分段比较的 `isWithin`。
326
+ */
327
+ export function resolveWorktreeEscape(
328
+ cwd: string,
329
+ worktreeRoot: string,
330
+ command: string,
331
+ ): string | null {
332
+ const cdRe = /\bcd\s+(?:"([^"]+)"|'([^']+)'|([^\s;|&]+))/g
333
+ for (const m of command.matchAll(cdRe)) {
334
+ const target = m[1] ?? m[2] ?? m[3]
335
+ if (!target) continue
336
+ const resolved = resolve(cwd, target)
337
+ if (!isWithin(resolved, cwd) && !isWithin(resolved, worktreeRoot)) return target
338
+ }
339
+ return null
340
+ }
341
+
310
342
  export function createBashTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
311
343
  return {
312
344
  name: 'Bash',
@@ -334,26 +366,18 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
334
366
  const timeout = Math.min((params.timeout as number) || 120_000, 600_000)
335
367
 
336
368
  // 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))
340
- // Detect cd to absolute paths outside the worktree
341
- const cdEscapePattern = /\bcd\s+(?:"([^"]+)"|'([^']+)'|([^\s;|&]+))/
342
- const cdMatch = command.match(cdEscapePattern)
343
- if (cdMatch) {
344
- const target = cdMatch[1] || cdMatch[2] || cdMatch[3] || ''
345
- // Resolve relative to cwd
346
- const resolved = target.startsWith('/')
347
- ? target
348
- : `${ctx.cwd}/${target}`.replace(/\/\.\//g, '/')
349
- if (!resolved.startsWith(ctx.cwd) && !resolved.startsWith(worktreeRoot + '/')) {
350
- return {
351
- success: false,
352
- content: '',
353
- error:
354
- `Worktree isolation: cannot cd outside worktree directory. ` +
355
- `Attempted: ${target}. Use tools within the worktree only.`,
356
- }
369
+ // 标记取自 core/paths.ts:新目录与历史 .claude/worktrees/ 都认,
370
+ // 隔离度只增不减(只认新前缀会让旧工作树失去保护)。
371
+ const worktreeMarker = findWorktreeMarker(ctx.cwd)
372
+ if (worktreeMarker) {
373
+ const escapeTarget = resolveWorktreeEscape(ctx.cwd, worktreeMarker.root, command)
374
+ if (escapeTarget !== null) {
375
+ return {
376
+ success: false,
377
+ content: '',
378
+ error:
379
+ `Worktree isolation: cannot cd outside worktree directory. ` +
380
+ `Attempted: ${escapeTarget}. Use tools within the worktree only.`,
357
381
  }
358
382
  }
359
383
  }
@@ -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,7 @@
1
+ import { resolve } from 'node:path'
1
2
  import type { ToolDefinition } from '../../shared/index.ts'
3
+ import { findWorktreeMarker } from '../../core/paths.ts'
4
+ import { isWithin } from '../../security/path.ts'
2
5
 
3
6
  // P0-4 (v2.1.222 alignment): Regex-based word-boundary patterns replace
4
7
  // fragile substring matching. Each pattern describes what it blocks.
@@ -52,21 +55,33 @@ export const DANGEROUS_GIT_PATTERNS: Array<{ pattern: RegExp; description: strin
52
55
  * when operating in a worktree context.
53
56
  */
54
57
  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]
58
+ // 标记取自 core/paths.ts:新目录与历史 .claude/worktrees/ 都认,隔离度只增不减。
59
+ const marker = findWorktreeMarker(cwd)
60
+ if (!marker) return null
61
+
62
+ // Extract the project root (everything before the worktree marker)
63
+ const worktreeRoot = marker.root
64
+
65
+ // Detect git commands that reference the main checkout path.
66
+ // NOTE: no `\b` before the leading `-` — `\b` needs a word/non-word
67
+ // transition and `-` is itself a non-word char, so `\b--work-tree=` never
68
+ // matched anything and this whole check was silently dead. `-C` needs an
69
+ // explicit boundary because without one it would match inside a path.
70
+ const mainCheckoutPaths = [
71
+ /--work-tree=([^\s]+)/g,
72
+ /--git-dir=([^\s]+)/g,
73
+ /(?:^|\s)-C\s+([^\s]+)/g,
74
+ ]
63
75
 
64
76
  for (const pathPattern of mainCheckoutPaths) {
65
77
  let match: RegExpExecArray | null
66
78
  while ((match = pathPattern.exec(command)) !== null) {
67
79
  const refPath = match[1]!
68
- // If the referenced path is outside the worktree, block it
69
- if (!refPath.startsWith(cwd) && !refPath.startsWith(worktreeRoot + '/')) {
80
+ // 归一后按**路径分段**判归属,不用字符串前缀:此前 `refPath.startsWith(cwd)`
81
+ // 从不解析 `..`,`--work-tree=/proj/../etc` 因为「以 /proj/ 开头」被放行,
82
+ // 而 git 拿到的是 /etc。判据与 Bash 守卫(resolveWorktreeEscape)同一套。
83
+ const resolved = resolve(cwd, refPath)
84
+ if (!isWithin(resolved, cwd) && !isWithin(resolved, worktreeRoot)) {
70
85
  return `Git command references path outside worktree: ${refPath}`
71
86
  }
72
87
  }
@@ -16,17 +16,23 @@ export async function runSearch(
16
16
  cmd: string[],
17
17
  cwd: string,
18
18
  timeoutMs: number,
19
- ): Promise<{ stdout: string; timedOut: boolean; exitCode: number | null }> {
19
+ ): Promise<{ stdout: string; stderr: string; timedOut: boolean; exitCode: number | null }> {
20
20
  const proc = Bun.spawn(cmd, { cwd, stdout: 'pipe', stderr: 'pipe' })
21
21
  let timedOut = false
22
22
  const timer = setTimeout(() => {
23
23
  timedOut = true
24
24
  proc.kill()
25
25
  }, timeoutMs)
26
- const stdout = await new Response(proc.stdout).text()
26
+ // 两条管道必须**并发**读。先读满 stdout 再读 stderr 会在子进程写满
27
+ // stderr(~64 KB 管道缓冲)时死锁:它阻塞在 write 上不退出,stdout 也就
28
+ // 永远读不到 EOF —— 只能等超时兜底,而超时会把「慢」和「错」说成同一件事。
29
+ const [stdout, stderr] = await Promise.all([
30
+ new Response(proc.stdout).text(),
31
+ new Response(proc.stderr).text(),
32
+ ])
27
33
  await proc.exited
28
34
  clearTimeout(timer)
29
- return { stdout, timedOut, exitCode: proc.exitCode }
35
+ return { stdout, stderr, timedOut, exitCode: proc.exitCode }
30
36
  }
31
37
 
32
38
  /** grep 输出上限:超过则显式截断并附标记(不能静默丢内容——模型会误以为看全了)。 */
@@ -107,7 +113,9 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
107
113
  if (exitCode === 0) {
108
114
  return {
109
115
  success: true,
110
- content: maskSearchOutput(stdout || '(no matches)', credentialConfig, 'heading'),
116
+ content: truncateGrepOutput(
117
+ maskSearchOutput(stdout || '(no matches)', credentialConfig, 'heading'),
118
+ ),
111
119
  }
112
120
  }
113
121
  // rg exit 2 (error, e.g. permission denied on protected dirs) — do NOT
@@ -117,12 +125,11 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
117
125
  if (stdout && stdout.trim()) {
118
126
  return {
119
127
  success: true,
120
- content: maskSearchOutput(
121
- stdout +
122
- '\n\n(rg exited 2 — some paths unreadable; narrow scope for complete results)',
123
- credentialConfig,
124
- 'heading',
125
- ),
128
+ // 先截断正文、再拼注解:注解拼在截断之内的话,它自己会被切掉,
129
+ // 模型拿到的就是一句没头没尾的提示。
130
+ content:
131
+ truncateGrepOutput(maskSearchOutput(stdout, credentialConfig, 'heading')) +
132
+ '\n\n(rg exited 2 — some paths unreadable; narrow scope for complete results)',
126
133
  }
127
134
  }
128
135
  return {
@@ -153,7 +160,11 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
153
160
  '+',
154
161
  ]
155
162
  try {
156
- const { stdout, timedOut, exitCode } = await runSearch(grepArgs, ctx.cwd, GREP_TIMEOUT_MS)
163
+ const { stdout, stderr, timedOut, exitCode } = await runSearch(
164
+ grepArgs,
165
+ ctx.cwd,
166
+ GREP_TIMEOUT_MS,
167
+ )
157
168
  if (timedOut) {
158
169
  return {
159
170
  success: false,
@@ -161,7 +172,17 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
161
172
  error: `Grep timed out after ${GREP_TIMEOUT_MS / 1000}s — narrow scope with "path" and "include".`,
162
173
  }
163
174
  }
164
- if (exitCode === 1) return { success: true, content: '(no matches)' }
175
+ // 退出码 1 在 find 这里是**两件事**:真的没搜到,和「根本没搜成」
176
+ // —— 目录不可读、grep 正则非法、grep 不在 PATH,BSD find 全都退 1。
177
+ // 后者一律伴随 stderr,所以用 stderr 而非退出码分辨;不加这一刀,
178
+ // 模型会被告知「没有匹配」,而真相是这次搜索压根没跑起来。
179
+ if (exitCode === 1) {
180
+ const err = stderr.trim()
181
+ if (err) {
182
+ return { success: false, content: '', error: `Search failed: ${err.slice(0, 500)}` }
183
+ }
184
+ return { success: true, content: '(no matches)' }
185
+ }
165
186
  if (exitCode === 0) {
166
187
  return {
167
188
  success: true,
@@ -171,7 +192,10 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
171
192
  return {
172
193
  success: false,
173
194
  content: '',
174
- error: 'grep failed. Install ripgrep: brew install ripgrep',
195
+ error:
196
+ `grep failed (exit ${exitCode})` +
197
+ (stderr.trim() ? `: ${stderr.trim().slice(0, 500)}` : '') +
198
+ '. Install ripgrep: brew install ripgrep',
175
199
  }
176
200
  } catch {
177
201
  return {