@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,287 @@
1
+ /**
2
+ * Launching the daemon as a detached process.
3
+ *
4
+ * The daemon must be started by *the same program* the user invoked:
5
+ * - source mode (`bun run bin/mipham.ts`): argv[0] is bun, argv[1] the script
6
+ * - compiled binary (`dist/mipham`): argv[0] is the binary itself
7
+ *
8
+ * The previous implementation hardcoded `spawn('bun', ['run', <path>])`, which
9
+ * broke both ways in a compiled binary: `bun` is not on PATH (that is the whole
10
+ * point of shipping a binary), and `import.meta.url` resolves to a `$bunfs`
11
+ * path that no freshly spawned interpreter can read.
12
+ */
13
+
14
+ import { spawn, type SpawnOptions } from 'node:child_process'
15
+ import { closeSync, mkdirSync, openSync, readFileSync, statSync } from 'node:fs'
16
+ import { homedir } from 'node:os'
17
+ import { dirname, join, resolve } from 'node:path'
18
+
19
+ /** argv sentinel that re-enters this program as a daemon. Not user-facing. */
20
+ export const DAEMON_ENTRY = '__daemon'
21
+
22
+ const DEFAULT_LOG_FILE = join(homedir(), '.mipham', 'daemon.log')
23
+
24
+ /**
25
+ * argv prefix that re-runs *this* program.
26
+ *
27
+ * Is an interpreter sitting in front of this program? That — and only that —
28
+ * is what decides whether the re-exec has to re-pass a script path. Measured on
29
+ * bun 1.3.14; these are the shapes bun actually produces:
30
+ *
31
+ * bun run bin/mipham.ts daemon start
32
+ * argv = ["<…>/bun.exe", "<abs>/bin/mipham.ts", "daemon", "start"]
33
+ * execPath = "<…>/bun.exe" ← argv[0] IS the interpreter
34
+ * dist/mipham daemon start
35
+ * argv = ["bun", "/$bunfs/root/mipham", "daemon", "start"]
36
+ * execPath = "<…>/dist/mipham" ← argv[0] is not; the entry lives inside
37
+ *
38
+ * Both shapes put exactly TWO entries in front of the user's own arguments,
39
+ * which is why the rest of bin/mipham.ts parses with `process.argv.slice(2)`
40
+ * in either mode. Only the re-exec prefix has to tell the two apart — and the
41
+ * compiled entry is a `$bunfs` path that exists only inside the binary, so no
42
+ * re-exec can ever name it: the artifact re-runs `execPath` with no script.
43
+ *
44
+ * (Until this was measured the discriminator was "does argv[1] end in .ts/.js",
45
+ * so it read the compiled `$bunfs` entry as the first *user argument* — and the
46
+ * `__daemon` branch became unreachable in the artifact while source mode, where
47
+ * the heuristic happens to be right, stayed green.)
48
+ */
49
+ export function selfArgvPrefix(
50
+ argv0: string | undefined,
51
+ argv1: string | undefined,
52
+ execPath: string,
53
+ ): string[] {
54
+ return argv0 === execPath && typeof argv1 === 'string' ? [execPath, resolve(argv1)] : [execPath]
55
+ }
56
+
57
+ /**
58
+ * The user-facing arguments, with the interpreter/script prefix stripped.
59
+ *
60
+ * Always two, in both modes — same model as the `process.argv.slice(2)` used
61
+ * throughout bin/mipham.ts. See `selfArgvPrefix` for the measured shapes.
62
+ */
63
+ export function userArgs(argv: readonly string[]): string[] {
64
+ return argv.slice(2)
65
+ }
66
+
67
+ export interface SpawnPlan {
68
+ command: string
69
+ args: string[]
70
+ options: SpawnOptions
71
+ logPath: string
72
+ }
73
+
74
+ /**
75
+ * Pure: computes the spawn call without performing it, so the shape (argv[0],
76
+ * missing cwd, detached) is assertable in a unit test that runs under the
77
+ * source tree — where the original bug does *not* reproduce.
78
+ *
79
+ * `argv0`/`argv1` default to the real `process.argv[0]`/`process.argv[1]` and
80
+ * `execPath` to the real `process.execPath`. The branch is
81
+ * `argv0 === execPath && typeof argv1 === 'string'` — *both* conjuncts, and the
82
+ * second is reachable without passing `argv0`: `planDaemonSpawn({ argv1:
83
+ * undefined })` keeps the default equality (true whenever the calling process is
84
+ * in source mode) and fails the `typeof`, so it gets the *compiled* shape.
85
+ * Both satisfied ⇒ the *source* shape (an interpreter in front of a script path,
86
+ * so the script element is re-sent); otherwise ⇒ the bare `[execPath]` of the
87
+ * *compiled* shape. To force the compiled shape, pass `argv0` as well.
88
+ */
89
+ export function planDaemonSpawn(
90
+ opts: {
91
+ argv0?: string | undefined
92
+ argv1?: string | undefined
93
+ execPath?: string
94
+ extraArgs?: string[]
95
+ logPath?: string
96
+ } = {},
97
+ ): SpawnPlan {
98
+ const argv0 = 'argv0' in opts ? opts.argv0 : process.argv[0]
99
+ const argv1 = 'argv1' in opts ? opts.argv1 : process.argv[1]
100
+ const execPath = opts.execPath ?? process.execPath
101
+ return {
102
+ command: execPath,
103
+ // `selfArgvPrefix` returns the child's *argv*, so it starts with argv[0] —
104
+ // but spawn() sets argv[0] from `command` itself, so that element has to be
105
+ // dropped here. Keeping it puts `execPath` at argv[1], where the runtime
106
+ // reads it as *the script to execute*: node/bun then parse the interpreter's
107
+ // own binary as source and die with `error: Unexpected <binary>` before the
108
+ // script ever runs. Verified identical on node v24 and bun 1.3.14.
109
+ args: [
110
+ ...selfArgvPrefix(argv0, argv1, execPath).slice(1),
111
+ DAEMON_ENTRY,
112
+ ...(opts.extraArgs ?? []),
113
+ ],
114
+ // No `cwd`: the child must inherit this process's working directory.
115
+ // `daemonRoot = process.cwd()` is the daemon's path allowlist boundary.
116
+ options: { detached: true, env: { ...process.env } },
117
+ logPath: opts.logPath ?? DEFAULT_LOG_FILE,
118
+ }
119
+ }
120
+
121
+ export interface DaemonLaunch {
122
+ ok: boolean
123
+ pid?: number
124
+ port?: number
125
+ reason?: string
126
+ }
127
+
128
+ interface DaemonStatusLike {
129
+ pid: number
130
+ port: number
131
+ }
132
+
133
+ /** Injection seam: real implementations by default, fakes in unit tests. */
134
+ export interface LaunchDeps {
135
+ spawnFn?: typeof spawn
136
+ getStatus?: () => DaemonStatusLike | null
137
+ sleep?: (ms: number) => Promise<void>
138
+ }
139
+
140
+ const READY_TIMEOUT_MS = 10_000
141
+ const POLL_INTERVAL_MS = 100
142
+ /** How long `restart` waits for the *old* daemon to go before refusing. */
143
+ const OLD_DAEMON_EXIT_TIMEOUT_MS = 10_000
144
+
145
+ async function defaultGetStatus(): Promise<DaemonStatusLike | null> {
146
+ const { getDaemonStatus } = await import('./index')
147
+ return getDaemonStatus()
148
+ }
149
+
150
+ function tailLog(logPath: string, maxBytes = 800): string {
151
+ try {
152
+ const size = statSync(logPath).size
153
+ const start = Math.max(0, size - maxBytes)
154
+ return readFileSync(logPath, 'utf-8').slice(start).trim()
155
+ } catch {
156
+ return ''
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Start the daemon detached and *wait until it is actually up*.
162
+ *
163
+ * Never reports success on an unknown child: the previous implementation
164
+ * printed "Daemon started (PID unknown …)" and exited 0 whenever the pid file
165
+ * was missing, which turned every launch failure into a silent one.
166
+ */
167
+ export async function startDetachedDaemon(
168
+ opts: { timeoutMs?: number; pollMs?: number; deps?: LaunchDeps } = {},
169
+ ): Promise<DaemonLaunch> {
170
+ const deps = opts.deps ?? {}
171
+ const spawnFn = deps.spawnFn ?? spawn
172
+ const getStatus = deps.getStatus ?? defaultGetStatus
173
+ const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)))
174
+
175
+ const already = await getStatus()
176
+ if (already) return { ok: true, pid: already.pid, port: already.port }
177
+
178
+ const plan = planDaemonSpawn()
179
+ mkdirSync(dirname(plan.logPath), { recursive: true, mode: 0o700 })
180
+
181
+ let spawnError: Error | null = null
182
+ let exitCode: number | null = null
183
+ // The child inherits this fd; closing ours does not close theirs.
184
+ const logFd = openSync(plan.logPath, 'a', 0o600)
185
+ let child: ReturnType<typeof spawn>
186
+ try {
187
+ child = spawnFn(plan.command, plan.args, {
188
+ ...plan.options,
189
+ stdio: ['ignore', 'ignore', logFd],
190
+ })
191
+ } finally {
192
+ closeSync(logFd)
193
+ }
194
+ child.on('error', (err: Error) => {
195
+ spawnError = err
196
+ })
197
+ child.on('exit', (code: number | null) => {
198
+ exitCode = code ?? -1
199
+ })
200
+ child.unref()
201
+
202
+ const deadline = Date.now() + (opts.timeoutMs ?? READY_TIMEOUT_MS)
203
+ const pollMs = opts.pollMs ?? POLL_INTERVAL_MS
204
+ while (Date.now() < deadline) {
205
+ await sleep(pollMs)
206
+ if (spawnError) {
207
+ const err: Error = spawnError
208
+ return { ok: false, reason: `daemon failed to spawn: ${err.message}` }
209
+ }
210
+ const status = await getStatus()
211
+ if (status) return { ok: true, pid: status.pid, port: status.port }
212
+ if (exitCode !== null) {
213
+ const tail = tailLog(plan.logPath)
214
+ return {
215
+ ok: false,
216
+ reason: `daemon exited with code ${exitCode}${tail ? `:\n${tail}` : ''}`,
217
+ }
218
+ }
219
+ }
220
+ return {
221
+ ok: false,
222
+ reason: `daemon did not become ready within ${opts.timeoutMs ?? READY_TIMEOUT_MS}ms (log: ${plan.logPath})`,
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Wait until the daemon we just signalled is *gone*.
228
+ *
229
+ * `restart` used to SIGTERM and then sleep a fixed 500 ms. That is a guess, and
230
+ * the guess is load-bearing: `startDetachedDaemon()` opens with a probe of
231
+ * `getStatus()` and returns whatever pid/port it finds there. A pid file the old
232
+ * daemon has not unlinked yet therefore reads as "already running" — `restart`
233
+ * then reports `Daemon restarted (PID: <old>)` and exits 0 having started
234
+ * nothing, and the old daemon finishes exiting afterwards, leaving none. That is
235
+ * the same "reports success with no daemon behind it" failure this module exists
236
+ * to remove, reintroduced on a new write point.
237
+ *
238
+ * Returns true once `getStatus()` goes null, false if it never does before the
239
+ * deadline. `false` is a refusal, not a warning: the caller must not start.
240
+ */
241
+ export async function waitForDaemonExit(
242
+ opts: { timeoutMs?: number; pollMs?: number; deps?: LaunchDeps } = {},
243
+ ): Promise<boolean> {
244
+ const deps = opts.deps ?? {}
245
+ const getStatus = deps.getStatus ?? defaultGetStatus
246
+ const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)))
247
+ const deadline = Date.now() + (opts.timeoutMs ?? OLD_DAEMON_EXIT_TIMEOUT_MS)
248
+ const pollMs = opts.pollMs ?? POLL_INTERVAL_MS
249
+
250
+ for (;;) {
251
+ const status = await getStatus()
252
+ if (!status) return true
253
+ if (Date.now() >= deadline) return false
254
+ await sleep(pollMs)
255
+ }
256
+ }
257
+
258
+ /**
259
+ * The daemon process body, shared by the `__daemon` branch of the compiled
260
+ * binary and by `bin/daemon.ts` (source mode). One implementation, two entry
261
+ * points — a second copy is how "two render paths, only one wired" starts.
262
+ *
263
+ * The two callers pass *different* argv slices and that is deliberate: the
264
+ * compiled binary is `[binary, '__daemon', ...]` while the source entry is
265
+ * `[bun, 'bin/daemon.ts', ...]`, so the `__daemon` branch strips the sentinel
266
+ * (and `bin/daemon.ts` relies on the default) rather than either of them
267
+ * handing over a raw `process.argv` tail.
268
+ */
269
+ export async function runDaemonProcess(argv: string[] = process.argv.slice(2)): Promise<void> {
270
+ for (let i = 0; i < argv.length; i++) {
271
+ if (argv[i] === '--port' && argv[i + 1]) process.env.MIPHAM_PORT = argv[i + 1]
272
+ if (argv[i] === '--bind' && argv[i + 1]) process.env.MIPHAM_BIND = argv[i + 1]
273
+ }
274
+
275
+ const { startDaemon, stopDaemon } = await import('./index')
276
+ const { port } = await startDaemon()
277
+
278
+ console.log(`Daemon running on http://127.0.0.1:${port}`)
279
+ console.log(`PID: ${process.pid}`)
280
+
281
+ const shutdown = async (): Promise<void> => {
282
+ await stopDaemon(true)
283
+ process.exit(0)
284
+ }
285
+ process.on('SIGTERM', () => void shutdown())
286
+ process.on('SIGINT', () => void shutdown())
287
+ }
@@ -387,6 +387,8 @@ export class RemoteEngine {
387
387
  type: 'tool_result',
388
388
  tool_use_id: msg.toolId,
389
389
  content: msg.content,
390
+ // 回程也要带上,否则字段出了 WS 就回不来 —— 接远端 daemon 的 CLI 依旧失明。
391
+ isError: msg.isError,
390
392
  }
391
393
  }
392
394
 
@@ -38,6 +38,7 @@ import { createDingtalkAdapter } from './dingtalk/adapter.js'
38
38
  import { createDingtalkApi } from './dingtalk/api.js'
39
39
  import type { DingtalkConfig } from './dingtalk/types.js'
40
40
  import { startHeartbeat } from './heartbeat'
41
+ import { wireDaemonEngine } from './engine-capabilities'
41
42
 
42
43
  interface ServerConfig {
43
44
  db: DaemonDatabase
@@ -236,6 +237,14 @@ export function createServer(config: ServerConfig): Server<WsData> {
236
237
  )
237
238
  const engine = new QueryEngine(sharedRegistry, context, sharedTools, permission)
238
239
  engine.setSessionId(sessionId)
240
+ // Same engine capabilities as the interactive CLI — see engine-capabilities.ts.
241
+ // Must stay immediately after setSessionId and before the cache insert, so no
242
+ // path can obtain a half-wired engine.
243
+ wireDaemonEngine(engine, {
244
+ cwd,
245
+ registry: sharedRegistry,
246
+ skillsPaths: daemonConfig.skills?.paths,
247
+ })
239
248
  engineCache.set(sessionId, engine)
240
249
  return engine
241
250
  }
@@ -126,6 +126,9 @@ export class SessionWorker {
126
126
  let totalInputTokens = 0
127
127
  let totalOutputTokens = 0
128
128
  let stopReason: string = 'end_turn'
129
+ // The provider hit its output ceiling at least once in this turn. Reported only
130
+ // when nothing worse happened — the three original values keep their precedence.
131
+ let truncated = false
129
132
 
130
133
  try {
131
134
  for await (const chunk of this.engine.process(prompt, signal)) {
@@ -152,10 +155,19 @@ export class SessionWorker {
152
155
  if (chunk.outputTokens) totalOutputTokens += chunk.outputTokens
153
156
  }
154
157
 
155
- // Stop signal from engine — includes 'stop' and 'error' types
156
- if (chunk.type === 'stop') {
157
- break
158
+ // A turn cut off at the provider's output ceiling must not be reported as a
159
+ // clean finish — downstream (the benchmark driver reads stopReason) would
160
+ // read a truncated trial as a legitimate ending.
161
+ if (chunk.type === 'stop' && chunk.truncated) {
162
+ truncated = true
158
163
  }
164
+
165
+ // Deliberately NO break on 'stop' — the chunk type is overloaded here.
166
+ // Providers emit a provider-level 'stop' unconditionally at the end of
167
+ // EVERY LLM stream, including tool-call turns; the engine still has to
168
+ // execute those tools and run the continuation turns after it. The
169
+ // engine's own terminal 'stop' is always followed by `return`, so
170
+ // letting the generator run out is the only correct termination.
159
171
  }
160
172
  } catch (err) {
161
173
  stopReason = 'error'
@@ -167,6 +179,11 @@ export class SessionWorker {
167
179
  stopReason = 'interrupted'
168
180
  }
169
181
 
182
+ // ── Truncation is the weakest signal: interrupted / error still win ──
183
+ if (truncated && stopReason === 'end_turn') {
184
+ stopReason = 'output_limit'
185
+ }
186
+
170
187
  // Step 4: Persist assistant response and finalize
171
188
  if (assistantContent) {
172
189
  // Save the final (or partial) assistant message
@@ -326,6 +343,7 @@ export class SessionWorker {
326
343
  sessionId: this.session.id,
327
344
  toolId: chunk.tool_use_id ?? 'unknown',
328
345
  content: chunk.content ?? '',
346
+ isError: chunk.isError ?? false,
329
347
  }
330
348
  return msg
331
349
  }
@@ -286,7 +286,7 @@
286
286
  "list_fetching": "Fetching current task list...",
287
287
  "create_title": "── Create Task ──",
288
288
  "default_title": "── Task Management ──",
289
- "default_body": "The AI manages task state via TaskCreate, TaskList, TaskUpdate, and TaskGet tools.\nTasks appear in the /tasks view and persist across the session."
289
+ "default_body": "The AI manages task state via the Task tool, using the \"create\", \"list\", \"update\", and \"get\" actions.\nTasks appear in the /tasks view and persist across the session."
290
290
  },
291
291
  "copy": {
292
292
  "confirmed": "✓ Copied {count} assistant response(s) to clipboard.",
@@ -304,8 +304,8 @@
304
304
  },
305
305
  "task_list": {
306
306
  "title": "── Background Tasks ──",
307
- "detected": "{count} task operations detected in this session.\n\nUse Task tool (TaskCreate / TaskUpdate / TaskList) to manage structured task tracking.",
308
- "no_tasks": "No tasks tracked yet. Use TaskCreate, TaskUpdate, and TaskList tools to manage structured tasks.",
307
+ "detected": "{count} task operations detected in this session.\n\nUse the Task tool (action \"create\" / \"update\" / \"list\") to manage structured task tracking.",
308
+ "no_tasks": "No tasks tracked yet. Use the Task tool with action \"create\", \"update\", or \"list\" to manage structured tasks.",
309
309
  "reference": "Quick reference:",
310
310
  "legacy_hint": "Type /todos for the legacy task interface."
311
311
  },
@@ -985,10 +985,9 @@
985
985
  "mcp": { "name": "MCP", "description": "Manage MCP server connections" }
986
986
  },
987
987
  "errors": {
988
- "tool_not_allowed": "Tool \"{name}\" requires user approval (permission: ask). The tool was not executed. Press Shift+Tab to switch permission mode, or run /permissions.",
989
- "tool_denied_deny_rule": "Tool \"{name}\" blocked by a deny rule (\"{pattern}\"). Deny rules override permission mode — try a different approach.",
990
- "tool_denied_ask_rule": "Tool \"{name}\" requires approval (ask rule: \"{pattern}\"). Approve when prompted, or adjust the rule via /permissions.",
991
- "tool_denied_mode": "Tool \"{name}\" requires approval under \"{mode}\" mode. Press Shift+Tab to switch permission mode, or run /permissions.",
988
+ "tool_denied_deny_rule": "Tool \"{name}\" blocked by a deny rule (\"{pattern}\"). Deny rules override permission mode — try a different approach, or drop the rule with: /permissions remove \"{pattern}\"",
989
+ "tool_denied_ask_rule": "Tool \"{name}\" requires approval (ask rule: \"{pattern}\"). Approve when prompted, or stop being asked with: /permissions allow \"{pattern}\"",
990
+ "tool_denied_mode": "Tool \"{name}\" requires approval under \"{mode}\" mode. Press Shift+Tab to switch permission mode, or stop being asked with: /permissions allow \"{name}\" (or a narrower \"{name}(arg)\")",
992
991
  "tool_blocked": "Tool \"{name}\" blocked by hook",
993
992
  "user_input_blocked": "User input blocked by hook.",
994
993
  "dlp_blocked": "Request blocked by DLP policy.",
@@ -286,7 +286,7 @@
286
286
  "list_fetching": "正在获取当前任务列表...",
287
287
  "create_title": "── 创建任务 ──",
288
288
  "default_title": "── 任务管理 ──",
289
- "default_body": "AI 通过 TaskCreate、TaskList、TaskUpdate 和 TaskGet 工具管理任务状态。\n任务显示在 /tasks 视图中,并在会话期间保持。"
289
+ "default_body": "AI 通过 Task 工具的 \"create\"、\"list\"、\"update\" 和 \"get\" action 管理任务状态。\n任务显示在 /tasks 视图中,并在会话期间保持。"
290
290
  },
291
291
  "copy": {
292
292
  "confirmed": "✓ 已复制 {count} 条助手回复到剪贴板。",
@@ -304,8 +304,8 @@
304
304
  },
305
305
  "task_list": {
306
306
  "title": "── 后台任务 ──",
307
- "detected": "在此会话中检测到 {count} 次任务操作。\n\n使用 Task 工具(TaskCreate / TaskUpdate / TaskList)管理结构化任务跟踪。",
308
- "no_tasks": "尚未跟踪任何任务。使用 TaskCreate、TaskUpdate 和 TaskList 工具管理结构化任务。",
307
+ "detected": "在此会话中检测到 {count} 次任务操作。\n\n使用 Task 工具(action \"create\" / \"update\" / \"list\")管理结构化任务跟踪。",
308
+ "no_tasks": "尚未跟踪任何任务。使用 Task 工具的 action \"create\"、\"update\" 或 \"list\" 管理结构化任务。",
309
309
  "reference": "快速参考:",
310
310
  "legacy_hint": "输入 /todos 使用旧版任务界面。"
311
311
  },
@@ -985,10 +985,9 @@
985
985
  "mcp": { "name": "MCP", "description": "管理 MCP 服务器连接" }
986
986
  },
987
987
  "errors": {
988
- "tool_not_allowed": "工具 \"{name}\" 需要用户批准(权限:ask),未执行。按 Shift+Tab 切换权限模式,或运行 /permissions。",
989
- "tool_denied_deny_rule": "工具 \"{name}\" 被拒绝规则(\"{pattern}\")阻止。拒绝规则优先于权限模式 — 请改用其他方式。",
990
- "tool_denied_ask_rule": "工具 \"{name}\" 需要批准(ask 规则:\"{pattern}\")。请在提示时批准,或通过 /permissions 调整规则。",
991
- "tool_denied_mode": "工具 \"{name}\" 在 \"{mode}\" 模式下需要批准。按 Shift+Tab 切换权限模式,或运行 /permissions。",
988
+ "tool_denied_deny_rule": "工具 \"{name}\" 被拒绝规则(\"{pattern}\")阻止。拒绝规则优先于权限模式 — 请改用其他方式,或移除该规则:/permissions remove \"{pattern}\"",
989
+ "tool_denied_ask_rule": "工具 \"{name}\" 需要批准(ask 规则:\"{pattern}\")。请在提示时批准,或不再询问:/permissions allow \"{pattern}\"",
990
+ "tool_denied_mode": "工具 \"{name}\" 在 \"{mode}\" 模式下需要批准。按 Shift+Tab 切换权限模式,或不再询问:/permissions allow \"{name}\"(更窄的 \"{name}(arg)\" 亦可)",
992
991
  "tool_blocked": "工具 \"{name}\" 被钩子拦截",
993
992
  "user_input_blocked": "用户输入被钩子拦截。",
994
993
  "dlp_blocked": "请求被 DLP 策略阻止。",
package/src/index.tsx CHANGED
@@ -49,8 +49,12 @@ import { HookEngine } from './core/hooks'
49
49
  import { loadHookConfigs } from './core/hooks-config'
50
50
  import { ArtifactServer } from './artifacts/server'
51
51
  import { getMetrics } from './core/metrics'
52
+ import { initTelemetry, enableTelemetryNow } from './telemetry/index'
53
+ import { wasPrompted, markPrompted, isInteractive, setTelemetryEnabled } from './telemetry/consent'
54
+ import { officialEndpointHost } from './telemetry/endpoint'
52
55
  import { getWorkspaceTrust } from './core/workspace-trust'
53
- import { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
56
+ import { ARTIFACT_PORT } from './shared/constants'
57
+ import { artifactsRoot } from './artifacts/paths'
54
58
  import { AgentViewManager } from './agent-view/agent-view-manager'
55
59
  import { AgentViewDashboard } from './agent-view/dashboard'
56
60
  import { createT } from './i18n-core/t'
@@ -135,6 +139,72 @@ async function checkWorkspaceTrust(): Promise<void> {
135
139
  }
136
140
  }
137
141
 
142
+ /**
143
+ * One-time telemetry opt-in.
144
+ *
145
+ * Asked once per machine and never again — including when the answer is "no",
146
+ * which is why the marker (`telemetry.promptedAt`) is separate from the consent
147
+ * itself: otherwise "asked and declined" and "never asked" would look identical
148
+ * and the question would reappear on every launch.
149
+ *
150
+ * Two ways this deliberately does *not* ask:
151
+ * - when a marker already exists;
152
+ * - when there is no TTY to answer on (piped stdin, daemon, CI) — a prompt
153
+ * there would block forever on input that cannot arrive.
154
+ *
155
+ * In the second case the marker is left **unwritten**: burning it on a headless
156
+ * run would mean a user whose first invocation was `mipham -p "…"` is never
157
+ * offered the choice at all. Staying off by default costs nothing, and a later
158
+ * interactive run still asks.
159
+ */
160
+ async function promptForTelemetryConsent(): Promise<void> {
161
+ if (wasPrompted()) return
162
+ if (!isInteractive()) return
163
+
164
+ const rl = readline.createInterface({
165
+ input: process.stdin,
166
+ output: process.stderr, // stderr, so it cannot corrupt stdout rendering
167
+ })
168
+
169
+ try {
170
+ process.stderr.write('\n')
171
+ process.stderr.write(' Telemetry — optional, and off unless you say yes.\n')
172
+ process.stderr.write('\n')
173
+ process.stderr.write(' If enabled, Mipham Code sends counts of which commands and\n')
174
+ process.stderr.write(' tools you use, plus the app version, runtime and platform.\n')
175
+ // The destination, adjacent to what travels — consent is to an address,
176
+ // not to a category of data. Derived from the endpoint constant so a
177
+ // future move cannot leave this line naming the previous host.
178
+ process.stderr.write(` They are sent to ${officialEndpointHost()}.\n`)
179
+ process.stderr.write(' It does not send your code, prompts, file contents, file\n')
180
+ process.stderr.write(' paths, project names or API keys.\n')
181
+ process.stderr.write('\n')
182
+ process.stderr.write(' Change it any time with /telemetry on or /telemetry off.\n')
183
+ process.stderr.write('\n')
184
+
185
+ const answer = await new Promise<string>((resolve) => {
186
+ rl.question(' Enable anonymous usage statistics? [y/N]: ', (a) =>
187
+ resolve(a.trim().toLowerCase()),
188
+ )
189
+ })
190
+
191
+ const accepted = answer === 'y' || answer === 'yes'
192
+ setTelemetryEnabled(accepted)
193
+ markPrompted()
194
+ if (accepted) {
195
+ // Take effect in this session, not the next one.
196
+ enableTelemetryNow()
197
+ process.stderr.write(' ✓ Telemetry enabled. Thank you.\n\n')
198
+ } else {
199
+ process.stderr.write(' ✓ Telemetry stays off.\n\n')
200
+ }
201
+ } catch {
202
+ // Never let a question about diagnostics stop the CLI from starting.
203
+ } finally {
204
+ rl.close()
205
+ }
206
+ }
207
+
138
208
  // ── SetupGate: first-run wizard → App bridge ──
139
209
 
140
210
  interface SetupGateProps {
@@ -239,6 +309,11 @@ export async function runApp(options: RunOptions): Promise<void> {
239
309
  getMetrics().cliInvocations.inc()
240
310
  getMetrics().activeSessions.inc()
241
311
 
312
+ // Telemetry: resolve consent, install crash handlers, register the exit
313
+ // flush, and drain anything a previous session left queued. Does nothing
314
+ // observable when telemetry is off (the shipped default).
315
+ initTelemetry()
316
+
242
317
  // ── Workspace Trust Check ──
243
318
  await checkWorkspaceTrust()
244
319
 
@@ -524,7 +599,7 @@ export async function runApp(options: RunOptions): Promise<void> {
524
599
  }
525
600
 
526
601
  // Start artifact server (lazy — first artifact creation triggers listening)
527
- const artifactsDir = join(process.cwd(), MIPHAM_DIR, ARTIFACTS_DIR)
602
+ const artifactsDir = artifactsRoot(process.cwd())
528
603
  const artifactServer = new ArtifactServer(artifactsDir, ARTIFACT_PORT)
529
604
 
530
605
  // Create query engine
@@ -717,6 +792,11 @@ export async function runApp(options: RunOptions): Promise<void> {
717
792
  const hasProjectConfig = existsSync(join(process.cwd(), '.mipham', 'config.yml'))
718
793
  const needsSetup = !hasUserConfig && !hasProjectConfig
719
794
 
795
+ // Only ask when the first-run wizard is *not* about to take over the
796
+ // terminal — two prompts queued at once is a worse first impression than a
797
+ // question asked on the second launch.
798
+ if (!needsSetup) await promptForTelemetryConsent()
799
+
720
800
  const { waitUntilExit } = render(
721
801
  React.createElement(I18nProvider, {
722
802
  locale,
package/src/mcp/client.ts CHANGED
@@ -221,7 +221,7 @@ export class McpClient {
221
221
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
222
222
  try {
223
223
  try {
224
- connection.transport.close()
224
+ await connection.transport.close()
225
225
  } catch {
226
226
  /* ok */
227
227
  }
@@ -339,7 +339,9 @@ export class McpClient {
339
339
 
340
340
  this.cancelToolsRefresh(conn)
341
341
  try {
342
- conn.transport.close()
342
+ // disconnect() is synchronous and returns the removed names, so this close
343
+ // is best-effort and must not be awaited.
344
+ void conn.transport.close()
343
345
  } catch {
344
346
  /* best effort */
345
347
  }
@@ -9,7 +9,7 @@ import {
9
9
  } from 'node:fs'
10
10
  import { join } from 'node:path'
11
11
  import { homedir } from 'node:os'
12
- import { execSync } from 'node:child_process'
12
+ import { execFileSync } from 'node:child_process'
13
13
  import { validatePlugin } from './plugin-validator'
14
14
 
15
15
  const PLUGIN_DIR = join(homedir(), '.mipham', 'plugins')
@@ -110,11 +110,22 @@ export class PluginManager {
110
110
  'utf-8',
111
111
  )
112
112
 
113
- execSync(`npm install ${packageName} --prefix "${stagingDir}" --no-save`, {
114
- encoding: 'utf-8',
115
- stdio: 'pipe',
116
- timeout: 60_000,
117
- })
113
+ // `--ignore-scripts`: without it, installing a plugin runs that package's
114
+ // preinstall/install/postinstall hooks as the current user — arbitrary
115
+ // code execution from any npm package, reachable via `/install-plugin`.
116
+ // A plugin only needs to *be* files on disk; it never needs a build step
117
+ // of its own to be loaded from here. `execFileSync` (argv array, no
118
+ // shell) keeps the command line independent of packageName, so package
119
+ // name validation is not the only thing standing between us and a shell.
120
+ execFileSync(
121
+ 'npm',
122
+ ['install', packageName, '--prefix', stagingDir, '--no-save', '--ignore-scripts'],
123
+ {
124
+ encoding: 'utf-8',
125
+ stdio: 'pipe',
126
+ timeout: 60_000,
127
+ },
128
+ )
118
129
 
119
130
  // npm installs the package into <stagingDir>/node_modules/<packageName>/ —
120
131
  // validate there, then flatten it to the plugin dir root so its layout