@miphamai/cli 0.85.1 → 0.85.2

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 (48) hide show
  1. package/package.json +1 -1
  2. package/src/agent/agent-experience.ts +3 -2
  3. package/src/agent/background-registry.ts +7 -3
  4. package/src/agent/cross-session/discovery.ts +5 -12
  5. package/src/agent/cross-session/file-inbox.ts +2 -2
  6. package/src/agent/effectiveness-tracker.ts +3 -2
  7. package/src/agent/sub-agent.ts +44 -4
  8. package/src/agent-view/dashboard.tsx +22 -0
  9. package/src/commands/autoloop-journal.ts +6 -5
  10. package/src/commands/environment.ts +11 -8
  11. package/src/config/credential-crypto.ts +13 -1
  12. package/src/config/keys-manager.ts +7 -4
  13. package/src/config/loader.ts +46 -35
  14. package/src/config/preferences.ts +6 -3
  15. package/src/core/constitution-loader.ts +3 -2
  16. package/src/core/crsi-producer.ts +4 -2
  17. package/src/core/crsi-sandbox.ts +2 -1
  18. package/src/core/dream-engine.ts +5 -11
  19. package/src/core/engine.ts +9 -4
  20. package/src/core/error-signature-db.ts +3 -2
  21. package/src/core/eval-harness.ts +3 -3
  22. package/src/core/hooks-executor.ts +60 -2
  23. package/src/core/memory/memory-manager.ts +10 -6
  24. package/src/core/permission-audit.ts +13 -6
  25. package/src/core/permission-rules.ts +99 -5
  26. package/src/core/permission.ts +6 -1
  27. package/src/core/rule-engine.ts +3 -2
  28. package/src/core/session-log.ts +48 -11
  29. package/src/core/session-store.ts +64 -44
  30. package/src/daemon/auth.ts +4 -3
  31. package/src/daemon/index.ts +4 -3
  32. package/src/i18n-core/locales/en-US.json +1 -0
  33. package/src/i18n-core/locales/zh-CN.json +1 -0
  34. package/src/index.tsx +6 -0
  35. package/src/mcp/oauth.ts +47 -5
  36. package/src/mcp/token-store.ts +10 -11
  37. package/src/providers/openai-compat.ts +11 -0
  38. package/src/shared/package-info.ts +1 -1
  39. package/src/shared/regular-file.ts +63 -0
  40. package/src/shared/sanitize.ts +14 -2
  41. package/src/tools/agent/memory.ts +4 -2
  42. package/src/tools/agent/workflow.ts +6 -3
  43. package/src/tools/exec/task.ts +82 -30
  44. package/src/tools/scheduling/cron.ts +3 -9
  45. package/src/ui/app.tsx +48 -13
  46. package/src/ui/commands.ts +5 -2
  47. package/src/ui/ctrl-c-confirm.ts +63 -0
  48. package/src/workflow/journal.ts +80 -26
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.85.1",
3
+ "version": "0.85.2",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -1,4 +1,5 @@
1
- import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'
1
+ import { mkdirSync, readFileSync, existsSync, unlinkSync } from 'node:fs'
2
+ import { atomicWriteFileSync } from '../shared/atomic-write'
2
3
  import { join } from 'node:path'
3
4
  import { ExperienceRuleExtractor, type ExperienceRule } from './experience-rules.js'
4
5
  import { miphamHome } from '../core/paths.ts'
@@ -109,6 +110,6 @@ export class AgentExperience {
109
110
  },
110
111
  )
111
112
 
112
- writeFileSync(this.expFile, content, 'utf-8')
113
+ atomicWriteFileSync(this.expFile, content, { mode: 0o644 })
113
114
  }
114
115
  }
@@ -83,12 +83,16 @@ export class BackgroundAgentRegistry {
83
83
  *
84
84
  * @param description - Human-readable description
85
85
  * @param agentType - Sub-agent type (general, explore, plan, code-review)
86
- * @param executor - Async function that performs the work
86
+ * @param executor - Async function that performs the work. Given the task's
87
+ * abort signal **and its id**: the id is minted here, and it is the address
88
+ * peers send to (`SendMessage` → `MessageRouter` → this `bg-…`), so an
89
+ * executor that never learns it has no way to be reachable. Callers that do
90
+ * not need it can keep declaring just the signal.
87
91
  */
88
92
  spawn(
89
93
  description: string,
90
94
  agentType: string,
91
- executor: (signal: AbortSignal) => Promise<string>,
95
+ executor: (signal: AbortSignal, id: string) => Promise<string>,
92
96
  kind: BackgroundTaskKind = 'interactive',
93
97
  ): string {
94
98
  const id = `bg-${++this.idCounter}-${Date.now().toString(36)}`
@@ -132,7 +136,7 @@ export class BackgroundAgentRegistry {
132
136
  }
133
137
 
134
138
  // Execute in background — do NOT await
135
- executor(task.abortController.signal)
139
+ executor(task.abortController.signal, id)
136
140
  .then((result) => {
137
141
  task.status = 'completed'
138
142
  task.completedAt = new Date()
@@ -1,12 +1,5 @@
1
- import {
2
- readdirSync,
3
- readFileSync,
4
- writeFileSync,
5
- existsSync,
6
- mkdirSync,
7
- unlinkSync,
8
- statSync,
9
- } from 'node:fs'
1
+ import { readdirSync, readFileSync, existsSync, mkdirSync, unlinkSync, statSync } from 'node:fs'
2
+ import { atomicWriteFileSync } from '../../shared/atomic-write'
10
3
  import { join, basename } from 'node:path'
11
4
  import { hostname } from 'node:os'
12
5
  import type { SessionInfo, CrossSessionInbound } from '../../shared/types'
@@ -25,7 +18,7 @@ const STALE_SESSION_TTL_MS = 10 * 60 * 1000 // 10 min — heartbeat is 30s
25
18
  export function registerActiveSession(info: SessionInfo): void {
26
19
  mkdirSync(ACTIVE_SESSIONS_DIR, { recursive: true })
27
20
  const filePath = join(ACTIVE_SESSIONS_DIR, `${info.id}.json`)
28
- writeFileSync(filePath, JSON.stringify(info, null, 2), 'utf-8')
21
+ atomicWriteFileSync(filePath, JSON.stringify(info, null, 2), { mode: 0o644 })
29
22
  }
30
23
 
31
24
  /**
@@ -36,7 +29,7 @@ export function heartbeatSession(sessionId: string): void {
36
29
  if (existsSync(filePath)) {
37
30
  // Touch the file by rewriting it
38
31
  const raw = readFileSync(filePath, 'utf-8')
39
- writeFileSync(filePath, raw, 'utf-8') // updates mtime
32
+ atomicWriteFileSync(filePath, raw, { mode: 0o644 }) // updates mtime
40
33
  }
41
34
  }
42
35
 
@@ -141,7 +134,7 @@ export function renameActiveSession(sessionId: string, newName: string): string
141
134
  const info = JSON.parse(raw) as SessionInfo
142
135
  const others = discoverSessions().filter((s) => s.id !== sessionId)
143
136
  info.name = ensureUniqueSessionName(newName, others)
144
- writeFileSync(filePath, JSON.stringify(info, null, 2), 'utf-8')
137
+ atomicWriteFileSync(filePath, JSON.stringify(info, null, 2), { mode: 0o644 })
145
138
  return info.name
146
139
  }
147
140
 
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  mkdirSync,
3
- writeFileSync,
4
3
  readdirSync,
5
4
  readFileSync,
6
5
  unlinkSync,
@@ -8,6 +7,7 @@ import {
8
7
  renameSync,
9
8
  lstatSync,
10
9
  } from 'node:fs'
10
+ import { atomicWriteFileSync } from '../../shared/atomic-write'
11
11
  import { join } from 'node:path'
12
12
  import type { CrossSessionTransport } from './transport'
13
13
  import type { AgentMessage } from '../message-bus'
@@ -69,7 +69,7 @@ export class FileInboxTransport implements CrossSessionTransport {
69
69
 
70
70
  // Atomic write: temp file then rename
71
71
  const tmpPath = filePath + '.tmp'
72
- writeFileSync(tmpPath, JSON.stringify(envelope, null, 2), 'utf-8')
72
+ atomicWriteFileSync(tmpPath, JSON.stringify(envelope, null, 2), { mode: 0o644 })
73
73
  renameSync(tmpPath, filePath)
74
74
 
75
75
  return true
@@ -1,4 +1,5 @@
1
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
1
+ import { mkdirSync, readFileSync, existsSync } from 'node:fs'
2
+ import { atomicWriteFileSync } from '../shared/atomic-write'
2
3
  import { join, dirname } from 'node:path'
3
4
  import type { CrsiProvenanceBridge, CrsiVerdict } from './crsi-provenance-bridge.js'
4
5
  import { isRecoverableToolFailure } from './recoverable-failure.js'
@@ -174,7 +175,7 @@ export class EffectivenessTracker {
174
175
  for (const [k, v] of this.data) {
175
176
  obj[k] = v
176
177
  }
177
- writeFileSync(this.storePath, JSON.stringify(obj, null, 2), 'utf-8')
178
+ atomicWriteFileSync(this.storePath, JSON.stringify(obj, null, 2), { mode: 0o644 })
178
179
  }
179
180
 
180
181
  load(): void {
@@ -1,10 +1,10 @@
1
1
  import type { ProviderRegistry } from '../providers/registry'
2
2
  import type { Llm } from '../providers/llm'
3
- import type { ToolDefinition, ToolContext } from '../shared/index.ts'
3
+ import type { Message, ToolDefinition, ToolContext } from '../shared/index.ts'
4
4
  import type { SubAgentType, SubAgentOptions, AgentDefinition } from './types'
5
5
  import { createAgentContext } from './agent-context'
6
6
  import { getBackgroundAgentRegistry } from './background-registry'
7
- import { getMessageBus } from './message-bus'
7
+ import { formatInboundMessage, getMessageBus } from './message-bus'
8
8
  import type { HookEngine } from '../core/hooks'
9
9
  import { PermissionSystem } from '../core/permission'
10
10
  import { AgentExperience } from './agent-experience'
@@ -91,14 +91,14 @@ export class SubAgent {
91
91
  if (options.runInBackground) {
92
92
  const bgRegistry = getBackgroundAgentRegistry()
93
93
 
94
- const taskId = bgRegistry.spawn(description, agentType, async (signal) => {
94
+ const taskId = bgRegistry.spawn(description, agentType, async (signal, agentId) => {
95
95
  // Run the synchronous execution inside the background executor, reporting
96
96
  // cumulative token usage back to the registry for live footer display.
97
97
  const opts: SubAgentOptions = {
98
98
  ...options,
99
99
  onTokenUsage: (total) => bgRegistry.updateTokenUsage(taskId, total),
100
100
  }
101
- return this.runExecution(prompt, opts, signal)
101
+ return this.runExecution(prompt, opts, signal, agentId)
102
102
  })
103
103
 
104
104
  // Register completion callback for hook firing
@@ -210,10 +210,35 @@ export class SubAgent {
210
210
  /**
211
211
  * Internal execution method — shared by sync and background paths.
212
212
  */
213
+ /**
214
+ * Drain same-process messages addressed to *this* agent into its own turn,
215
+ * mirroring `Engine.drainInboundMessages` for the main session.
216
+ *
217
+ * Only the background path has an address to drain: `bg-…` is the recipient the
218
+ * message router publishes to, and it is minted by the registry — which is why
219
+ * `spawn` hands it to the executor. Until this ran, `SendMessage` to a running
220
+ * background agent returned `success: true, routedTo: 'bus'` and the message was
221
+ * read by nobody: the bus's only reader polled `[sessionId, 'main']`, so what
222
+ * the sender was told had been delivered sat there until the 1-hour prune.
223
+ * (The same hole the max-turns notice advertises away — "Use SendMessage to
224
+ * continue this sub-agent".)
225
+ */
226
+ private drainInboundMessages(agentId: string | undefined, messages: Message[]): number {
227
+ if (!agentId) return 0
228
+ const bus = getMessageBus()
229
+ const inbound = bus.poll(agentId)
230
+ for (const msg of inbound) {
231
+ messages.push({ role: 'user', content: formatInboundMessage(msg) })
232
+ }
233
+ if (inbound.length > 0) bus.markAllRead(agentId)
234
+ return inbound.length
235
+ }
236
+
213
237
  private async runExecution(
214
238
  prompt: string,
215
239
  options: SubAgentOptions,
216
240
  signal?: AbortSignal,
241
+ agentId?: string,
217
242
  ): Promise<string> {
218
243
  if (!this.registry.getActive()) {
219
244
  throw new Error('No active provider available for sub-agent execution')
@@ -391,6 +416,10 @@ export class SubAgent {
391
416
  throw new DOMException('Aborted', 'AbortError')
392
417
  }
393
418
 
419
+ // Per turn, not once per run: a peer can write while this agent is mid
420
+ // task, and the point of the channel is to steer the work in progress.
421
+ this.drainInboundMessages(agentId, currentMessages)
422
+
394
423
  const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
395
424
  let turnText = ''
396
425
 
@@ -506,6 +535,12 @@ export class SubAgent {
506
535
  // `createSubAgentPermission`), so the default sub-agent is unchanged.
507
536
  const decision = await gate.resolveApproval(tool, effectiveInput, { signal })
508
537
  if (decision.level === 'ask') {
538
+ // Same circuit breaker as `Engine.executeTool`, sharing its threshold:
539
+ // after this many refusals in a row, say so, so the model stops
540
+ // re-issuing the call. It matters more here — a sub-agent gets five
541
+ // turns and cannot ask anyone, so a silent retry loop spends the whole
542
+ // run on a route that is closed to it.
543
+ const limitExceeded = gate.incrementBlockCounter()
509
544
  currentMessages.push({
510
545
  role: 'user' as const,
511
546
  content:
@@ -513,10 +548,15 @@ export class SubAgent {
513
548
  `Cannot execute in non-interactive sub-agent context.` +
514
549
  (decision.source === 'classifier' && decision.classifierReason
515
550
  ? ` Classifier: ${decision.classifierReason}`
551
+ : '') +
552
+ (limitExceeded
553
+ ? '\n(Consecutive block limit reached. This route stays closed for the rest of this run — try a different approach instead of retrying this call.)'
516
554
  : ''),
517
555
  })
518
556
  continue
519
557
  }
558
+ // Allowed — the streak is broken (mirrors the engine's reset).
559
+ gate.resetBlockCounter()
520
560
 
521
561
  try {
522
562
  const result = await tool.execute(effectiveInput, toolContext)
@@ -13,6 +13,7 @@ import { Box, Text, useInput } from 'ink'
13
13
  import { AgentViewManager, type AgentSession, type SessionStatus } from './agent-view-manager'
14
14
  import { SessionRow } from './session-row'
15
15
  import { SessionPeek } from './session-peek'
16
+ import { useCtrlCConfirm } from '../ui/ctrl-c-confirm'
16
17
 
17
18
  interface DashboardProps {
18
19
  manager: AgentViewManager
@@ -35,6 +36,9 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
35
36
  // Bump to force flatList recompute after a session is removed (list membership change).
36
37
  const [version, setVersion] = useState(0)
37
38
 
39
+ // Ctrl+C 的「再按一次才退」,与主界面同源(见 ui/ctrl-c-confirm.ts)
40
+ const ctrlC = useCtrlCConfirm()
41
+
38
42
  // Flash a brief feedback message that auto-clears
39
43
  const showFeedback = useCallback((msg: string) => {
40
44
  setFeedback(msg)
@@ -112,6 +116,24 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
112
116
  )
113
117
 
114
118
  useInput((input, key) => {
119
+ // Ctrl+C 不再一下就退出(Ink 的 `exitOnCtrlC` 已在 render 处关掉,见
120
+ // src/index.tsx):第一次只提示,再按一次才走。面板里 Esc 已经是退出键,
121
+ // 所以这里只补「误按一次不带走整个面板」。
122
+ if (key.ctrl && input === 'c') {
123
+ if (peekingSessionId) {
124
+ setPeekingSessionId(null)
125
+ ctrlC.reset()
126
+ return
127
+ }
128
+ if (ctrlC.isArmed()) {
129
+ onExit()
130
+ return
131
+ }
132
+ ctrlC.arm()
133
+ showFeedback('Ctrl+C again to exit')
134
+ return
135
+ }
136
+
115
137
  if (key.escape) {
116
138
  if (peekingSessionId) {
117
139
  setPeekingSessionId(null)
@@ -1,4 +1,5 @@
1
- import { mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
1
+ import { mkdirSync, readFileSync, readdirSync, existsSync } from 'node:fs'
2
+ import { atomicWriteFileSync } from '../shared/atomic-write'
2
3
  import { join } from 'node:path'
3
4
  import { miphamHome } from '../core/paths.ts'
4
5
 
@@ -43,7 +44,7 @@ export function createAutoloopJournal(
43
44
  totalTokens: 0,
44
45
  maxIterations: 100,
45
46
  }
46
- writeFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), 'utf-8')
47
+ atomicWriteFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), { mode: 0o644 })
47
48
  return journal
48
49
  }
49
50
 
@@ -52,7 +53,7 @@ export function recordLoopTokens(sessionId: string, delta: number): void {
52
53
  const journal = readAutoloopJournal(sessionId)
53
54
  if (!journal) return
54
55
  journal.totalTokens += delta
55
- writeFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), 'utf-8')
56
+ atomicWriteFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), { mode: 0o644 })
56
57
  }
57
58
 
58
59
  /** Read the journal for an autonomous loop. */
@@ -88,7 +89,7 @@ export function logAutoloopIteration(sessionId: string, summary: string): void {
88
89
  journal.logs.push(`[${journal.lastIteration}] #${journal.iterations}: ${summary.slice(0, 200)}`)
89
90
  // Keep last 50 log entries
90
91
  if (journal.logs.length > 50) journal.logs = journal.logs.slice(-50)
91
- writeFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), 'utf-8')
92
+ atomicWriteFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), { mode: 0o644 })
92
93
  }
93
94
 
94
95
  /** Mark an autonomous loop as completed or stopped. */
@@ -97,7 +98,7 @@ export function completeAutoloopJournal(sessionId: string, status: 'completed' |
97
98
  if (!journal) return
98
99
  journal.status = status
99
100
  journal.lastIteration = new Date().toISOString()
100
- writeFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), 'utf-8')
101
+ atomicWriteFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), { mode: 0o644 })
101
102
  }
102
103
 
103
104
  /** List all active autonomous loops. */
@@ -89,7 +89,8 @@ Full changelog: https://mipham.ai/code/releases`,
89
89
  // ═══════════════════════════════════════════════════════════════
90
90
 
91
91
  const ideCmd: CommandHandler = async (_ctx) => {
92
- const { mkdirSync, writeFileSync } = await import('node:fs')
92
+ const { mkdirSync } = await import('node:fs')
93
+ const { atomicWriteFileSync } = await import('../shared/atomic-write')
93
94
  const { join } = await import('node:path')
94
95
 
95
96
  const cwd = process.cwd()
@@ -121,7 +122,7 @@ const ideCmd: CommandHandler = async (_ctx) => {
121
122
  },
122
123
  },
123
124
  }
124
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8')
125
+ atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o644 })
125
126
  files.push('.vscode/settings.json')
126
127
 
127
128
  // ── keybindings.json: Cmd+Esc launch ──
@@ -137,7 +138,7 @@ const ideCmd: CommandHandler = async (_ctx) => {
137
138
  command: 'workbench.action.terminal.new',
138
139
  },
139
140
  ]
140
- writeFileSync(keybindingsPath, JSON.stringify(keybindings, null, 2) + '\n', 'utf-8')
141
+ atomicWriteFileSync(keybindingsPath, JSON.stringify(keybindings, null, 2) + '\n', { mode: 0o644 })
141
142
  files.push('.vscode/keybindings.json')
142
143
 
143
144
  // ── extensions.json: recommended ──
@@ -145,7 +146,7 @@ const ideCmd: CommandHandler = async (_ctx) => {
145
146
  const extensions = {
146
147
  recommendations: ['miphamai.mipham-code'],
147
148
  }
148
- writeFileSync(extensionsPath, JSON.stringify(extensions, null, 2) + '\n', 'utf-8')
149
+ atomicWriteFileSync(extensionsPath, JSON.stringify(extensions, null, 2) + '\n', { mode: 0o644 })
149
150
  files.push('.vscode/extensions.json')
150
151
 
151
152
  return {
@@ -178,8 +179,9 @@ const ideCmd: CommandHandler = async (_ctx) => {
178
179
  // ═══════════════════════════════════════════════════════════════
179
180
 
180
181
  const terminalSetupCmd: CommandHandler = async () => {
181
- const { writeFileSync, appendFileSync, existsSync, mkdirSync, readFileSync } =
182
- await import('node:fs')
182
+ const { existsSync, mkdirSync, readFileSync } = await import('node:fs')
183
+ const { atomicWriteFileSync } = await import('../shared/atomic-write')
184
+ const { appendRegularFileSync } = await import('../shared/regular-file')
183
185
  const { join } = await import('node:path')
184
186
  const { homedir } = await import('node:os')
185
187
 
@@ -210,7 +212,7 @@ const terminalSetupCmd: CommandHandler = async () => {
210
212
  ' export MIPHAM_PROVIDER=$(grep "defaultProvider:" ~/.mipham/config.yml | awk "{print \$2}")',
211
213
  'fi',
212
214
  ].join('\n')
213
- writeFileSync(shellScript, shellContent + '\n', 'utf-8')
215
+ atomicWriteFileSync(shellScript, shellContent + '\n', { mode: 0o644 })
214
216
  lines.push(` ✅ Generated: ${shellScript}`)
215
217
 
216
218
  // ── 2. Append to shell profile ──
@@ -224,7 +226,8 @@ const terminalSetupCmd: CommandHandler = async () => {
224
226
  if (existing.includes('shell-setup.sh')) {
225
227
  lines.push(` ⏭ ${profileName} already has Mipham Code integration`)
226
228
  } else {
227
- appendFileSync(profilePath, sourceLine, 'utf-8')
229
+ // 写不进去(含 profile 路径上是个 FIFO)就与下面 catch 同路:把命令印给用户。
230
+ if (!appendRegularFileSync(profilePath, sourceLine)) throw new Error('not a regular file')
228
231
  lines.push(` ✅ Added to ~/${profileName}`)
229
232
  }
230
233
  } catch {
@@ -8,6 +8,7 @@ import {
8
8
  statSync,
9
9
  } from 'node:fs'
10
10
  import { dirname, join } from 'node:path'
11
+ import { isRegularFile } from '../shared/regular-file'
11
12
  import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
12
13
 
13
14
  /**
@@ -43,7 +44,9 @@ export function getCredentialKey(keyDir: string): Buffer {
43
44
  const keyPath = join(keyDir, CREDENTIAL_KEY_FILENAME)
44
45
  if (!existsSync(keyPath)) {
45
46
  const legacyPath = join(keyDir, LEGACY_KEY_FILENAME)
46
- if (existsSync(legacyPath)) {
47
+ // 迁移的**源**要过类型闸:`copyFileSync` 打开 FIFO 读端会一直等到有写者
48
+ // (见 shared/regular-file.ts)。
49
+ if (isRegularFile(legacyPath)) {
47
50
  copyFileSync(legacyPath, keyPath)
48
51
  chmodSync(keyPath, 0o400)
49
52
  }
@@ -58,6 +61,15 @@ export function getCredentialKey(keyDir: string): Buffer {
58
61
  * would ever notice.
59
62
  */
60
63
  function readKeyFile(keyPath: string): Buffer {
64
+ // 类型闸先过:这里两次进入都在**启动链**上(配置里任何 `enc:v1:` 都要它解密),
65
+ // 而 `readFileSync` 读 FIFO/socket 会一直等到有写者为止 —— 挂住时用户看不到报错、
66
+ // 也没有计时器救得回来。明确失败,并把「去删掉那个东西」写进消息里。
67
+ if (!isRegularFile(keyPath)) {
68
+ throw new Error(
69
+ `${keyPath} is not a regular file — refusing to read the credential key from it ` +
70
+ `(remove or replace it with a regular file).`,
71
+ )
72
+ }
61
73
  if ((statSync(keyPath).mode & 0o777) !== 0o400) chmodSync(keyPath, 0o400)
62
74
  return readFileSync(keyPath)
63
75
  }
@@ -1,6 +1,7 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'
1
+ import { mkdirSync, chmodSync } from 'node:fs'
2
2
  import { join, dirname } from 'node:path'
3
3
  import { atomicWriteFileSync } from '../shared/atomic-write'
4
+ import { readRegularFileSync } from '../shared/regular-file'
4
5
  import { saveProviderApiKey } from './loader'
5
6
  import { miphamHome } from '../core/paths.ts'
6
7
 
@@ -29,9 +30,11 @@ export interface KeysData {
29
30
  }
30
31
 
31
32
  function loadKeys(): KeysData {
32
- if (!existsSync(KEYS_FILE)) return {}
33
33
  try {
34
- const raw = readFileSync(KEYS_FILE, 'utf-8')
34
+ // 类型闸在读取之前(`~/.mipham/keys.json` 若是个 FIFO,`readFileSync` 会
35
+ // 一直等写者)—— 见 shared/regular-file.ts。
36
+ const raw = readRegularFileSync(KEYS_FILE)
37
+ if (raw === null) return {}
35
38
  const parsed: unknown = JSON.parse(raw)
36
39
  // Valid JSON is not necessarily a key map. `null` throws straight out of
37
40
  // `Object.entries` in `list()` — i.e. on the startup path, not in some corner
@@ -92,7 +95,7 @@ export class KeyManager {
92
95
  const backupDir = join(MIPHAM_HOME, 'keys')
93
96
  mkdirSync(backupDir, { recursive: true })
94
97
  const backupPath = join(backupDir, `${provider}.backup`)
95
- writeFileSync(backupPath, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 })
98
+ atomicWriteFileSync(backupPath, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 })
96
99
  try {
97
100
  chmodSync(backupPath, 0o600)
98
101
  } catch {
@@ -1,15 +1,8 @@
1
- import {
2
- readFileSync,
3
- existsSync,
4
- copyFileSync,
5
- mkdirSync,
6
- readdirSync,
7
- unlinkSync,
8
- chmodSync,
9
- } from 'node:fs'
1
+ import { existsSync, copyFileSync, mkdirSync, readdirSync, unlinkSync, chmodSync } from 'node:fs'
10
2
  import { join, dirname } from 'node:path'
11
3
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
12
4
  import { atomicWriteFileSync } from '../shared/atomic-write'
5
+ import { isRegularFile, readRegularFileSync } from '../shared/regular-file'
13
6
  import type {
14
7
  MiphamConfig,
15
8
  ProviderConfig,
@@ -41,7 +34,16 @@ const BACKUP_PREFIX = 'config.backup-'
41
34
  function safeParseYaml(path: string, label: string): Partial<MiphamConfig> | null {
42
35
  try {
43
36
  if (!existsSync(path)) return null
44
- const raw = readFileSync(path, 'utf-8')
37
+ // `existsSync` 先过一遍只为「缺席不吭声」;真正决定读不读的是**文件类型**
38
+ // —— FIFO 上 `readFileSync` 会阻塞到有写者出现,那次挂死没有报错也没有
39
+ // 计时器救得回来(见 shared/regular-file.ts)。
40
+ const raw = readRegularFileSync(path)
41
+ if (raw === null) {
42
+ process.stderr.write(
43
+ `⚠ Mipham Code: ${label} (${path}) is not a readable regular file — ignoring it.\n`,
44
+ )
45
+ return null
46
+ }
45
47
  return parseYaml(raw) as Partial<MiphamConfig>
46
48
  } catch (err: unknown) {
47
49
  const msg = err instanceof Error ? err.message : String(err)
@@ -151,7 +153,8 @@ function mergeConfig(
151
153
  */
152
154
  function backupConfig(configPath: string): void {
153
155
  try {
154
- if (!existsSync(configPath)) return
156
+ // 备份源同样要过类型闸:`copyFileSync` 打开 FIFO 读端一样会等到有写者为止。
157
+ if (!isRegularFile(configPath)) return
155
158
  mkdirSync(MIPHAM_HOME, { recursive: true, mode: 0o700 })
156
159
 
157
160
  const ts = new Date().toISOString().replace(/[:.]/g, '-')
@@ -190,6 +193,10 @@ export function tryRestoreFromBackup(configPath: string): boolean {
190
193
 
191
194
  if (files.length === 0) return false
192
195
 
196
+ // 目标已经在那儿而且不是普通文件(FIFO/socket/目录)时不还原:往 FIFO 写
197
+ // 会挡在「等读者」上,把「恢复」变成又一次挂死;目录则会 EISDIR。缺席才写。
198
+ if (existsSync(configPath) && !isRegularFile(configPath)) return false
199
+
193
200
  const latestBackup = join(MIPHAM_HOME, files[0]!)
194
201
  copyFileSync(latestBackup, configPath)
195
202
  process.stderr.write(`⚠ Mipham Code: restored config from backup (${files[0]})\n`)
@@ -220,8 +227,8 @@ function loadMcpJson(cwd: string): McpServerConfig[] {
220
227
 
221
228
  for (const path of searchPaths) {
222
229
  try {
223
- if (!existsSync(path)) continue
224
- const raw = readFileSync(path, 'utf-8')
230
+ const raw = readRegularFileSync(path)
231
+ if (raw === null) continue
225
232
  // Every McpServerConfig field is optional here (the name comes from the
226
233
  // key), so a field added to the type is accepted without touching this.
227
234
  const parsed = JSON.parse(raw) as {
@@ -304,8 +311,8 @@ export function loadSettingsJson(
304
311
 
305
312
  for (const { path, readHooks, isProject } of searchPaths) {
306
313
  try {
307
- if (!existsSync(path)) continue
308
- const raw = readFileSync(path, 'utf-8')
314
+ const raw = readRegularFileSync(path)
315
+ if (raw === null) continue
309
316
  const parsed = JSON.parse(raw) as {
310
317
  hooks?: Record<string, unknown>
311
318
  permissions?: { allow?: unknown; deny?: unknown }
@@ -376,10 +383,11 @@ export function settingsPathFor(scope: SettingsScope, cwd: string = process.cwd(
376
383
  * not something to clobber — the user's other settings live in the same file.
377
384
  */
378
385
  export function readSettingsDoc(path: string): Record<string, unknown> {
379
- if (!existsSync(path)) return {}
386
+ const raw = readRegularFileSync(path)
387
+ if (raw === null) return {}
380
388
  let parsed: unknown
381
389
  try {
382
- parsed = JSON.parse(readFileSync(path, 'utf-8'))
390
+ parsed = JSON.parse(raw)
383
391
  } catch {
384
392
  throw new Error(`${path} is not valid JSON. Fix or remove it, then retry.`)
385
393
  }
@@ -452,8 +460,10 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
452
460
  const projectConfig = safeParseYaml(configPath, 'project config')
453
461
  if (projectConfig) {
454
462
  config = mergeConfig(config, projectConfig, false)
455
- } else if (existsSync(configPath)) {
456
- // File exists but failed to parse — try to restore from backup
463
+ } else if (isRegularFile(configPath)) {
464
+ // A real file is there but failed to parse — try to restore from backup.
465
+ // 非普通文件走不到这里:那不是「损坏的配置」,而是根本不该当配置读的东西
466
+ // (FIFO/socket/目录),「恢复」对它会变成往 FIFO 里写、又一次挂死。
457
467
  process.stderr.write(`⚠ Mipham Code: project config is corrupted, attempting recovery...\n`)
458
468
  if (!tryRestoreFromBackup(configPath)) {
459
469
  process.stderr.write(`⚠ Mipham Code: no backup available for project config. Skipping.\n`)
@@ -470,8 +480,9 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
470
480
  const userConfig = safeParseYaml(userConfigPath, 'user config')
471
481
  if (userConfig) {
472
482
  config = mergeConfig(config, userConfig, true)
473
- } else if (existsSync(userConfigPath)) {
474
- // File exists but failed to parse — try to restore from backup
483
+ } else if (isRegularFile(userConfigPath)) {
484
+ // 用户的 config.yml 是真文件但解析不了 —— 才谈得上「恢复」。判据同上:
485
+ // 非普通文件不是损坏的配置。
475
486
  process.stderr.write(`⚠ Mipham Code: user config is corrupted, attempting recovery...\n`)
476
487
  if (!tryRestoreFromBackup(userConfigPath)) {
477
488
  process.stderr.write(`⚠ Mipham Code: no backup available for user config. Skipping.\n`)
@@ -545,8 +556,8 @@ export function loadInferenceHookConfig(): InferenceHookConfig {
545
556
  const paths = [userConfigPath]
546
557
  for (const path of paths) {
547
558
  try {
548
- if (!existsSync(path)) continue
549
- const raw = readFileSync(path, 'utf-8')
559
+ const raw = readRegularFileSync(path)
560
+ if (raw === null) continue
550
561
  const parsed = parseYaml(raw) as Record<string, unknown>
551
562
  const section = parsed.inference_hooks as Partial<InferenceHookConfig> | undefined
552
563
  if (section) {
@@ -587,8 +598,8 @@ function mergeCredentialMaskingFile(
587
598
  allowLoosening: boolean,
588
599
  ): CredentialMaskingConfig {
589
600
  try {
590
- if (!existsSync(path)) return merged
591
- const raw = readFileSync(path, 'utf-8')
601
+ const raw = readRegularFileSync(path)
602
+ if (raw === null) return merged
592
603
  const parsed = parseYaml(raw) as Record<string, unknown>
593
604
  const section = parsed.credential_masking as Partial<CredentialMaskingConfig> | undefined
594
605
  if (!section) return merged
@@ -664,8 +675,8 @@ export function loadBackgroundAgentConfig(cwd: string = process.cwd()): Backgrou
664
675
  const paths = [userConfigPath, configPath]
665
676
  for (const path of paths) {
666
677
  try {
667
- if (!existsSync(path)) continue
668
- const raw = readFileSync(path, 'utf-8')
678
+ const raw = readRegularFileSync(path)
679
+ if (raw === null) continue
669
680
  const parsed = parseYaml(raw) as Record<string, unknown>
670
681
  const section = parsed.background_agent as Partial<BackgroundAgentConfig> | undefined
671
682
  if (section) {
@@ -697,8 +708,8 @@ export function loadCrossSessionConfig(cwd: string = process.cwd()): CrossSessio
697
708
  const paths = [userConfigPath, configPath] // project wins (loaded last)
698
709
  for (const path of paths) {
699
710
  try {
700
- if (!existsSync(path)) continue
701
- const raw = readFileSync(path, 'utf-8')
711
+ const raw = readRegularFileSync(path)
712
+ if (raw === null) continue
702
713
  const parsed = parseYaml(raw) as Record<string, unknown>
703
714
  const section = parsed.cross_session as Partial<CrossSessionConfig> | undefined
704
715
  if (section) {
@@ -753,11 +764,11 @@ function decryptProviderApiKeys(providers: ProviderConfig[] | undefined): void {
753
764
  export function getProviderApiKey(providerId: string, cwd: string = process.cwd()): string | null {
754
765
  const userConfigPath = join(MIPHAM_HOME, 'config.yml')
755
766
  const projectConfigPath = join(cwd, MIPHAM_DIR, 'config.yml')
756
- const configPath = existsSync(userConfigPath) ? userConfigPath : projectConfigPath
767
+ const configPath = isRegularFile(userConfigPath) ? userConfigPath : projectConfigPath
757
768
 
758
769
  try {
759
- if (!existsSync(configPath)) return null
760
- const raw = readFileSync(configPath, 'utf-8')
770
+ const raw = readRegularFileSync(configPath)
771
+ if (raw === null) return null
761
772
  const doc = (parseYaml(raw) as Record<string, unknown>) || {}
762
773
  const providers = (doc.providers as Array<Record<string, unknown>>) || []
763
774
  const p = providers.find((x) => x.id === providerId)
@@ -788,9 +799,9 @@ export function saveProviderApiKey(providerId: string, apiKey: string): boolean
788
799
 
789
800
  // Read existing config (or start fresh)
790
801
  let doc: Record<string, unknown> = {}
791
- if (existsSync(configPath)) {
792
- const raw = readFileSync(configPath, 'utf-8')
793
- doc = (parseYaml(raw) as Record<string, unknown>) || {}
802
+ const existing = readRegularFileSync(configPath)
803
+ if (existing !== null) {
804
+ doc = (parseYaml(existing) as Record<string, unknown>) || {}
794
805
  }
795
806
 
796
807
  // Find and update the provider in the providers array