@miphamai/cli 0.85.1 → 0.85.3
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/bin/mipham.ts +42 -2
- package/package.json +1 -1
- package/skills/standard/mipham-code-setup.SKILL.md +9 -2
- 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 +67 -6
- package/src/agent/types.ts +10 -0
- package/src/agent-view/agent-view-manager.ts +46 -0
- package/src/agent-view/dashboard.tsx +106 -16
- package/src/agent-view/session-view.tsx +128 -0
- package/src/commands/autoloop-journal.ts +6 -5
- package/src/commands/environment.ts +11 -8
- package/src/commands/project.ts +5 -3
- package/src/config/credential-crypto.ts +13 -1
- package/src/config/keys-manager.ts +7 -4
- package/src/config/loader.ts +144 -41
- package/src/config/preferences.ts +6 -3
- package/src/core/constitution-loader.ts +3 -2
- package/src/core/context.ts +35 -6
- 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/instructions.ts +69 -48
- package/src/core/memory/memory-manager.ts +10 -6
- package/src/core/permission-audit.ts +13 -6
- package/src/core/permission-config.ts +28 -7
- 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/attach-protocol.ts +30 -3
- package/src/daemon/auth.ts +4 -3
- package/src/daemon/index.ts +4 -3
- package/src/daemon/remote-engine.ts +173 -24
- package/src/daemon/server.ts +51 -1
- package/src/daemon/session-worker.ts +34 -1
- package/src/i18n-core/locales/en-US.json +1 -0
- package/src/i18n-core/locales/zh-CN.json +1 -0
- package/src/index.tsx +75 -17
- 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/arg-validation.ts +74 -2
- 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/skills/bundled-skills.ts +1 -1
- 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 +78 -14
- package/src/ui/commands.ts +73 -31
- package/src/ui/ctrl-c-confirm.ts +63 -0
- package/src/workflow/journal.ts +80 -26
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
* mipham agents (from CLI)
|
|
9
9
|
* /agents (from slash command within a running session)
|
|
10
10
|
*/
|
|
11
|
-
import React, { useState, useCallback, useMemo } from 'react'
|
|
11
|
+
import React, { useState, useCallback, useEffect, useMemo } from 'react'
|
|
12
12
|
import { Box, Text, useInput } from 'ink'
|
|
13
|
+
import TextInput from 'ink-text-input'
|
|
13
14
|
import { AgentViewManager, type AgentSession, type SessionStatus } from './agent-view-manager'
|
|
14
15
|
import { SessionRow } from './session-row'
|
|
15
16
|
import { SessionPeek } from './session-peek'
|
|
17
|
+
import { useCtrlCConfirm } from '../ui/ctrl-c-confirm'
|
|
18
|
+
import { getBackgroundAgentRegistry } from '../agent/background-registry'
|
|
16
19
|
|
|
17
20
|
interface DashboardProps {
|
|
18
21
|
manager: AgentViewManager
|
|
@@ -32,8 +35,13 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
32
35
|
const [peekingSessionId, setPeekingSessionId] = useState<string | null>(null)
|
|
33
36
|
const [groupBy, setGroupBy] = useState<'status' | 'directory'>('status')
|
|
34
37
|
const [feedback, setFeedback] = useState<string | null>(null)
|
|
35
|
-
// Bump to force flatList recompute after
|
|
38
|
+
// Bump to force flatList recompute after the session set changes.
|
|
36
39
|
const [version, setVersion] = useState(0)
|
|
40
|
+
// Non-null while the rename box is open; holds the row being renamed + the draft.
|
|
41
|
+
const [renaming, setRenaming] = useState<{ id: string; draft: string } | null>(null)
|
|
42
|
+
|
|
43
|
+
// Ctrl+C 的「再按一次才退」,与主界面同源(见 ui/ctrl-c-confirm.ts)
|
|
44
|
+
const ctrlC = useCtrlCConfirm()
|
|
37
45
|
|
|
38
46
|
// Flash a brief feedback message that auto-clears
|
|
39
47
|
const showFeedback = useCallback((msg: string) => {
|
|
@@ -41,6 +49,12 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
41
49
|
setTimeout(() => setFeedback(null), 1800)
|
|
42
50
|
}, [])
|
|
43
51
|
|
|
52
|
+
// The mutations that matter happen outside this component: `/bg` and `/fork`
|
|
53
|
+
// resolve their executors long after the keystroke that spawned them, so a
|
|
54
|
+
// run that finishes while this panel is open used to leave the row — and the
|
|
55
|
+
// header counts — frozen at whatever they were when the panel mounted.
|
|
56
|
+
useEffect(() => manager.onChange(() => setVersion((v) => v + 1)), [manager])
|
|
57
|
+
|
|
44
58
|
// Build a flat list of sessions in group order, with group headers
|
|
45
59
|
const flatList = useMemo(() => {
|
|
46
60
|
const result: Array<
|
|
@@ -111,7 +125,55 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
111
125
|
[manager, onAttach],
|
|
112
126
|
)
|
|
113
127
|
|
|
128
|
+
// Commit the open rename box. Empty title cancels — a session with no name is
|
|
129
|
+
// indistinguishable in the list, so the box refuses rather than writing ''.
|
|
130
|
+
const handleRenameSubmit = useCallback(
|
|
131
|
+
(value: string) => {
|
|
132
|
+
const id = renaming?.id
|
|
133
|
+
setRenaming(null)
|
|
134
|
+
if (!id) return
|
|
135
|
+
const title = value.trim()
|
|
136
|
+
if (!title) {
|
|
137
|
+
showFeedback('Rename cancelled — title cannot be empty')
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
manager.rename(id, title)
|
|
141
|
+
showFeedback(`Renamed to ${title}`)
|
|
142
|
+
},
|
|
143
|
+
[manager, renaming, showFeedback],
|
|
144
|
+
)
|
|
145
|
+
|
|
114
146
|
useInput((input, key) => {
|
|
147
|
+
// While the rename box is open it owns the keyboard: the list keys below
|
|
148
|
+
// must not fire on the same keystroke that is being typed into the title
|
|
149
|
+
// (Ink delivers every key to every mounted useInput).
|
|
150
|
+
if (renaming) {
|
|
151
|
+
if (key.escape || (key.ctrl && input === 'c')) {
|
|
152
|
+
setRenaming(null)
|
|
153
|
+
ctrlC.reset()
|
|
154
|
+
showFeedback('Rename cancelled')
|
|
155
|
+
}
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Ctrl+C 不再一下就退出(Ink 的 `exitOnCtrlC` 已在 render 处关掉,见
|
|
160
|
+
// src/index.tsx):第一次只提示,再按一次才走。面板里 Esc 已经是退出键,
|
|
161
|
+
// 所以这里只补「误按一次不带走整个面板」。
|
|
162
|
+
if (key.ctrl && input === 'c') {
|
|
163
|
+
if (peekingSessionId) {
|
|
164
|
+
setPeekingSessionId(null)
|
|
165
|
+
ctrlC.reset()
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
if (ctrlC.isArmed()) {
|
|
169
|
+
onExit()
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
ctrlC.arm()
|
|
173
|
+
showFeedback('Ctrl+C again to exit')
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
115
177
|
if (key.escape) {
|
|
116
178
|
if (peekingSessionId) {
|
|
117
179
|
setPeekingSessionId(null)
|
|
@@ -128,7 +190,7 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
128
190
|
return
|
|
129
191
|
}
|
|
130
192
|
|
|
131
|
-
// Ctrl+R — rename selected session
|
|
193
|
+
// Ctrl+R — open the rename box on the selected session
|
|
132
194
|
if (key.ctrl && input === 'r') {
|
|
133
195
|
if (sessionsOnly.length === 0) {
|
|
134
196
|
showFeedback('No sessions to rename')
|
|
@@ -136,13 +198,13 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
136
198
|
}
|
|
137
199
|
const current = sessionsOnly[selectedIndex]
|
|
138
200
|
if (!current) return
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
201
|
+
// Seeded with the current title: renaming is usually an edit, and a box
|
|
202
|
+
// that opens blank silently invites the user to retype a whole task name.
|
|
203
|
+
setRenaming({ id: current.session.id, draft: current.session.title })
|
|
142
204
|
return
|
|
143
205
|
}
|
|
144
206
|
|
|
145
|
-
// Ctrl+X —
|
|
207
|
+
// Ctrl+X — stop the selected session's work (if any) and drop its row
|
|
146
208
|
if (key.ctrl && input === 'x') {
|
|
147
209
|
if (sessionsOnly.length === 0) {
|
|
148
210
|
showFeedback('No sessions to remove')
|
|
@@ -150,11 +212,20 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
150
212
|
}
|
|
151
213
|
const current = sessionsOnly[selectedIndex]
|
|
152
214
|
if (!current) return
|
|
153
|
-
|
|
215
|
+
const session = current.session
|
|
216
|
+
// Discarding a row whose work is still running would throw away the only
|
|
217
|
+
// handle to it — the manager's own `kill()` flips a status label and
|
|
218
|
+
// stops nothing, and the running sub-agent checks the registry's abort
|
|
219
|
+
// signal. So: stop the task, then remove the row.
|
|
220
|
+
const stopped = session.taskId ? getBackgroundAgentRegistry().stop(session.taskId) : false
|
|
221
|
+
manager.remove(session.id)
|
|
154
222
|
setPeekingSessionId(null)
|
|
155
223
|
setSelectedIndex((prev) => Math.max(0, Math.min(prev, sessionsOnly.length - 2)))
|
|
156
|
-
|
|
157
|
-
|
|
224
|
+
showFeedback(
|
|
225
|
+
stopped
|
|
226
|
+
? `Stopped + removed ${session.title || session.id}`
|
|
227
|
+
: `Removed ${session.title || session.id}`,
|
|
228
|
+
)
|
|
158
229
|
return
|
|
159
230
|
}
|
|
160
231
|
|
|
@@ -198,11 +269,7 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
198
269
|
}
|
|
199
270
|
const current = sessionsOnly[selectedIndex]
|
|
200
271
|
if (!current) return
|
|
201
|
-
|
|
202
|
-
handleAttach(current.session.id)
|
|
203
|
-
} else {
|
|
204
|
-
handleAttach(current.session.id)
|
|
205
|
-
}
|
|
272
|
+
handleAttach(current.session.id)
|
|
206
273
|
return
|
|
207
274
|
}
|
|
208
275
|
})
|
|
@@ -211,7 +278,9 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
211
278
|
const peekData = useMemo(() => {
|
|
212
279
|
if (!peekingSessionId) return null
|
|
213
280
|
return manager.peek(peekingSessionId) ?? null
|
|
214
|
-
|
|
281
|
+
// `version` is a dependency so an open peek keeps up with a session that
|
|
282
|
+
// reports new messages while the viewer sits on it.
|
|
283
|
+
}, [manager, peekingSessionId, version])
|
|
215
284
|
|
|
216
285
|
const totalSessions = sessionsOnly.length
|
|
217
286
|
const counts = manager.countByStatus()
|
|
@@ -301,6 +370,27 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
301
370
|
<SessionPeek session={peekData.session} recentMessages={peekData.recentMessages} />
|
|
302
371
|
)}
|
|
303
372
|
|
|
373
|
+
{/* Rename box — opens on Ctrl+R, owns the keyboard until Enter/Esc */}
|
|
374
|
+
{renaming && (
|
|
375
|
+
<Box
|
|
376
|
+
flexDirection="column"
|
|
377
|
+
marginTop={1}
|
|
378
|
+
borderStyle="single"
|
|
379
|
+
borderColor="cyan"
|
|
380
|
+
padding={1}
|
|
381
|
+
>
|
|
382
|
+
<Text dimColor>Rename session — Enter to save · Esc to cancel</Text>
|
|
383
|
+
<Box>
|
|
384
|
+
<Text color="cyan">{'> '}</Text>
|
|
385
|
+
<TextInput
|
|
386
|
+
value={renaming.draft}
|
|
387
|
+
onChange={(draft) => setRenaming((cur) => (cur ? { ...cur, draft } : cur))}
|
|
388
|
+
onSubmit={handleRenameSubmit}
|
|
389
|
+
/>
|
|
390
|
+
</Box>
|
|
391
|
+
</Box>
|
|
392
|
+
)}
|
|
393
|
+
|
|
304
394
|
{/* Feedback toast — flashes briefly on action */}
|
|
305
395
|
{feedback && (
|
|
306
396
|
<Box marginTop={1}>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentSessionView — where Enter ("attach") in the Agent View dashboard lands.
|
|
3
|
+
*
|
|
4
|
+
* Read-only on purpose. The session's work runs inside its own `SubAgent` with
|
|
5
|
+
* its own context; this CLI has no channel back into that loop, so a view that
|
|
6
|
+
* accepted input would be collecting keystrokes it could not deliver. What this
|
|
7
|
+
* shows is the real thing instead: the transcript the session actually
|
|
8
|
+
* accumulated, kept current through the manager's change notifications.
|
|
9
|
+
*
|
|
10
|
+
* Esc returns to the dashboard.
|
|
11
|
+
*/
|
|
12
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
13
|
+
import { Box, Text, useInput } from 'ink'
|
|
14
|
+
import { AgentViewManager, type SessionStatus } from './agent-view-manager'
|
|
15
|
+
import { ChatPanel } from '../ui/chat'
|
|
16
|
+
|
|
17
|
+
interface AgentSessionViewProps {
|
|
18
|
+
manager: AgentViewManager
|
|
19
|
+
sessionId: string
|
|
20
|
+
onDetach: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const STATUS_COLORS: Record<SessionStatus, string> = {
|
|
24
|
+
'needs-input': 'yellow',
|
|
25
|
+
working: 'cyan',
|
|
26
|
+
completed: 'green',
|
|
27
|
+
failed: 'red',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const STATUS_LABELS: Record<SessionStatus, string> = {
|
|
31
|
+
'needs-input': 'needs input',
|
|
32
|
+
working: 'working',
|
|
33
|
+
completed: 'completed',
|
|
34
|
+
failed: 'failed',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function AgentSessionView({ manager, sessionId, onDetach }: AgentSessionViewProps) {
|
|
38
|
+
const [version, setVersion] = useState(0)
|
|
39
|
+
|
|
40
|
+
// The session keeps running while we look at it — follow it rather than
|
|
41
|
+
// photographing it once at mount.
|
|
42
|
+
useEffect(() => manager.onChange(() => setVersion((v) => v + 1)), [manager])
|
|
43
|
+
|
|
44
|
+
const session = useMemo(
|
|
45
|
+
() => manager.get(sessionId),
|
|
46
|
+
// `version` is a dependency so the transcript keeps up with a session that
|
|
47
|
+
// reports new messages while the viewer sits on it.
|
|
48
|
+
[manager, sessionId, version],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
useInput((_input, key) => {
|
|
52
|
+
if (key.escape) onDetach()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Esc while the agent view is open is the app's "go back" — but the app-level
|
|
56
|
+
// handler lives in `app.tsx` and only knows its own layers, so this component
|
|
57
|
+
// answers for itself.
|
|
58
|
+
const status = session ? STATUS_LABELS[session.status] : 'gone'
|
|
59
|
+
const statusColor = session ? STATUS_COLORS[session.status] : 'red'
|
|
60
|
+
|
|
61
|
+
const transcript = session?.messages ?? []
|
|
62
|
+
const emptyState = useCallback(
|
|
63
|
+
() => (
|
|
64
|
+
<Box paddingLeft={2}>
|
|
65
|
+
<Text dimColor>(no messages yet)</Text>
|
|
66
|
+
</Box>
|
|
67
|
+
),
|
|
68
|
+
[],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if (!session) {
|
|
72
|
+
return (
|
|
73
|
+
<Box flexDirection="column" padding={1} height="100%">
|
|
74
|
+
<Text bold color="red">
|
|
75
|
+
Session {sessionId} no longer exists.
|
|
76
|
+
</Text>
|
|
77
|
+
<Box marginTop={1}>
|
|
78
|
+
<Text dimColor>Esc back to Agent View</Text>
|
|
79
|
+
</Box>
|
|
80
|
+
</Box>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Box flexDirection="column" padding={1} height="100%">
|
|
86
|
+
{/* Header */}
|
|
87
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
88
|
+
<Box>
|
|
89
|
+
<Text bold color="cyan">
|
|
90
|
+
Attached
|
|
91
|
+
</Text>
|
|
92
|
+
<Text dimColor> — {session.id}</Text>
|
|
93
|
+
</Box>
|
|
94
|
+
<Box>
|
|
95
|
+
<Text>{session.title}</Text>
|
|
96
|
+
<Text dimColor>
|
|
97
|
+
{' '}
|
|
98
|
+
· {session.provider}/{session.model} · <Text color={statusColor}>{status}</Text>
|
|
99
|
+
</Text>
|
|
100
|
+
</Box>
|
|
101
|
+
{session.branch && (
|
|
102
|
+
<Box>
|
|
103
|
+
<Text dimColor>
|
|
104
|
+
branch {session.branch} · worktree {session.worktree}
|
|
105
|
+
</Text>
|
|
106
|
+
</Box>
|
|
107
|
+
)}
|
|
108
|
+
<Box>
|
|
109
|
+
<Text dimColor>
|
|
110
|
+
{transcript.length} message(s) · read-only transcript · Esc back to Agent View
|
|
111
|
+
</Text>
|
|
112
|
+
</Box>
|
|
113
|
+
</Box>
|
|
114
|
+
|
|
115
|
+
{/* Divider */}
|
|
116
|
+
<Box marginBottom={1}>
|
|
117
|
+
<Text dimColor>{'─'.repeat(70)}</Text>
|
|
118
|
+
</Box>
|
|
119
|
+
|
|
120
|
+
{/* Transcript */}
|
|
121
|
+
{transcript.length === 0 ? (
|
|
122
|
+
emptyState()
|
|
123
|
+
) : (
|
|
124
|
+
<ChatPanel messages={transcript} focusMode={false} />
|
|
125
|
+
)}
|
|
126
|
+
</Box>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
@@ -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 {
|
package/src/commands/project.ts
CHANGED
|
@@ -240,8 +240,10 @@ Messages: ${msgs.length} in context
|
|
|
240
240
|
Tools: ${ctx.engine.getTools().size} available
|
|
241
241
|
|
|
242
242
|
Shift+Tab cycles: ${renderCycleLine()} (this session only).
|
|
243
|
-
To persist a mode,
|
|
244
|
-
|
|
243
|
+
To persist a mode, name it in ~/.mipham/config.yml (\`permission:\`) or in
|
|
244
|
+
~/.mipham/settings.json (\`permissions.defaultMode\`) — every mode below is accepted
|
|
245
|
+
in either. A **project-level** file cannot set a mode: it arrives with the code, so
|
|
246
|
+
whoever wrote the repository would be choosing the approval gate.
|
|
245
247
|
|
|
246
248
|
Modes (least → most permissive):
|
|
247
249
|
${renderModeTable()}
|
|
@@ -476,7 +478,7 @@ const setupCmd: CommandHandler = async (ctx, args) => {
|
|
|
476
478
|
${skills.length} loaded (${standardSkills} standard + ${miphamSkills} mipham)
|
|
477
479
|
|
|
478
480
|
Permissions
|
|
479
|
-
Mode: ${ctx.
|
|
481
|
+
Mode: ${ctx.engine.getPermission().getMode()} · Tools: ${ctx.engine.getTools().size}
|
|
480
482
|
|
|
481
483
|
|
|
482
484
|
── Setup Steps ──
|
|
@@ -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 {
|