@miphamai/cli 0.81.6 → 0.81.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -9
- package/bin/daemon.ts +7 -32
- package/bin/mipham.ts +43 -29
- package/package.json +5 -2
- package/skills/standard/mipham-code-setup.SKILL.md +3 -3
- package/src/agent/sub-agent.ts +12 -1
- package/src/artifacts/manifest.ts +90 -34
- package/src/artifacts/paths.ts +19 -0
- package/src/artifacts/server.ts +48 -8
- package/src/commands/project.ts +92 -12
- package/src/config/keys-manager.ts +10 -11
- package/src/config/loader.ts +82 -1
- package/src/config/preferences.ts +5 -2
- package/src/core/context.ts +10 -2
- package/src/core/cron-poller.ts +30 -6
- package/src/core/engine.ts +19 -4
- package/src/core/metrics.ts +8 -0
- package/src/core/paths.ts +79 -0
- package/src/core/permission-rules.ts +261 -17
- package/src/core/permission.ts +3 -0
- package/src/core/session-log.ts +55 -3
- package/src/core/session-store.ts +11 -1
- package/src/daemon/engine-capabilities.ts +131 -0
- package/src/daemon/index.ts +4 -1
- package/src/daemon/launch.ts +287 -0
- package/src/daemon/remote-engine.ts +2 -0
- package/src/daemon/server.ts +9 -0
- package/src/daemon/session-worker.ts +21 -3
- package/src/i18n-core/locales/en-US.json +6 -7
- package/src/i18n-core/locales/zh-CN.json +6 -7
- package/src/index.tsx +82 -2
- package/src/mcp/client.ts +4 -2
- package/src/plugin/plugin-manager.ts +17 -6
- package/src/providers/anthropic.ts +28 -2
- package/src/providers/openai-compat.ts +14 -1
- package/src/security/path.ts +6 -1
- package/src/shared/atomic-write.ts +28 -5
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +24 -0
- package/src/skills/bundled-skills.ts +1 -1
- package/src/telemetry/consent.ts +209 -0
- package/src/telemetry/crash.ts +197 -0
- package/src/telemetry/endpoint.ts +82 -0
- package/src/telemetry/index.ts +153 -0
- package/src/telemetry/payload.ts +141 -0
- package/src/telemetry/queue.ts +95 -0
- package/src/telemetry/redact.ts +127 -0
- package/src/telemetry/transport.ts +81 -0
- package/src/tools/agent/workflow.ts +11 -4
- package/src/tools/artifact/artifact.ts +14 -4
- package/src/tools/exec/bash.ts +45 -21
- package/src/tools/exec/enter-worktree.ts +6 -5
- package/src/tools/exec/exit-worktree.ts +10 -5
- package/src/tools/exec/git.ts +25 -10
- package/src/tools/file/grep.ts +37 -13
- package/src/tools/file/read.ts +151 -45
- package/src/tools/scheduling/cron.ts +34 -5
- package/src/tools/system/config.ts +9 -5
- package/src/ui/app.tsx +40 -11
- package/src/ui/commands.ts +186 -45
- package/src/workflow/primitives/agent.ts +4 -2
- package/src/artifacts/versioning.ts +0 -127
- package/src/core/task-runner-tasks.json +0 -14
- package/src/core/task-runner.ts +0 -163
- package/src/skills/mipham/runtime.ts +0 -66
- package/src/skills/standard/runtime.ts +0 -62
|
@@ -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
|
+
}
|
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
|
}
|
|
@@ -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
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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,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 {
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|