@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,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
|
+
}
|
|
@@ -227,6 +227,9 @@ export class RemoteEngine {
|
|
|
227
227
|
/** No-op: reasoning effort is managed by the daemon session. */
|
|
228
228
|
setEffort(_level: string): void {}
|
|
229
229
|
|
|
230
|
+
/** No-op: file-read tracking lives with the daemon's own engine. */
|
|
231
|
+
resetFileTracking(): void {}
|
|
232
|
+
|
|
230
233
|
/** Remote mode has no local agent registry. */
|
|
231
234
|
getAgentRegistry(): undefined {
|
|
232
235
|
return undefined
|
|
@@ -384,6 +387,8 @@ export class RemoteEngine {
|
|
|
384
387
|
type: 'tool_result',
|
|
385
388
|
tool_use_id: msg.toolId,
|
|
386
389
|
content: msg.content,
|
|
390
|
+
// 回程也要带上,否则字段出了 WS 就回不来 —— 接远端 daemon 的 CLI 依旧失明。
|
|
391
|
+
isError: msg.isError,
|
|
387
392
|
}
|
|
388
393
|
}
|
|
389
394
|
|
package/src/daemon/server.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -152,10 +152,12 @@ export class SessionWorker {
|
|
|
152
152
|
if (chunk.outputTokens) totalOutputTokens += chunk.outputTokens
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
155
|
+
// Deliberately NO break on 'stop' — the chunk type is overloaded here.
|
|
156
|
+
// Providers emit a provider-level 'stop' unconditionally at the end of
|
|
157
|
+
// EVERY LLM stream, including tool-call turns; the engine still has to
|
|
158
|
+
// execute those tools and run the continuation turns after it. The
|
|
159
|
+
// engine's own terminal 'stop' is always followed by `return`, so
|
|
160
|
+
// letting the generator run out is the only correct termination.
|
|
159
161
|
}
|
|
160
162
|
} catch (err) {
|
|
161
163
|
stopReason = 'error'
|
|
@@ -326,6 +328,7 @@ export class SessionWorker {
|
|
|
326
328
|
sessionId: this.session.id,
|
|
327
329
|
toolId: chunk.tool_use_id ?? 'unknown',
|
|
328
330
|
content: chunk.content ?? '',
|
|
331
|
+
isError: chunk.isError ?? false,
|
|
329
332
|
}
|
|
330
333
|
return msg
|
|
331
334
|
}
|
|
@@ -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
|
|
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 (
|
|
308
|
-
"no_tasks": "No tasks tracked yet. Use
|
|
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
|
-
"
|
|
989
|
-
"
|
|
990
|
-
"
|
|
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 通过
|
|
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 工具(
|
|
308
|
-
"no_tasks": "尚未跟踪任何任务。使用
|
|
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
|
-
"
|
|
989
|
-
"
|
|
990
|
-
"
|
|
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,6 +49,9 @@ 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
56
|
import { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
|
|
54
57
|
import { AgentViewManager } from './agent-view/agent-view-manager'
|
|
@@ -135,6 +138,72 @@ async function checkWorkspaceTrust(): Promise<void> {
|
|
|
135
138
|
}
|
|
136
139
|
}
|
|
137
140
|
|
|
141
|
+
/**
|
|
142
|
+
* One-time telemetry opt-in.
|
|
143
|
+
*
|
|
144
|
+
* Asked once per machine and never again — including when the answer is "no",
|
|
145
|
+
* which is why the marker (`telemetry.promptedAt`) is separate from the consent
|
|
146
|
+
* itself: otherwise "asked and declined" and "never asked" would look identical
|
|
147
|
+
* and the question would reappear on every launch.
|
|
148
|
+
*
|
|
149
|
+
* Two ways this deliberately does *not* ask:
|
|
150
|
+
* - when a marker already exists;
|
|
151
|
+
* - when there is no TTY to answer on (piped stdin, daemon, CI) — a prompt
|
|
152
|
+
* there would block forever on input that cannot arrive.
|
|
153
|
+
*
|
|
154
|
+
* In the second case the marker is left **unwritten**: burning it on a headless
|
|
155
|
+
* run would mean a user whose first invocation was `mipham -p "…"` is never
|
|
156
|
+
* offered the choice at all. Staying off by default costs nothing, and a later
|
|
157
|
+
* interactive run still asks.
|
|
158
|
+
*/
|
|
159
|
+
async function promptForTelemetryConsent(): Promise<void> {
|
|
160
|
+
if (wasPrompted()) return
|
|
161
|
+
if (!isInteractive()) return
|
|
162
|
+
|
|
163
|
+
const rl = readline.createInterface({
|
|
164
|
+
input: process.stdin,
|
|
165
|
+
output: process.stderr, // stderr, so it cannot corrupt stdout rendering
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
process.stderr.write('\n')
|
|
170
|
+
process.stderr.write(' Telemetry — optional, and off unless you say yes.\n')
|
|
171
|
+
process.stderr.write('\n')
|
|
172
|
+
process.stderr.write(' If enabled, Mipham Code sends counts of which commands and\n')
|
|
173
|
+
process.stderr.write(' tools you use, plus the app version, runtime and platform.\n')
|
|
174
|
+
// The destination, adjacent to what travels — consent is to an address,
|
|
175
|
+
// not to a category of data. Derived from the endpoint constant so a
|
|
176
|
+
// future move cannot leave this line naming the previous host.
|
|
177
|
+
process.stderr.write(` They are sent to ${officialEndpointHost()}.\n`)
|
|
178
|
+
process.stderr.write(' It does not send your code, prompts, file contents, file\n')
|
|
179
|
+
process.stderr.write(' paths, project names or API keys.\n')
|
|
180
|
+
process.stderr.write('\n')
|
|
181
|
+
process.stderr.write(' Change it any time with /telemetry on or /telemetry off.\n')
|
|
182
|
+
process.stderr.write('\n')
|
|
183
|
+
|
|
184
|
+
const answer = await new Promise<string>((resolve) => {
|
|
185
|
+
rl.question(' Enable anonymous usage statistics? [y/N]: ', (a) =>
|
|
186
|
+
resolve(a.trim().toLowerCase()),
|
|
187
|
+
)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const accepted = answer === 'y' || answer === 'yes'
|
|
191
|
+
setTelemetryEnabled(accepted)
|
|
192
|
+
markPrompted()
|
|
193
|
+
if (accepted) {
|
|
194
|
+
// Take effect in this session, not the next one.
|
|
195
|
+
enableTelemetryNow()
|
|
196
|
+
process.stderr.write(' ✓ Telemetry enabled. Thank you.\n\n')
|
|
197
|
+
} else {
|
|
198
|
+
process.stderr.write(' ✓ Telemetry stays off.\n\n')
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// Never let a question about diagnostics stop the CLI from starting.
|
|
202
|
+
} finally {
|
|
203
|
+
rl.close()
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
138
207
|
// ── SetupGate: first-run wizard → App bridge ──
|
|
139
208
|
|
|
140
209
|
interface SetupGateProps {
|
|
@@ -239,6 +308,11 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
239
308
|
getMetrics().cliInvocations.inc()
|
|
240
309
|
getMetrics().activeSessions.inc()
|
|
241
310
|
|
|
311
|
+
// Telemetry: resolve consent, install crash handlers, register the exit
|
|
312
|
+
// flush, and drain anything a previous session left queued. Does nothing
|
|
313
|
+
// observable when telemetry is off (the shipped default).
|
|
314
|
+
initTelemetry()
|
|
315
|
+
|
|
242
316
|
// ── Workspace Trust Check ──
|
|
243
317
|
await checkWorkspaceTrust()
|
|
244
318
|
|
|
@@ -717,6 +791,11 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
717
791
|
const hasProjectConfig = existsSync(join(process.cwd(), '.mipham', 'config.yml'))
|
|
718
792
|
const needsSetup = !hasUserConfig && !hasProjectConfig
|
|
719
793
|
|
|
794
|
+
// Only ask when the first-run wizard is *not* about to take over the
|
|
795
|
+
// terminal — two prompts queued at once is a worse first impression than a
|
|
796
|
+
// question asked on the second launch.
|
|
797
|
+
if (!needsSetup) await promptForTelemetryConsent()
|
|
798
|
+
|
|
720
799
|
const { waitUntilExit } = render(
|
|
721
800
|
React.createElement(I18nProvider, {
|
|
722
801
|
locale,
|