@miphamai/cli 0.81.6 → 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.
Files changed (51) 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/commands/project.ts +92 -12
  8. package/src/config/keys-manager.ts +3 -3
  9. package/src/config/loader.ts +82 -1
  10. package/src/core/context.ts +10 -2
  11. package/src/core/engine.ts +19 -4
  12. package/src/core/metrics.ts +8 -0
  13. package/src/core/paths.ts +79 -0
  14. package/src/core/permission-rules.ts +121 -13
  15. package/src/core/permission.ts +3 -0
  16. package/src/core/session-log.ts +11 -2
  17. package/src/daemon/engine-capabilities.ts +131 -0
  18. package/src/daemon/index.ts +4 -1
  19. package/src/daemon/launch.ts +287 -0
  20. package/src/daemon/remote-engine.ts +2 -0
  21. package/src/daemon/server.ts +9 -0
  22. package/src/daemon/session-worker.ts +7 -4
  23. package/src/i18n-core/locales/en-US.json +6 -7
  24. package/src/i18n-core/locales/zh-CN.json +6 -7
  25. package/src/index.tsx +79 -0
  26. package/src/mcp/client.ts +4 -2
  27. package/src/providers/anthropic.ts +2 -0
  28. package/src/shared/package-info.ts +1 -1
  29. package/src/shared/types.ts +15 -0
  30. package/src/skills/bundled-skills.ts +1 -1
  31. package/src/telemetry/consent.ts +209 -0
  32. package/src/telemetry/crash.ts +197 -0
  33. package/src/telemetry/endpoint.ts +82 -0
  34. package/src/telemetry/index.ts +153 -0
  35. package/src/telemetry/payload.ts +141 -0
  36. package/src/telemetry/queue.ts +95 -0
  37. package/src/telemetry/redact.ts +127 -0
  38. package/src/telemetry/transport.ts +81 -0
  39. package/src/tools/agent/workflow.ts +11 -4
  40. package/src/tools/exec/bash.ts +6 -4
  41. package/src/tools/exec/enter-worktree.ts +6 -5
  42. package/src/tools/exec/exit-worktree.ts +10 -5
  43. package/src/tools/exec/git.ts +18 -8
  44. package/src/tools/system/config.ts +3 -3
  45. package/src/ui/app.tsx +40 -11
  46. package/src/ui/commands.ts +159 -34
  47. package/src/workflow/primitives/agent.ts +4 -2
  48. package/src/core/task-runner-tasks.json +0 -14
  49. package/src/core/task-runner.ts +0 -163
  50. package/src/skills/mipham/runtime.ts +0 -66
  51. package/src/skills/standard/runtime.ts +0 -62
@@ -0,0 +1,153 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { getMetrics } from '../core/metrics'
3
+ import { resolveTelemetry, getOrCreateInstallId, type TelemetryConsent } from './consent'
4
+ import { enqueueSync } from './queue'
5
+ import { buildSessionEvent } from './payload'
6
+ import { buildCrashEvent, hasCrashed, installCrashHandlers } from './crash'
7
+ import { flushQueueInBackground } from './transport'
8
+
9
+ /**
10
+ * Telemetry facade.
11
+ *
12
+ * Data flow — collecting and sending are deliberately decoupled, because
13
+ * `process.on('exit')` cannot await async work:
14
+ *
15
+ * during a session counters accumulate in memory (`getMetrics()`); zero I/O
16
+ * at exit synchronous: whitelist snapshot + session metadata → queue
17
+ * next startup async, fire-and-forget: drain queue → POST → ack or keep
18
+ *
19
+ * Nothing here is a parallel counting system. `getMetrics()` already exists and
20
+ * is the single source for what actually got used; telemetry only *reads* it,
21
+ * and only for the whitelisted families (`payload.ts`).
22
+ */
23
+
24
+ interface TelemetryState {
25
+ consent: TelemetryConsent
26
+ installId: string
27
+ startedAt: number
28
+ /** Guards against a double flush when both SIGINT and a normal exit fire. */
29
+ flushed: boolean
30
+ }
31
+
32
+ let state: TelemetryState | null = null
33
+
34
+ /**
35
+ * The exit flush, kept by reference so `resetTelemetryState` can remove
36
+ * exactly this one — `removeAllListeners('exit')` would detach the runner's
37
+ * own teardown and every other exit path in the process.
38
+ */
39
+ let onExit: (() => void) | null = null
40
+
41
+ /**
42
+ * Start telemetry for this process.
43
+ *
44
+ * Safe to call once at startup. Returns the resolved consent so callers (and
45
+ * `/telemetry status`) can report why telemetry is on or off.
46
+ */
47
+ export function initTelemetry(cwd: string = process.cwd()): TelemetryConsent {
48
+ const consent = resolveTelemetry(cwd)
49
+ const installId = consent.enabled ? getOrCreateInstallId(cwd) : ''
50
+
51
+ state = { consent, installId, startedAt: Date.now(), flushed: false }
52
+
53
+ // Crash capture is installed unconditionally, even when telemetry is off:
54
+ // it is what keeps a crash from becoming a silent hang. When telemetry is
55
+ // off the record is simply never uploaded.
56
+ installCrashHandlers()
57
+
58
+ // Likewise unconditional. The flush has to be registered while telemetry is
59
+ // still off, because the user can turn it on mid-session (`/telemetry on` →
60
+ // `enableTelemetryNow`) and that session must still be reported. Registering
61
+ // it here rather than in the TUI's own `process.on('exit')` also means the
62
+ // remote-attach and non-interactive paths are covered, not just the one that
63
+ // reaches the TUI setup.
64
+ if (!onExit) {
65
+ onExit = () => shutdownTelemetry()
66
+ process.on('exit', onExit)
67
+ }
68
+
69
+ if (consent.enabled) flushQueueInBackground(consent.endpoint)
70
+
71
+ return consent
72
+ }
73
+
74
+ /**
75
+ * Record a slash-command invocation.
76
+ *
77
+ * Unconditional, like every other counter in the registry — `getMetrics()` is
78
+ * a metrics registry used by the artifact server too, not a telemetry buffer.
79
+ * Whether the snapshot ever leaves the machine is decided at exit.
80
+ */
81
+ export function recordCommand(name: string): void {
82
+ getMetrics().commandCalls.inc({ command_name: name })
83
+ }
84
+
85
+ /** Record a tool invocation that bypassed the engine's `executeTool` funnel. */
86
+ export function recordToolCall(name: string): void {
87
+ getMetrics().toolCalls.inc({ tool_name: name })
88
+ }
89
+
90
+ export function isTelemetryEnabled(): boolean {
91
+ return state?.consent.enabled === true
92
+ }
93
+
94
+ export function getTelemetryConsent(): TelemetryConsent | null {
95
+ return state?.consent ?? null
96
+ }
97
+
98
+ /**
99
+ * Form and queue the session payload. **Synchronous by requirement** — this is
100
+ * called from inside `process.on('exit')`, where an await would never settle.
101
+ *
102
+ * A no-op when telemetry is off, which is what makes "off" mean no collection,
103
+ * no queue file, and no network.
104
+ */
105
+ export function shutdownTelemetry(now: Date = new Date()): void {
106
+ const current = state
107
+ if (!current || current.flushed) return
108
+ current.flushed = true
109
+ if (!current.consent.enabled) return
110
+
111
+ try {
112
+ if (hasCrashed()) {
113
+ const crash = buildCrashEvent(current.installId)
114
+ if (crash) enqueueSync(crash)
115
+ }
116
+ enqueueSync(
117
+ buildSessionEvent({
118
+ installId: current.installId,
119
+ startedAt: current.startedAt,
120
+ endedAt: now.getTime(),
121
+ crashed: hasCrashed(),
122
+ }),
123
+ )
124
+ } catch {
125
+ // A telemetry write must never break the exit path.
126
+ }
127
+ }
128
+
129
+ /** Test seam: drop facade state and unhook the exit flush. */
130
+ export function resetTelemetryState(): void {
131
+ state = null
132
+ if (onExit) process.off('exit', onExit)
133
+ onExit = null
134
+ }
135
+
136
+ /**
137
+ * Persist the user's answer and start reporting immediately, so `/telemetry on`
138
+ * takes effect in the running session rather than at the next launch.
139
+ */
140
+ export function enableTelemetryNow(cwd: string = process.cwd()): void {
141
+ const consent = resolveTelemetry(cwd)
142
+ state = {
143
+ consent,
144
+ installId: getOrCreateInstallId(cwd),
145
+ startedAt: state?.startedAt ?? Date.now(),
146
+ flushed: false,
147
+ }
148
+ }
149
+
150
+ /** A fresh anonymous id, for `/telemetry reset-id`. */
151
+ export function newInstallId(): string {
152
+ return randomUUID()
153
+ }
@@ -0,0 +1,141 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { getMetrics } from '../core/metrics'
3
+ import { PACKAGE_VERSION } from '../shared/package-info'
4
+ import type { QueuedEvent } from './queue'
5
+ import { runtimeTag } from './redact'
6
+
7
+ /**
8
+ * Payload construction.
9
+ *
10
+ * The counters are a **whitelist, not a whole-registry dump**. A dump would
11
+ * silently enrol every future counter into the uploaded payload, and a future
12
+ * counter's labels may well carry PII. A whitelist is explicit, can be written
13
+ * into the public data dictionary, and can be locked down by a test asserting
14
+ * the emitted key set is a subset of it.
15
+ *
16
+ * See `docs/telemetry.md` for the public data dictionary.
17
+ */
18
+
19
+ /**
20
+ * Bumped whenever the payload shape changes, so the endpoint can evolve.
21
+ *
22
+ * v2 (this version) = the crash event no longer carries `stackFrames`.
23
+ * The collector never kept them — "dimensional aggregates only" leaves a frame
24
+ * string nowhere to live — so sending them bought ~3 KB per crash of transfer
25
+ * and a privacy surface in exchange for nothing. The frame *count* stays: it is
26
+ * what keeps "this stack was short" distinguishable from "this stack was cut",
27
+ * and it is a single integer.
28
+ *
29
+ * The ordering was not optional: the collector had to accept v1 **before** any
30
+ * client stopped sending frames. Checked in `apps/telemetry/src/schema.ts`,
31
+ * which lists both versions.
32
+ */
33
+ export const SCHEMA_VERSION = 2
34
+
35
+ /**
36
+ * Counter family names cleared for upload, as they appear in the registry.
37
+ * Adding to this list is a privacy decision — it must be matched by a
38
+ * data-dictionary entry in the same commit.
39
+ */
40
+ export const COUNTER_WHITELIST = [
41
+ 'mipham_code_cli_invocations_total',
42
+ 'mipham_code_command_calls_total',
43
+ 'mipham_code_tool_calls_total',
44
+ 'mipham_code_crsi_rule_applications_total',
45
+ 'mipham_code_sis_interceptions_total',
46
+ ] as const
47
+
48
+ /** Maximum length of a label value kept in the payload. */
49
+ const MAX_LABEL_LENGTH = 64
50
+
51
+ /** `Counter.toJSON()` reports `object`; this is the shape it actually returns. */
52
+ interface CounterJson {
53
+ name: string
54
+ series: { labels: string; value: number }[]
55
+ }
56
+
57
+ /**
58
+ * Pull the first `="…"` value out of the registry's formatted label string
59
+ * (e.g. `{tool_name="Bash"}` → `Bash`). Whitelisted families carry at most one
60
+ * label, so first-value is the whole story.
61
+ */
62
+ function firstLabelValue(labels: string): string {
63
+ const match = /="((?:[^"\\]|\\.)*)"/.exec(labels)
64
+ if (!match || match[1] === undefined) return ''
65
+ return match[1]
66
+ .replace(/\\(.)/g, '$1')
67
+ .replace(/[^\x20-\x7e]/g, '')
68
+ .slice(0, MAX_LABEL_LENGTH)
69
+ }
70
+
71
+ /**
72
+ * Snapshot the whitelisted counters into a flat `Record<string, number>`.
73
+ *
74
+ * Keys are the family name with the `mipham_code_` prefix and `_total` suffix
75
+ * stripped, plus the label value when there is one:
76
+ * `mipham_code_cli_invocations_total` → `cli_invocations`
77
+ * `mipham_code_tool_calls_total{tool_name="Bash"}` → `tool_calls.Bash`
78
+ *
79
+ * Label cardinality: `tool_name` is closed by construction — there are only as
80
+ * many tool names as the registry declares. `command_name` is **not**: it comes
81
+ * from user input, so the caller is responsible for collapsing unrecognised
82
+ * names (see `commandLabelFor` in `ui/commands.ts`). `MAX_LABEL_LENGTH` below
83
+ * truncates a value, it does not bound how many keys there are.
84
+ */
85
+ export function snapshotCounters(): Record<string, number> {
86
+ const metrics = getMetrics()
87
+ const { counters } = metrics.toJSON() as { counters: CounterJson[] }
88
+ const out: Record<string, number> = {}
89
+
90
+ for (const counter of counters) {
91
+ const family = counter.name
92
+ if (!(COUNTER_WHITELIST as readonly string[]).includes(family)) continue
93
+
94
+ const short = family.replace(/^mipham_code_/, '').replace(/_total$/, '')
95
+
96
+ for (const series of counter.series) {
97
+ const value = firstLabelValue(series.labels)
98
+ const key = value ? `${short}.${value}` : short
99
+ out[key] = (out[key] ?? 0) + series.value
100
+ }
101
+ }
102
+
103
+ return out
104
+ }
105
+
106
+ /** `darwin/arm64`, `linux/x64`, … — coarse platform identity, no hostname. */
107
+ function platformTag(): string {
108
+ return `${process.platform}/${process.arch}`
109
+ }
110
+
111
+ export interface SessionMeta {
112
+ installId: string
113
+ startedAt: number
114
+ endedAt: number
115
+ crashed: boolean
116
+ }
117
+
118
+ /**
119
+ * The `session` event: one per CLI launch, formed at exit.
120
+ *
121
+ * Contains counters and coarse environment facts only. It never carries
122
+ * conversation content, prompts, file contents, paths, user names, hostnames,
123
+ * API keys, or environment variable values.
124
+ */
125
+ export function buildSessionEvent(meta: SessionMeta, now: Date = new Date()): QueuedEvent {
126
+ return {
127
+ id: randomUUID(),
128
+ kind: 'session',
129
+ payload: {
130
+ installId: meta.installId,
131
+ schemaVersion: SCHEMA_VERSION,
132
+ occurredAt: now.toISOString(),
133
+ appVersion: PACKAGE_VERSION,
134
+ runtime: runtimeTag(),
135
+ platform: platformTag(),
136
+ sessionDurationMs: Math.max(0, meta.endedAt - meta.startedAt),
137
+ crashed: meta.crashed,
138
+ counters: snapshotCounters(),
139
+ },
140
+ }
141
+ }
@@ -0,0 +1,95 @@
1
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { atomicWriteFileSync } from '../shared/atomic-write'
4
+ import { telemetryDir } from './consent'
5
+
6
+ /**
7
+ * Local queue of telemetry events awaiting upload.
8
+ *
9
+ * Every operation here is **synchronous**, and that is a requirement, not a
10
+ * convenience: the session payload is written from inside
11
+ * `process.on('exit')`, which cannot await async work. Every other exit-path
12
+ * write in the repo is synchronous for the same reason (`persistSession` in
13
+ * `index.tsx`, `EffectivenessTracker.persist()`).
14
+ *
15
+ * Sending is therefore decoupled from collecting: events are queued at exit and
16
+ * uploaded on the *next* startup, where async is allowed.
17
+ */
18
+
19
+ /** Entries kept before the oldest are dropped. Bounds disk use on a machine that can never reach an endpoint. */
20
+ export const QUEUE_MAX_ENTRIES = 100
21
+
22
+ export interface QueuedEvent {
23
+ /** Stable per-event id, used to acknowledge a successful upload. */
24
+ id: string
25
+ kind: 'session' | 'crash'
26
+ payload: Record<string, unknown>
27
+ }
28
+
29
+ export function queuePath(): string {
30
+ return join(telemetryDir(), 'queue.jsonl')
31
+ }
32
+
33
+ /**
34
+ * Read the queue. A missing file is an empty queue; an unparsable *line* is
35
+ * skipped rather than thrown, so one truncated write can never wedge every
36
+ * future session.
37
+ */
38
+ export function readQueue(): QueuedEvent[] {
39
+ const path = queuePath()
40
+ if (!existsSync(path)) return []
41
+
42
+ let raw: string
43
+ try {
44
+ raw = readFileSync(path, 'utf-8')
45
+ } catch {
46
+ return []
47
+ }
48
+
49
+ const events: QueuedEvent[] = []
50
+ for (const line of raw.split('\n')) {
51
+ const trimmed = line.trim()
52
+ if (!trimmed) continue
53
+ try {
54
+ const parsed = JSON.parse(trimmed) as QueuedEvent
55
+ if (parsed && typeof parsed === 'object' && typeof parsed.id === 'string') events.push(parsed)
56
+ } catch {
57
+ /* skip the damaged line, keep the rest */
58
+ }
59
+ }
60
+ return events
61
+ }
62
+
63
+ /**
64
+ * Replace the queue wholesale, atomically.
65
+ *
66
+ * Capacity is enforced by dropping the *oldest* entries — the newest events
67
+ * describe the current version, which is the version worth hearing about.
68
+ * Silent: a queue write must never surface as a user-visible error, and must
69
+ * never take down an exit path.
70
+ */
71
+ export function writeQueue(events: QueuedEvent[]): void {
72
+ try {
73
+ const kept = events.length > QUEUE_MAX_ENTRIES ? events.slice(-QUEUE_MAX_ENTRIES) : events
74
+ const body = kept.map((e) => JSON.stringify(e)).join('\n')
75
+ // atomicWriteFileSync does not create parents (same as the settings writer,
76
+ // which mkdirs first) — without this the queue silently never materialises
77
+ // on a fresh machine, where ~/.mipham/telemetry/ does not exist yet.
78
+ mkdirSync(telemetryDir(), { recursive: true })
79
+ atomicWriteFileSync(queuePath(), body.length > 0 ? body + '\n' : '', { mode: 0o600 })
80
+ } catch {
81
+ /* best-effort by design */
82
+ }
83
+ }
84
+
85
+ /** Append one event, dropping the oldest if the queue is at capacity. */
86
+ export function enqueueSync(event: QueuedEvent): void {
87
+ writeQueue([...readQueue(), event])
88
+ }
89
+
90
+ /** Drop events whose ids are no longer wanted (i.e. they uploaded successfully). */
91
+ export function ackQueue(ids: Iterable<string>): void {
92
+ const done = new Set(ids)
93
+ if (done.size === 0) return
94
+ writeQueue(readQueue().filter((e) => !done.has(e.id)))
95
+ }
@@ -0,0 +1,127 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { homedir } from 'node:os'
3
+
4
+ /**
5
+ * Redaction for crash reports.
6
+ *
7
+ * Nothing in this module is reusable from the existing scrubbers: the four
8
+ * families in the repo (`credential-masker/`, `security/gate.ts`,
9
+ * `shared/sanitize.ts`, `skills/sanitizer.ts`) all match *credential shapes* —
10
+ * token prefixes, JWTs, `sk-ant-…`. None of them removes a user name out of a
11
+ * path. The only home→`~` helper is `ui/chat.tsx` `displayCwd()`, which is
12
+ * unexported, has zero call sites, and only ever looks at `process.cwd()` — it
13
+ * cannot touch a path inside an error object or a stack frame.
14
+ *
15
+ * So the guarantee below has to be carried by tests, not by reuse.
16
+ */
17
+
18
+ /** Frames kept by default. `Error.stackTraceLimit` is 10; we allow headroom. */
19
+ export const MAX_STACK_FRAMES = 15
20
+
21
+ /** Marker substituted for the working directory — hides the project name. */
22
+ const CWD_MARKER = '<cwd>'
23
+
24
+ /** Marker substituted for the home directory. */
25
+ const HOME_MARKER = '~'
26
+
27
+ /**
28
+ * Marker substituted for the first directory segment under home.
29
+ *
30
+ * Home replacement alone still discloses the user's own naming: a frame in
31
+ * `~/acme-secret-merger-2026/src/a.ts` would render as exactly that. The
32
+ * data dictionary promises no project names, so the segment right after `~` is
33
+ * collapsed too. Structure and the frame's own file name survive — which is
34
+ * what makes a frame useful — but the user's label for a directory does not.
35
+ */
36
+ const DIR_MARKER = '<dir>'
37
+
38
+ function escapeRegExp(s: string): string {
39
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
40
+ }
41
+
42
+ /**
43
+ * Replace absolute home/cwd paths with their markers, anywhere in the string.
44
+ *
45
+ * Order matters and is not arbitrary: cwd is normally *inside* home, so
46
+ * replacing home first would leave `~/proj/src/a.ts` — which leaks the project
47
+ * directory name. Replacing cwd first yields `<cwd>/src/a.ts`, which does not.
48
+ * This ordering is asserted by a test.
49
+ *
50
+ * Paths that are neither under cwd nor under home are left alone. Those are
51
+ * system/package locations (e.g. `/usr/local/lib/node_modules/…`) that contain
52
+ * no user data, and keeping them is what makes a frame useful for debugging.
53
+ */
54
+ export function redactText(input: string, cwd?: string): string {
55
+ let out = input
56
+
57
+ if (cwd && cwd.length > 1) {
58
+ out = out.replace(new RegExp(escapeRegExp(cwd), 'g'), CWD_MARKER)
59
+ }
60
+
61
+ let home = ''
62
+ try {
63
+ home = homedir()
64
+ } catch {
65
+ /* homedir() can throw when neither HOME nor the passwd entry resolves */
66
+ }
67
+ if (home && home.length > 1) {
68
+ out = out.replace(new RegExp(escapeRegExp(home), 'g'), HOME_MARKER)
69
+ }
70
+
71
+ // Collapse the first segment after `~` (both separators — Windows stacks use
72
+ // backslashes) so a user-chosen directory name is not disclosed. The matched
73
+ // separator is echoed back rather than normalised, so a Windows path does not
74
+ // come out with mixed separators.
75
+ out = out.replace(
76
+ /~([\\/])[^\\/\s:)]+/g,
77
+ (_match, sep: string) => `${HOME_MARKER}${sep}${DIR_MARKER}`,
78
+ )
79
+
80
+ return out
81
+ }
82
+
83
+ export interface RedactedStack {
84
+ /** Redacted frame lines, truncated to `maxFrames`. */
85
+ frames: string[]
86
+ /** Frame count *before* truncation — truncation loses data, this bounds the loss. */
87
+ frameCount: number
88
+ }
89
+
90
+ /**
91
+ * Extract and redact the frame lines of a V8 stack.
92
+ *
93
+ * The first line of a stack is `"TypeError: <message>"` — it is dropped
94
+ * outright, never redacted, because the message routinely embeds paths and user
95
+ * data. `crash.ts` sends only `hashMessage(error.message)` instead.
96
+ */
97
+ export function redactStack(
98
+ stack: string,
99
+ opts: { cwd?: string; maxFrames?: number } = {},
100
+ ): RedactedStack {
101
+ const { cwd = process.cwd(), maxFrames = MAX_STACK_FRAMES } = opts
102
+
103
+ const frameLines = stack.split('\n').filter((line) => /^\s*at\s/.test(line))
104
+ const redacted = frameLines.map((line) => redactText(line.trim(), cwd))
105
+
106
+ return {
107
+ frames: redacted.slice(0, maxFrames),
108
+ frameCount: frameLines.length,
109
+ }
110
+ }
111
+
112
+ /**
113
+ * sha256 of the error message, first 16 hex chars.
114
+ *
115
+ * Only the digest is reportable. The plaintext message is not — same reason the
116
+ * stack's first line is dropped.
117
+ */
118
+ export function hashMessage(message: string): string {
119
+ return createHash('sha256').update(message).digest('hex').slice(0, 16)
120
+ }
121
+
122
+ /** Runtime tag for the session payload, e.g. `bun@1.2` / `node@22`. */
123
+ export function runtimeTag(): string {
124
+ const versions = process.versions as Record<string, string | undefined>
125
+ if (versions.bun) return `bun@${versions.bun}`
126
+ return `node@${(versions.node ?? '0').split('.')[0]}`
127
+ }
@@ -0,0 +1,81 @@
1
+ import { fetchWithRetry } from '../providers/fetch-utils'
2
+ import { ackQueue, readQueue, type QueuedEvent } from './queue'
3
+
4
+ /**
5
+ * Upload queued events.
6
+ *
7
+ * Runs at *startup*, not at exit: `process.on('exit')` cannot await async work,
8
+ * so collecting and sending are decoupled — the session queue is written
9
+ * synchronously at exit and drained here on the next launch. Same shape as the
10
+ * existing startup update check (`shared/update.ts`), which is likewise
11
+ * fire-and-forget.
12
+ *
13
+ * Every failure is silent and leaves the event queued for the next attempt. A
14
+ * telemetry endpoint being unreachable must never be visible to the user.
15
+ */
16
+
17
+ /** Per-request budget. Short: this runs alongside startup, not during it. */
18
+ const REQUEST_TIMEOUT_MS = 10_000
19
+
20
+ export interface FlushResult {
21
+ sent: number
22
+ failed: number
23
+ }
24
+
25
+ /**
26
+ * Drain the queue to `endpoint`.
27
+ *
28
+ * Returns without sending anything when the endpoint is empty — which is the
29
+ * shipped default, so an unconfigured install performs **zero** network calls.
30
+ */
31
+ export async function flushQueue(
32
+ endpoint: string,
33
+ opts: { fetchImpl?: typeof fetch; events?: QueuedEvent[] } = {},
34
+ ): Promise<FlushResult> {
35
+ if (!endpoint) return { sent: 0, failed: 0 }
36
+
37
+ const events = opts.events ?? readQueue()
38
+ if (events.length === 0) return { sent: 0, failed: 0 }
39
+
40
+ const sent: string[] = []
41
+ let failed = 0
42
+
43
+ for (const event of events) {
44
+ try {
45
+ const response = await fetchWithRetry(
46
+ endpoint,
47
+ {
48
+ method: 'POST',
49
+ headers: { 'Content-Type': 'application/json' },
50
+ body: JSON.stringify(event),
51
+ },
52
+ { timeout: REQUEST_TIMEOUT_MS, maxRetries: 2, baseDelay: 1000 },
53
+ )
54
+
55
+ // 4xx means we are sending something the endpoint will never accept —
56
+ // retrying forever would wedge the queue behind a permanently bad event.
57
+ // Drop it on the floor rather than block every later event.
58
+ if (response.ok || (response.status >= 400 && response.status < 500)) {
59
+ sent.push(event.id)
60
+ } else {
61
+ failed++
62
+ }
63
+ } catch {
64
+ failed++
65
+ }
66
+ }
67
+
68
+ ackQueue(sent)
69
+ return { sent: sent.length, failed }
70
+ }
71
+
72
+ /**
73
+ * Fire-and-forget startup flush. Never rejects, never blocks the caller, and
74
+ * never surfaces an error — the caller does not await it.
75
+ */
76
+ export function flushQueueInBackground(endpoint: string): void {
77
+ if (!endpoint) return
78
+ void flushQueue(endpoint).catch(() => {
79
+ /* best-effort by design */
80
+ })
81
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
2
  import { runWorkflow } from '../../workflow/runtime'
3
+ import { workflowScriptDir } from '../../core/paths.ts'
3
4
  import type { QueryEngine } from '../../core/engine'
4
5
 
5
6
  export const workflowTool: ToolDefinition = {
@@ -108,10 +109,11 @@ export const workflowTool: ToolDefinition = {
108
109
  )
109
110
 
110
111
  // Persist last-run state for /workflow save
112
+ let persistWarning = ''
111
113
  try {
112
114
  const { existsSync, mkdirSync, writeFileSync } = await import('node:fs')
113
115
  const { join } = await import('node:path')
114
- const workflowsDir = join(process.cwd(), '.claude', 'workflows')
116
+ const workflowsDir = workflowScriptDir(process.cwd())
115
117
  if (!existsSync(workflowsDir)) {
116
118
  mkdirSync(workflowsDir, { recursive: true })
117
119
  }
@@ -120,8 +122,13 @@ export const workflowTool: ToolDefinition = {
120
122
  JSON.stringify({ runId, script, timestamp: new Date().toISOString() }),
121
123
  'utf-8',
122
124
  )
123
- } catch {
124
- // best-effort — don't fail the workflow if state persistence fails
125
+ } catch (err) {
126
+ // Persistence stays best-effort — the workflow itself succeeded, so a
127
+ // disk error must not fail it. But it must not be *silent* either:
128
+ // swallowing this made the next `/workflow save` report "No recent
129
+ // workflow run found" while the script sat intact in
130
+ // ~/.mipham/workflows/<runId>/script.js.
131
+ persistWarning = `\n\n⚠️ Could not persist last-run state for /workflow save: ${String(err)}`
125
132
  }
126
133
 
127
134
  let content = `Workflow ${runId} completed.\n\n`
@@ -130,7 +137,7 @@ export const workflowTool: ToolDefinition = {
130
137
  }
131
138
  content += `Result:\n${typeof result === 'string' ? result : JSON.stringify(result, null, 2)}`
132
139
 
133
- return { success: true, content }
140
+ return { success: true, content: content + persistWarning }
134
141
  } catch (err) {
135
142
  return {
136
143
  success: false,
@@ -2,6 +2,7 @@ import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index
2
2
  import { sanitizeCommand } from '../../shared/sanitize.ts'
3
3
  import { DANGEROUS_GIT_PATTERNS } from './git.ts'
4
4
  import { isUncOrDevicePath } from '../../security/path.ts'
5
+ import { findWorktreeMarker } from '../../core/paths.ts'
5
6
  import type { Service } from '../../vajra'
6
7
  import { toolKey } from '../seam'
7
8
  import { withValidation } from '../validation'
@@ -334,9 +335,10 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
334
335
  const timeout = Math.min((params.timeout as number) || 120_000, 600_000)
335
336
 
336
337
  // P0-4: Worktree isolation — block cd escape attempts
337
- const WORKTREE_MARKER = '.claude/worktrees/'
338
- if (ctx.cwd.includes(WORKTREE_MARKER)) {
339
- const worktreeRoot = ctx.cwd.substring(0, ctx.cwd.indexOf(WORKTREE_MARKER))
338
+ // 标记取自 core/paths.ts:新目录与历史 .claude/worktrees/ 都认,
339
+ // 隔离度只增不减(只认新前缀会让旧工作树失去保护)。
340
+ const worktreeMarker = findWorktreeMarker(ctx.cwd)
341
+ if (worktreeMarker) {
340
342
  // Detect cd to absolute paths outside the worktree
341
343
  const cdEscapePattern = /\bcd\s+(?:"([^"]+)"|'([^']+)'|([^\s;|&]+))/
342
344
  const cdMatch = command.match(cdEscapePattern)
@@ -346,7 +348,7 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
346
348
  const resolved = target.startsWith('/')
347
349
  ? target
348
350
  : `${ctx.cwd}/${target}`.replace(/\/\.\//g, '/')
349
- if (!resolved.startsWith(ctx.cwd) && !resolved.startsWith(worktreeRoot + '/')) {
351
+ if (!resolved.startsWith(ctx.cwd) && !resolved.startsWith(worktreeMarker.root + '/')) {
350
352
  return {
351
353
  success: false,
352
354
  content: '',