@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.
- package/package.json +1 -1
- package/src/agent/agent-experience.ts +3 -2
- package/src/agent/background-registry.ts +7 -3
- package/src/agent/cross-session/discovery.ts +5 -12
- package/src/agent/cross-session/file-inbox.ts +2 -2
- package/src/agent/effectiveness-tracker.ts +3 -2
- package/src/agent/sub-agent.ts +44 -4
- package/src/agent-view/dashboard.tsx +22 -0
- package/src/commands/autoloop-journal.ts +6 -5
- package/src/commands/environment.ts +11 -8
- package/src/config/credential-crypto.ts +13 -1
- package/src/config/keys-manager.ts +7 -4
- package/src/config/loader.ts +46 -35
- package/src/config/preferences.ts +6 -3
- package/src/core/constitution-loader.ts +3 -2
- package/src/core/crsi-producer.ts +4 -2
- package/src/core/crsi-sandbox.ts +2 -1
- package/src/core/dream-engine.ts +5 -11
- package/src/core/engine.ts +9 -4
- package/src/core/error-signature-db.ts +3 -2
- package/src/core/eval-harness.ts +3 -3
- package/src/core/hooks-executor.ts +60 -2
- package/src/core/memory/memory-manager.ts +10 -6
- package/src/core/permission-audit.ts +13 -6
- package/src/core/permission-rules.ts +99 -5
- package/src/core/permission.ts +6 -1
- package/src/core/rule-engine.ts +3 -2
- package/src/core/session-log.ts +48 -11
- package/src/core/session-store.ts +64 -44
- package/src/daemon/auth.ts +4 -3
- package/src/daemon/index.ts +4 -3
- package/src/i18n-core/locales/en-US.json +1 -0
- package/src/i18n-core/locales/zh-CN.json +1 -0
- package/src/index.tsx +6 -0
- package/src/mcp/oauth.ts +47 -5
- package/src/mcp/token-store.ts +10 -11
- package/src/providers/openai-compat.ts +11 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/regular-file.ts +63 -0
- package/src/shared/sanitize.ts +14 -2
- package/src/tools/agent/memory.ts +4 -2
- package/src/tools/agent/workflow.ts +6 -3
- package/src/tools/exec/task.ts +82 -30
- package/src/tools/scheduling/cron.ts +3 -9
- package/src/ui/app.tsx +48 -13
- package/src/ui/commands.ts +5 -2
- package/src/ui/ctrl-c-confirm.ts +63 -0
- package/src/workflow/journal.ts +80 -26
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
178
|
+
atomicWriteFileSync(this.storePath, JSON.stringify(obj, null, 2), { mode: 0o644 })
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
load(): void {
|
package/src/agent/sub-agent.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
98
|
+
atomicWriteFileSync(backupPath, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 })
|
|
96
99
|
try {
|
|
97
100
|
chmodSync(backupPath, 0o600)
|
|
98
101
|
} catch {
|
package/src/config/loader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
386
|
+
const raw = readRegularFileSync(path)
|
|
387
|
+
if (raw === null) return {}
|
|
380
388
|
let parsed: unknown
|
|
381
389
|
try {
|
|
382
|
-
parsed = JSON.parse(
|
|
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 (
|
|
456
|
-
//
|
|
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 (
|
|
474
|
-
//
|
|
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
|
-
|
|
549
|
-
|
|
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
|
-
|
|
591
|
-
|
|
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
|
-
|
|
668
|
-
|
|
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
|
-
|
|
701
|
-
|
|
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 =
|
|
767
|
+
const configPath = isRegularFile(userConfigPath) ? userConfigPath : projectConfigPath
|
|
757
768
|
|
|
758
769
|
try {
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
doc = (parseYaml(
|
|
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
|