@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.
Files changed (63) hide show
  1. package/bin/mipham.ts +42 -2
  2. package/package.json +1 -1
  3. package/skills/standard/mipham-code-setup.SKILL.md +9 -2
  4. package/src/agent/agent-experience.ts +3 -2
  5. package/src/agent/background-registry.ts +7 -3
  6. package/src/agent/cross-session/discovery.ts +5 -12
  7. package/src/agent/cross-session/file-inbox.ts +2 -2
  8. package/src/agent/effectiveness-tracker.ts +3 -2
  9. package/src/agent/sub-agent.ts +67 -6
  10. package/src/agent/types.ts +10 -0
  11. package/src/agent-view/agent-view-manager.ts +46 -0
  12. package/src/agent-view/dashboard.tsx +106 -16
  13. package/src/agent-view/session-view.tsx +128 -0
  14. package/src/commands/autoloop-journal.ts +6 -5
  15. package/src/commands/environment.ts +11 -8
  16. package/src/commands/project.ts +5 -3
  17. package/src/config/credential-crypto.ts +13 -1
  18. package/src/config/keys-manager.ts +7 -4
  19. package/src/config/loader.ts +144 -41
  20. package/src/config/preferences.ts +6 -3
  21. package/src/core/constitution-loader.ts +3 -2
  22. package/src/core/context.ts +35 -6
  23. package/src/core/crsi-producer.ts +4 -2
  24. package/src/core/crsi-sandbox.ts +2 -1
  25. package/src/core/dream-engine.ts +5 -11
  26. package/src/core/engine.ts +9 -4
  27. package/src/core/error-signature-db.ts +3 -2
  28. package/src/core/eval-harness.ts +3 -3
  29. package/src/core/hooks-executor.ts +60 -2
  30. package/src/core/instructions.ts +69 -48
  31. package/src/core/memory/memory-manager.ts +10 -6
  32. package/src/core/permission-audit.ts +13 -6
  33. package/src/core/permission-config.ts +28 -7
  34. package/src/core/permission-rules.ts +99 -5
  35. package/src/core/permission.ts +6 -1
  36. package/src/core/rule-engine.ts +3 -2
  37. package/src/core/session-log.ts +48 -11
  38. package/src/core/session-store.ts +64 -44
  39. package/src/daemon/attach-protocol.ts +30 -3
  40. package/src/daemon/auth.ts +4 -3
  41. package/src/daemon/index.ts +4 -3
  42. package/src/daemon/remote-engine.ts +173 -24
  43. package/src/daemon/server.ts +51 -1
  44. package/src/daemon/session-worker.ts +34 -1
  45. package/src/i18n-core/locales/en-US.json +1 -0
  46. package/src/i18n-core/locales/zh-CN.json +1 -0
  47. package/src/index.tsx +75 -17
  48. package/src/mcp/oauth.ts +47 -5
  49. package/src/mcp/token-store.ts +10 -11
  50. package/src/providers/openai-compat.ts +11 -0
  51. package/src/shared/arg-validation.ts +74 -2
  52. package/src/shared/package-info.ts +1 -1
  53. package/src/shared/regular-file.ts +63 -0
  54. package/src/shared/sanitize.ts +14 -2
  55. package/src/skills/bundled-skills.ts +1 -1
  56. package/src/tools/agent/memory.ts +4 -2
  57. package/src/tools/agent/workflow.ts +6 -3
  58. package/src/tools/exec/task.ts +82 -30
  59. package/src/tools/scheduling/cron.ts +3 -9
  60. package/src/ui/app.tsx +78 -14
  61. package/src/ui/commands.ts +73 -31
  62. package/src/ui/ctrl-c-confirm.ts +63 -0
  63. 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 a session is removed (list membership change).
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
- const newTitle = `session-${Date.now().toString(36)}`
140
- manager.rename(current.session.id, newTitle)
141
- showFeedback(`Renamed to ${newTitle}`)
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 — permanently remove the selected session
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
- manager.remove(current.session.id)
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
- setVersion((v) => v + 1)
157
- showFeedback(`Removed ${current.session.title || current.session.id}`)
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
- if (peekingSessionId) {
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
- }, [manager, peekingSessionId])
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, 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 {
@@ -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, set \`permission:\` in .mipham/config.yml — every mode below is
244
- accepted there.
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.config.permission} · Tools: ${ctx.engine.getTools().size}
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
- 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 {