@miphamai/cli 0.85.0 → 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 (49) 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/shared/update.ts +45 -1
  42. package/src/tools/agent/memory.ts +4 -2
  43. package/src/tools/agent/workflow.ts +6 -3
  44. package/src/tools/exec/task.ts +82 -30
  45. package/src/tools/scheduling/cron.ts +3 -9
  46. package/src/ui/app.tsx +48 -13
  47. package/src/ui/commands.ts +5 -2
  48. package/src/ui/ctrl-c-confirm.ts +63 -0
  49. package/src/workflow/journal.ts +80 -26
@@ -1,17 +1,16 @@
1
- import {
2
- mkdirSync,
3
- readdirSync,
4
- readFileSync,
5
- writeFileSync,
6
- unlinkSync,
7
- renameSync,
8
- existsSync,
9
- statSync,
10
- } from 'node:fs'
1
+ import { mkdirSync, readdirSync, unlinkSync, existsSync, statSync } from 'node:fs'
11
2
  import { join } from 'node:path'
12
3
  import { createHash } from 'node:crypto'
13
4
  import type { Message } from '../shared/types'
14
- import { sanitizeSessionName, SessionLog, deriveMessages, messageToEvents } from './session-log'
5
+ import {
6
+ sanitizeSessionName,
7
+ SessionLog,
8
+ deriveMessages,
9
+ messageToEvents,
10
+ isValidMessage,
11
+ } from './session-log'
12
+ import { atomicWriteFileSync } from '../shared/atomic-write'
13
+ import { readRegularFileSync } from '../shared/regular-file'
15
14
  import { miphamHome } from './paths.ts'
16
15
 
17
16
  export interface SessionMetadata {
@@ -54,6 +53,25 @@ function filePath(name: string): string {
54
53
  return join(SESSIONS_DIR, `${sanitizeSessionName(name)}.jsonl`)
55
54
  }
56
55
 
56
+ /**
57
+ * 旧格式快照(单 JSON 对象 `{metadata, messages}`)的形状闸。
58
+ *
59
+ * `'metadata' in parsed` 拦不住 `{"metadata": null, …}` —— `in` 看的是**键在不在**,
60
+ * 而 `/resume <name>` 紧接着就读 `session.metadata.name`,那条链上没有 try(实测
61
+ * `TypeError`)。`messages` 的元素同理:`[null]` 会一路送到 UI 的
62
+ * `forwardedMessages.map((msg) => msg.role)`(`app.tsx:1041`)与 provider 的内容分支。
63
+ *
64
+ * 形状不对**整份返回 null**,而不是把坏元素过滤掉:调用方随即回落到事件日志那条路,
65
+ * 读不出来会如实报 load failed;过滤则会凭空造出一段残缺历史 —— 静默改写用户数据。
66
+ */
67
+ function asStoredSnapshot(parsed: unknown): StoredSession | null {
68
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
69
+ const { metadata, messages } = parsed as { metadata?: unknown; messages?: unknown }
70
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null
71
+ if (!Array.isArray(messages) || !messages.every(isValidMessage)) return null
72
+ return { metadata: metadata as SessionMetadata, messages: messages as Message[] }
73
+ }
74
+
57
75
  export class SessionStore {
58
76
  /**
59
77
  * Save a session as JSONL (one JSON object per line).
@@ -79,10 +97,11 @@ export class SessionStore {
79
97
  messages,
80
98
  }
81
99
 
82
- // Atomic write: write to temp file, then rename (same-fs rename is atomic)
83
- const tmp = path + '.tmp'
84
- writeFileSync(tmp, JSON.stringify(session) + '\n', { encoding: 'utf-8', mode: 0o600 })
85
- renameSync(tmp, path)
100
+ // Atomic write: write to temp file, then rename (same-fs rename is atomic).
101
+ // 临时名由 atomicWriteFileSync 生成(pid + 随机后缀)—— 从前这里是固定的
102
+ // `path + '.tmp'`,两个进程同时存同一个会话时,先 rename 的那个会把**后一个**写者的
103
+ // 内容搬进目标,而它自己的 rename 再抛 ENOENT:在一个「目标永不半截」的函数里丢一次写。
104
+ atomicWriteFileSync(path, JSON.stringify(session) + '\n', { mode: 0o600 })
86
105
 
87
106
  // Incremental index update — only touch this session's entry
88
107
  try {
@@ -123,18 +142,12 @@ export class SessionStore {
123
142
  /** 从磁盘重开一个 SessionLog(不存在返回空 log)。旧格式快照自动迁移为事件日志。 */
124
143
  static loadLog(name: string): SessionLog {
125
144
  const path = filePath(name)
126
- if (!existsSync(path)) return SessionLog.open(name)
127
145
  try {
128
- const raw = readFileSync(path, 'utf-8')
129
- const parsed = JSON.parse(raw) as unknown
130
- if (
131
- parsed &&
132
- typeof parsed === 'object' &&
133
- 'metadata' in parsed &&
134
- Array.isArray((parsed as { messages?: unknown }).messages)
135
- ) {
146
+ const raw = readRegularFileSync(path)
147
+ if (raw === null) return SessionLog.open(name)
148
+ const old = asStoredSnapshot(JSON.parse(raw))
149
+ if (old) {
136
150
  // 旧格式快照 → 迁移为事件日志(重写文件,避免后续 append 混格式)
137
- const old = parsed as StoredSession
138
151
  const log = new SessionLog(name)
139
152
  log.append({
140
153
  type: 'session/start',
@@ -160,19 +173,12 @@ export class SessionStore {
160
173
  */
161
174
  static load(name: string): StoredSession | null {
162
175
  const path = filePath(name)
163
- if (!existsSync(path)) return null
164
-
165
176
  try {
166
- const raw = readFileSync(path, 'utf-8')
167
- // 旧格式:单 JSON 对象 {metadata, messages}
168
- const parsed = JSON.parse(raw) as unknown
169
- if (
170
- parsed &&
171
- typeof parsed === 'object' &&
172
- 'metadata' in parsed &&
173
- Array.isArray((parsed as { messages?: unknown }).messages)
174
- ) {
175
- return parsed as StoredSession
177
+ const raw = readRegularFileSync(path)
178
+ if (raw !== null) {
179
+ // 旧格式:单 JSON 对象 {metadata, messages}
180
+ const old = asStoredSnapshot(JSON.parse(raw))
181
+ if (old) return old
176
182
  }
177
183
  } catch {
178
184
  // 多行 JSONL → 新格式,走事件解析
@@ -295,7 +301,7 @@ export class SessionStore {
295
301
  }
296
302
  }
297
303
 
298
- writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { encoding: 'utf-8', mode: 0o600 })
304
+ atomicWriteFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { mode: 0o600 })
299
305
  }
300
306
 
301
307
  /**
@@ -323,7 +329,7 @@ export class SessionStore {
323
329
  } else {
324
330
  index.push(entry)
325
331
  }
326
- writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { encoding: 'utf-8', mode: 0o600 })
332
+ atomicWriteFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { mode: 0o600 })
327
333
  }
328
334
 
329
335
  /**
@@ -340,8 +346,7 @@ export class SessionStore {
340
346
  ? `${safeRaw.slice(0, 80)}-${createHash('sha256').update(safeRaw).digest('hex').slice(0, 16)}`
341
347
  : safeRaw
342
348
  const summaryPath = join(SUMMARIES_DIR, `${safe}.md`)
343
- writeFileSync(summaryPath, `# ${name}\n\n${summary}\n\nTags: ${tags.join(', ')}\n`, {
344
- encoding: 'utf-8',
349
+ atomicWriteFileSync(summaryPath, `# ${name}\n\n${summary}\n\nTags: ${tags.join(', ')}\n`, {
345
350
  mode: 0o600,
346
351
  })
347
352
 
@@ -365,7 +370,7 @@ export class SessionStore {
365
370
  tags,
366
371
  })
367
372
  }
368
- writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { encoding: 'utf-8', mode: 0o600 })
373
+ atomicWriteFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { mode: 0o600 })
369
374
  } catch {
370
375
  // Index write is best-effort
371
376
  }
@@ -414,11 +419,26 @@ export class SessionStore {
414
419
 
415
420
  /**
416
421
  * Read the raw index file, returning empty array if missing or corrupt.
422
+ *
423
+ * 「corrupt」不只是语法错:`null`、`{"a":1}`、`[1,2]` 都过得了 `JSON.parse`。放它们
424
+ * 过去,`getLatest()` 会按数组用它 —— `null` 在启动路径(`index.tsx` 建系统提示时)
425
+ * 抛 `TypeError: index.length`;`[1,2]` 更安静:`getLatest()` 返回数字 `1`,调用方接着
426
+ * 读 `latest.name` 拿到 `undefined`,再喂给 `load()` ⇒ `name.replace` 抛。条目还必须
427
+ * 有**字符串 name**:索引条目的第一用途就是当文件名用。
417
428
  */
418
429
  private static loadIndexRaw(): SessionIndexEntry[] {
419
- if (!existsSync(INDEX_FILE)) return []
430
+ const raw = readRegularFileSync(INDEX_FILE)
431
+ if (raw === null) return []
420
432
  try {
421
- return JSON.parse(readFileSync(INDEX_FILE, 'utf-8'))
433
+ const parsed: unknown = JSON.parse(raw)
434
+ if (!Array.isArray(parsed)) return []
435
+ return parsed.filter(
436
+ (e): e is SessionIndexEntry =>
437
+ !!e &&
438
+ typeof e === 'object' &&
439
+ typeof (e as { name?: unknown }).name === 'string' &&
440
+ (e as { name: string }).name.length > 0,
441
+ )
422
442
  } catch {
423
443
  return []
424
444
  }
@@ -1,6 +1,7 @@
1
1
  // apps/cli/src/daemon/auth.ts
2
2
  import { randomBytes, timingSafeEqual } from 'node:crypto'
3
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
3
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs'
4
+ import { atomicWriteFileSync } from '../shared/atomic-write'
4
5
  import { dirname } from 'node:path'
5
6
 
6
7
  /**
@@ -21,7 +22,7 @@ export function loadOrCreateToken(tokenPath: string): string {
21
22
 
22
23
  const token = generateToken()
23
24
  mkdirSync(dirname(tokenPath), { recursive: true, mode: 0o700 })
24
- writeFileSync(tokenPath, token, { mode: 0o600 })
25
+ atomicWriteFileSync(tokenPath, token, { mode: 0o600 })
25
26
  return token
26
27
  }
27
28
 
@@ -51,7 +52,7 @@ export function verifyToken(expected: string, provided: string): boolean {
51
52
  */
52
53
  export function rotateToken(tokenPath: string): string {
53
54
  const token = generateToken()
54
- writeFileSync(tokenPath, token, { mode: 0o600 })
55
+ atomicWriteFileSync(tokenPath, token, { mode: 0o600 })
55
56
  return token
56
57
  }
57
58
 
@@ -6,7 +6,8 @@
6
6
  // getPort() reads the last known port from disk
7
7
 
8
8
  import { join } from 'node:path'
9
- import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'
9
+ import { existsSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs'
10
+ import { atomicWriteFileSync } from '../shared/atomic-write'
10
11
  import { createServer as createNetServer } from 'node:net'
11
12
  import type { Server } from 'bun'
12
13
  import { DaemonDatabase } from './database'
@@ -247,8 +248,8 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
247
248
  activeServer = server
248
249
 
249
250
  // Write PID and port files
250
- writeFileSync(PID_FILE, String(process.pid))
251
- writeFileSync(PORT_FILE, String(port))
251
+ atomicWriteFileSync(PID_FILE, String(process.pid), { mode: 0o644 })
252
+ atomicWriteFileSync(PORT_FILE, String(port), { mode: 0o644 })
252
253
 
253
254
  return { port, token }
254
255
  }
@@ -893,6 +893,7 @@
893
893
  },
894
894
  "status": {
895
895
  "esc_to_interrupt": "esc to interrupt",
896
+ "ctrl_c_again": "ctrl+c again to exit",
896
897
  "shift_tab_cycle": "shift+tab",
897
898
  "left_for_agents": "ctrl+g agents to manage",
898
899
  "mipham_modes": "Mipham",
@@ -893,6 +893,7 @@
893
893
  },
894
894
  "status": {
895
895
  "esc_to_interrupt": "Esc 中断",
896
+ "ctrl_c_again": "再按一次 Ctrl+C 退出",
896
897
  "shift_tab_cycle": "Shift+Tab",
897
898
  "left_for_agents": "ctrl+g 管理代理",
898
899
  "mipham_modes": "Mipham独有",
package/src/index.tsx CHANGED
@@ -346,6 +346,10 @@ export async function runApp(options: RunOptions): Promise<void> {
346
346
  sessionId: options.remoteSession.sessionId,
347
347
  }),
348
348
  }),
349
+ // Ink 默认 exitOnCtrlC: true,它在**任何 handler 拿到按键之前**就退进程
350
+ // (App.js `input === '\x03'`,且 useInput 那条路还会跳过全部监听器)。
351
+ // 于是 Ctrl+C 的语义只能由我们自己定 —— 见 App 里的「再按一次才退」。
352
+ { exitOnCtrlC: false },
349
353
  )
350
354
  await waitUntilExit()
351
355
 
@@ -364,6 +368,7 @@ export async function runApp(options: RunOptions): Promise<void> {
364
368
  onAttach={() => {}}
365
369
  onExit={() => process.exit(0)}
366
370
  />,
371
+ { exitOnCtrlC: false },
367
372
  )
368
373
  await waitUntilExit()
369
374
  process.exit(0)
@@ -868,6 +873,7 @@ export async function runApp(options: RunOptions): Promise<void> {
868
873
  t,
869
874
  }),
870
875
  }),
876
+ { exitOnCtrlC: false },
871
877
  )
872
878
  await waitUntilExit()
873
879
  saveAndExit()
package/src/mcp/oauth.ts CHANGED
@@ -3,7 +3,7 @@ import { exec } from 'node:child_process'
3
3
  import { createServer, Server } from 'node:http'
4
4
  import type { IncomingMessage, ServerResponse } from 'node:http'
5
5
  import type { McpServerConfig } from '../shared/types'
6
- import { TokenStore } from './token-store'
6
+ import { TokenStore, type TokenData } from './token-store'
7
7
  import { fetchWithRetry } from '../providers/fetch-utils'
8
8
  import { createT } from '../i18n-core/t'
9
9
  import enUS from '../i18n-core/locales/en-US.json'
@@ -23,6 +23,29 @@ interface TokenResponse {
23
23
  scopes?: string[]
24
24
  }
25
25
 
26
+ /**
27
+ * 这份凭证**绑给谁** —— 即它会流经哪几个端点、以哪个 client 身份取得。
28
+ *
29
+ * 为什么需要:`TokenStore` 只按**服务名**存(`<name>.enc`),而这个名来自
30
+ * `.mcp.json` —— 那是**从 cwd 读**的项目级配置。clone 一个仓库就等于让它给你的
31
+ * MCP 服务起名,于是「名字相同」推不出「签发方相同」:把某个常见名(如 `github`)
32
+ * 的 url / tokenUrl 指到别处,缓存里那份真凭证就会被送给新的主机 —— access token
33
+ * 被直接塞进 env 交给对面,refresh token 被 POST 给对面的令牌端点(后者更危险:
34
+ * 它能换出新的 access token)。
35
+ *
36
+ * 绑定的是**凭证会去的地方**,四者任一变化即视为另一份凭证:授权端点、令牌端点、
37
+ * client 身份、资源端点(access token 最终发给它)。
38
+ */
39
+ export function credentialBinding(config: McpServerConfig): string {
40
+ const auth = config.auth
41
+ return JSON.stringify([
42
+ auth?.authorizationUrl ?? '',
43
+ auth?.tokenUrl ?? '',
44
+ auth?.clientId ?? '',
45
+ config.url ?? '',
46
+ ])
47
+ }
48
+
26
49
  function base64url(buf: Buffer): string {
27
50
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
28
51
  }
@@ -127,12 +150,28 @@ export class OAuthClient {
127
150
  scopes: data.scope?.split(' '),
128
151
  }
129
152
 
130
- this.store.save(config.name, result)
153
+ this.store.save(config.name, { ...result, boundTo: credentialBinding(config) })
131
154
  return result
132
155
  }
133
156
 
134
- async getValidAccessToken(serverName: string, config: McpServerConfig): Promise<string> {
157
+ /**
158
+ * 取回该服务名下的凭证 —— **仅当它确实是当前这份配置签发的**。
159
+ *
160
+ * 绑定值不符时**不删**:把配置改回去,这份凭证仍然有效,而删除是不可逆的副作用。
161
+ * 但也**不用**它 —— 换过端点之后,「同名」不再说明任何事。
162
+ *
163
+ * 旧版本存下的凭证没有 `boundTo`(`undefined`)⇒ 一律不匹配 ⇒ 走一次 PKCE 重新
164
+ * 授权。这是认下的代价:无法核实签发方的凭证,就是不能拿出去用,而多授权一次
165
+ * 的代价远小于把一份 refresh token 交给别人的端点。
166
+ */
167
+ private loadBoundToken(serverName: string, config: McpServerConfig): TokenData | null {
135
168
  const saved = this.store.load(serverName)
169
+ if (!saved) return null
170
+ return saved.boundTo === credentialBinding(config) ? saved : null
171
+ }
172
+
173
+ async getValidAccessToken(serverName: string, config: McpServerConfig): Promise<string> {
174
+ const saved = this.loadBoundToken(serverName, config)
136
175
  if (saved && new Date(saved.expiresAt).getTime() > Date.now() + 60000) {
137
176
  return saved.accessToken
138
177
  }
@@ -144,9 +183,11 @@ export class OAuthClient {
144
183
  }
145
184
 
146
185
  async refreshAccessToken(serverName: string, config: McpServerConfig): Promise<string> {
147
- const saved = this.store.load(serverName)
186
+ const saved = this.loadBoundToken(serverName, config)
148
187
  if (!saved?.refreshToken) {
149
- throw new Error(`No refresh token available for "${serverName}"`)
188
+ throw new Error(
189
+ `No refresh token available for "${serverName}" — none stored, or the stored one was issued for a different endpoint`,
190
+ )
150
191
  }
151
192
  const auth = config.auth!
152
193
  // A single failed refresh is often a transient network/server error, not a
@@ -179,6 +220,7 @@ export class OAuthClient {
179
220
  accessToken: data.access_token,
180
221
  refreshToken: data.refresh_token || saved.refreshToken,
181
222
  expiresAt: new Date(Date.now() + (data.expires_in || 3600) * 1000).toISOString(),
223
+ boundTo: credentialBinding(config),
182
224
  })
183
225
  return data.access_token
184
226
  }
@@ -1,22 +1,21 @@
1
- import {
2
- existsSync,
3
- readFileSync,
4
- writeFileSync,
5
- mkdirSync,
6
- unlinkSync,
7
- readdirSync,
8
- chmodSync,
9
- } from 'node:fs'
1
+ import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, chmodSync } from 'node:fs'
2
+ import { atomicWriteFileSync } from '../shared/atomic-write'
10
3
  import { join, dirname } from 'node:path'
11
4
  import { encrypt, decrypt, getCredentialKey } from '../config/credential-crypto'
12
5
  import { miphamHome } from '../core/paths.ts'
13
6
 
14
- interface TokenData {
7
+ export interface TokenData {
15
8
  accessToken: string
16
9
  refreshToken?: string
17
10
  expiresAt: string
18
11
  createdAt?: string
19
12
  scopes?: string[]
13
+ /**
14
+ * 这份凭证绑给哪个签发方(见 `credentialBinding`)。缺失 = 绑定未知,
15
+ * 调用方必须当作**不可用** —— 凭证只按服务名存,而服务名来自可从 cwd 读的
16
+ * 项目级配置,故「同名」推不出「同一签发方」。
17
+ */
18
+ boundTo?: string
20
19
  }
21
20
 
22
21
  export class TokenStore {
@@ -36,7 +35,7 @@ export class TokenStore {
36
35
  createdAt: data.createdAt || new Date().toISOString(),
37
36
  })
38
37
  const encrypted = encrypt(json, this.key)
39
- writeFileSync(filePath, encrypted, { mode: 0o600 })
38
+ atomicWriteFileSync(filePath, encrypted, { mode: 0o600 })
40
39
  try {
41
40
  chmodSync(filePath, 0o600)
42
41
  } catch {
@@ -280,6 +280,17 @@ export class OpenAICompatProvider implements ProviderInstance {
280
280
  const msg = messages[i]!
281
281
  if (!msg) continue
282
282
 
283
+ // A `system` entry is a header, not a turn: it belongs in the first slot,
284
+ // and never alongside an already-emitted system prompt. A mid-array entry
285
+ // is a client-generated UI line — the engine stores a provider failure as
286
+ // `system` so a resumed session can render it as an ⚠ line — and passing it
287
+ // through as system role hands text that came *from a provider* the highest
288
+ // authority on the very next turn. anthropic.ts drops system entries
289
+ // outright (its protocol forbids them in the array at all); keeping the
290
+ // head is the part OpenAI-compatible endpoints genuinely accept.
291
+ const isHeader = msg.role === 'system' && i === 0 && result.length === 0
292
+ if (msg.role === 'system' && !isHeader) continue
293
+
283
294
  // ── String content ──
284
295
  if (typeof msg.content === 'string') {
285
296
  // Combine: assistant text + next assistant message with tool_use blocks
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.85.0' as const
12
+ export const PACKAGE_VERSION = '0.85.2' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -0,0 +1,63 @@
1
+ import { appendFileSync, existsSync, readFileSync, statSync } from 'node:fs'
2
+
3
+ /**
4
+ * 路径是不是**普通文件** —— 判据是文件**类型**,不是存在性。
5
+ *
6
+ * `existsSync` 对 FIFO、socket、设备节点、目录一律为真,而 `readFileSync` 读一个
7
+ * 没有写者的 FIFO 会**阻塞到有写者打开它为止**。于是 `mkfifo ~/.mipham/config.yml`
8
+ * 这一条命令(或某个崩掉的工具留在 `~/.mipham/` 下的 socket)就能让 CLI 启动**永久
9
+ * 挂住**:没有输出、没有报错,也没有任何东西能给它计时 —— 阻塞发生在同步调用里,
10
+ * 定时器与 signal handler 都排不上队。闸门只能架在文件类型上。
11
+ *
12
+ * 符号链接指向普通文件**算数**(`statSync` 跟随链接,`~/.mipham` 常被软链进
13
+ * dotfiles 仓库);指向 FIFO 的不算。
14
+ *
15
+ * 已知边界:stat 与随后的 read 之间不是原子的 —— 恰好在那道缝里把路径换成 FIFO
16
+ * 仍会阻塞。不设防:能改你配置路径的人本来就能做更坏的事。
17
+ */
18
+ export function isRegularFile(path: string): boolean {
19
+ try {
20
+ return statSync(path).isFile()
21
+ } catch {
22
+ // 不存在(ENOENT)、路径中有一段不是目录(ENOTDIR)、权限不足(EACCES)、
23
+ // 链接成环(ELOOP)—— 对调用方都是同一件事:这里没有能读的内容。
24
+ return false
25
+ }
26
+ }
27
+
28
+ /**
29
+ * 读一个**普通文件**的文本内容;没有可读的普通文件时返回 null。
30
+ *
31
+ * null 的含义是「这里没有可读的普通文件」,三种来源一视同仁:缺失、不是普通文件
32
+ * (FIFO / socket / 目录 / 设备节点)、是普通文件但读不动(权限等)。
33
+ *
34
+ * 本函数**不往 stderr 写任何东西** —— 多数调用点读的是可选覆盖层,「文件不在」是
35
+ * 常态。需要提示的调用点自己判断(见 `config/loader.ts` 的 `safeParseYaml`)。
36
+ */
37
+ export function readRegularFileSync(path: string): string | null {
38
+ if (!isRegularFile(path)) return null
39
+ try {
40
+ return readFileSync(path, 'utf-8')
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ /**
47
+ * 追加写入,只在目标**是普通文件或还不存在**时才写;返回是否真的写了。
48
+ *
49
+ * `appendFileSync` 会**打开目标写** —— 目标若是 FIFO 就等到有读者为止,与读侧同一个
50
+ * 坑,只是撞在写这一侧(配置备份的 `copyFileSync` 当初也是这么挂住的)。「还不存在」
51
+ * 必须放行:追加写天然承担创建。
52
+ *
53
+ * 返回 `false` 的意思是**没写**,调用方得知道自己丢了什么 —— 别把「跳过」当成功。
54
+ */
55
+ export function appendRegularFileSync(
56
+ path: string,
57
+ content: string,
58
+ options: { mode?: number } = {},
59
+ ): boolean {
60
+ if (existsSync(path) && !isRegularFile(path)) return false
61
+ appendFileSync(path, content, { encoding: 'utf-8', mode: options.mode ?? 0o600 })
62
+ return true
63
+ }
@@ -25,16 +25,28 @@
25
25
  * applied to what tools *write to disk*, not only to what gets pattern-matched.
26
26
  * Adding them here would silently rewrite file contents.
27
27
  *
28
+ * Deliberately **not** included — the two joiners, ZWNJ (U+200C) and ZWJ (U+200D),
29
+ * for exactly the reason above. This is why the zero-width part of the set is spelled
30
+ * `\u{200B}\u{200E}-\u{200F}` and *not* the closed range `\u{200B}-\u{200F}` it used to be:
31
+ * - both are load-bearing in *visible* text. ZWNJ attaches a Persian/Arabic suffix to a
32
+ * Latin word or number (the plural of "PDF"), and ZWJ is what holds a family emoji
33
+ * together (U+1F468 U+200D U+1F469 U+200D U+1F467) rather than three separate people;
34
+ * - they buy nothing on the permission path either, since no shell ignores them —
35
+ * `bash -c 'ec<ZWJ>ho hi'` is `command not found` (same for ZWNJ and ZWSP). So removing
36
+ * them from the set costs no matching protection; it only stops the write path from
37
+ * rewriting what the user asked us to write.
38
+ *
28
39
  * The tag block (U+E0000–E007F) *is* included: it is invisible in itself, and the
29
40
  * only sequences that use it (subdivision flags, e.g. U+1F3F4 + a tag run) degrade
30
41
  * to the bare black flag — which renders the same.
31
42
  */
32
43
  const DANGEROUS_UNICODE =
33
- /[\u{061C}\u{115F}-\u{1160}\u{180E}\u{200B}-\u{200F}\u{202A}-\u{202E}\u{2060}\u{2066}-\u{2069}\u{3164}\u{FEFF}\u{FFA0}\u{E0000}-\u{E007F}]/gu
44
+ /[\u{061C}\u{115F}-\u{1160}\u{180E}\u{200B}\u{200E}-\u{200F}\u{202A}-\u{202E}\u{2060}\u{2066}-\u{2069}\u{3164}\u{FEFF}\u{FFA0}\u{E0000}-\u{E007F}]/gu
34
45
 
35
46
  /**
36
47
  * Strip dangerous invisible Unicode characters from a string.
37
- * - Zero-width: U+200B (ZWSP), U+200C (ZWNJ), U+200D (ZWJ), U+200E/F (LTR/RTL marks)
48
+ * - Zero-width: U+200B (ZWSP), U+200E/F (LTR/RTL marks) — the joiners U+200C/U+200D are
49
+ * deliberately kept, see `DANGEROUS_UNICODE`
38
50
  * - Bidi controls: U+202A-E, U+2066-9, U+061C (Arabic letter mark)
39
51
  * - Word joiner: U+2060
40
52
  * - BOM: U+FEFF
@@ -320,8 +320,36 @@ export function verifyInstalledVersion(paths: InstallPaths, expected: string): I
320
320
  /**
321
321
  * 传给 npm 调用的选项。**只此一处** —— 默认 runner 原样转发它,所以测试断的选项
322
322
  * 与生产跑的是同一个对象,不是一个长得像的副本。
323
+ *
324
+ * `detached: true`:npm 自成**进程组** ⇒ 终端按 Ctrl-C 时内核发出的 SIGINT
325
+ * (只发给**前台**进程组)到不了它。没有这一条,Ctrl-C 会把 CLI 与 npm 一起打死在
326
+ * `reify` 中间 —— 旧树已删、新树没写完。它与 `blockSigintDuringInstall()` 是一对:
327
+ * 那条保住 CLI(好让下面的 catch 有机会回滚),这条保住 npm。
323
328
  */
324
- export type InstallOptions = { encoding: 'utf-8'; stdio: 'inherit' }
329
+ export type InstallOptions = { encoding: 'utf-8'; stdio: 'inherit'; detached: true }
330
+
331
+ /**
332
+ * 安装期间把 CLI 自己的 SIGINT 挡掉,返回「撤销」函数。
333
+ *
334
+ * 回滚代码就在 `performUpdate` 的 catch 里 —— 而终端按 Ctrl-C 时 SIGINT 发给**整个
335
+ * 前台进程组**,CLI 与 npm 会一起死 ⇒ catch 永远不执行,用户手里留下半截树且**无人回滚**
336
+ * (2026-09-22 事故里「回滚写了却没跑」的那一格)。
337
+ *
338
+ * ⚠️ 挂了 handler 之后信号**不会立刻**变成 JS 回调:事件循环正阻塞在 `execSync` 里,
339
+ * 回调要等它返回才跑(实测打印顺序是「execSync 返回」在前、「handler 跑了」在后)。
340
+ * 所以**不能**用一个标志位判断「用户按过 Ctrl-C」—— 读完 execSync 立刻看,它一定还是
341
+ * `false`。本函数只保证进程活着,不做上报。
342
+ *
343
+ * ⚠️ 「安装期间 Ctrl-C 会被忽略」是**用户可见的行为变化** —— 本次改动只落在代码与测试,
344
+ * 发布那一笔必须写进 `CHANGELOG.md`(别忘了它是代价,不是附带好处)。
345
+ */
346
+ function blockSigintDuringInstall(): () => void {
347
+ const swallow = (): void => {}
348
+ process.on('SIGINT', swallow)
349
+ return () => {
350
+ process.off('SIGINT', swallow)
351
+ }
352
+ }
325
353
 
326
354
  /** 执行 `npm install -g`。可注入 —— 测试永不联网。 */
327
355
  export type InstallRunner = (command: string, options: InstallOptions) => void
@@ -381,20 +409,36 @@ export function performUpdate(
381
409
  if (paths)
382
410
  snap = snapshotInstall(paths, readPkgVersion(paths.pkgDir) ?? getCurrentVersion(), backupRoot)
383
411
 
412
+ // 安装期间两道守卫(缺一,被保下来的都只有一半):
413
+ // · 这里 —— CLI 自己不被 SIGINT 打死,好让下面的 catch/回滚有机会跑;
414
+ // · 下面的 `detached: true` —— npm 自成进程组,终端的 SIGINT 到不了它。
415
+ //
416
+ // 守位只盖住「npm 在跑」这一段 —— 也就是**唯一会破坏磁盘**的那一段。两端各留一个
417
+ // 未覆盖的窄口,各自无害:① 它前面的快照(复制 6601 个文件)期间按 Ctrl-C ⇒ CLI 退出、
418
+ // npm 从未启动,旧树完好;② 它后面的自证(只读:读 package.json + 跑一次 launcher)
419
+ // 期间按 Ctrl-C ⇒ 不再自动回滚,但树是**完整的**,不是「一个 CLI 都没有」。
420
+ const unblockSigint = blockSigintDuringInstall()
384
421
  try {
385
422
  // 这里**故意不设 timeout**。任何一个能在正常安装途中开火的计时器,开火那一刻就是破坏
386
423
  // 本身:npm 被 SIGTERM 时会留下半截树(旧包已删、新包没写完)⇒ 用户一个 CLI 都没有,
387
424
  // 连 `mipham update` 本身也没了。本机实测这个包的下载要 11 分钟以上,而原来设在 10 分钟。
388
425
  // 进度由 npm 自己印在用户终端上(stdio: 'inherit'),要中断交由用户决定。
426
+ //
427
+ // detached 只换进程组、不换等待语义 —— 实测 execSync 照样阻塞到 npm 退出(1.01s vs
428
+ // 未 detached 的 1.02s),所以自证仍在装完之后;stdio:'inherit' 下它的输出也照样
429
+ // 打在用户终端上(两条通道都实测可见)。
389
430
  install(`npm install -g ${PACKAGE}@${version}${registryFlag}`, {
390
431
  encoding: 'utf-8',
391
432
  stdio: 'inherit',
433
+ detached: true,
392
434
  })
393
435
  } catch (err) {
394
436
  const rolledBack = paths && snap ? restoreInstall(snap, paths) : false
395
437
  discardSnapshot(snap)
396
438
  const detail = err instanceof Error && err.message ? `(${err.message.split('\n')[0]})` : ''
397
439
  return { ok: false, verified: false, rolledBack, reason: `安装进程被中断${detail}` }
440
+ } finally {
441
+ unblockSigint()
398
442
  }
399
443
 
400
444
  if (!paths) {
@@ -1,4 +1,5 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs'
1
+ import { readFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs'
2
+ import { atomicWriteFileSync } from '../../shared/atomic-write'
2
3
  import { join } from 'node:path'
3
4
  import type { ToolDefinition } from '../../shared/index.ts'
4
5
  import { MemoryManager } from '../../core/memory/memory-manager'
@@ -83,7 +84,8 @@ export const memoryTool: ToolDefinition = {
83
84
 
84
85
  if (action === 'write') {
85
86
  const body = formatMemory(name, params.content as string)
86
- writeFileSync(filePath, body, 'utf-8')
87
+ // 与 MemoryManager 写的是同一批 .md:两侧都走原子写,权限语义保持一致。
88
+ atomicWriteFileSync(filePath, body, { mode: 0o644 })
87
89
  return { success: true, content: `Memory "${name}" written` }
88
90
  }
89
91
 
@@ -111,16 +111,19 @@ export const workflowTool: ToolDefinition = {
111
111
  // Persist last-run state for /workflow save
112
112
  let persistWarning = ''
113
113
  try {
114
- const { existsSync, mkdirSync, writeFileSync } = await import('node:fs')
114
+ const { existsSync, mkdirSync } = await import('node:fs')
115
+ const { atomicWriteFileSync } = await import('../../shared/atomic-write')
115
116
  const { join } = await import('node:path')
116
117
  const workflowsDir = workflowScriptDir(process.cwd())
117
118
  if (!existsSync(workflowsDir)) {
118
119
  mkdirSync(workflowsDir, { recursive: true })
119
120
  }
120
- writeFileSync(
121
+ // 机器状态(每次 run 重写,`/workflow save` 会读回):非原子写留下的半截 JSON
122
+ // 会让那条读取抛解析错。
123
+ atomicWriteFileSync(
121
124
  join(workflowsDir, '.last-run.json'),
122
125
  JSON.stringify({ runId, script, timestamp: new Date().toISOString() }),
123
- 'utf-8',
126
+ { mode: 0o644 },
124
127
  )
125
128
  } catch (err) {
126
129
  // Persistence stays best-effort — the workflow itself succeeded, so a