@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
@@ -1,15 +1,8 @@
1
- import {
2
- readFileSync,
3
- existsSync,
4
- copyFileSync,
5
- mkdirSync,
6
- readdirSync,
7
- unlinkSync,
8
- chmodSync,
9
- } from 'node:fs'
1
+ import { existsSync, copyFileSync, mkdirSync, readdirSync, unlinkSync, chmodSync } from 'node:fs'
10
2
  import { join, dirname } from 'node:path'
11
3
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
12
4
  import { atomicWriteFileSync } from '../shared/atomic-write'
5
+ import { isRegularFile, readRegularFileSync } from '../shared/regular-file'
13
6
  import type {
14
7
  MiphamConfig,
15
8
  ProviderConfig,
@@ -41,7 +34,16 @@ const BACKUP_PREFIX = 'config.backup-'
41
34
  function safeParseYaml(path: string, label: string): Partial<MiphamConfig> | null {
42
35
  try {
43
36
  if (!existsSync(path)) return null
44
- const raw = readFileSync(path, 'utf-8')
37
+ // `existsSync` 先过一遍只为「缺席不吭声」;真正决定读不读的是**文件类型**
38
+ // —— FIFO 上 `readFileSync` 会阻塞到有写者出现,那次挂死没有报错也没有
39
+ // 计时器救得回来(见 shared/regular-file.ts)。
40
+ const raw = readRegularFileSync(path)
41
+ if (raw === null) {
42
+ process.stderr.write(
43
+ `⚠ Mipham Code: ${label} (${path}) is not a readable regular file — ignoring it.\n`,
44
+ )
45
+ return null
46
+ }
45
47
  return parseYaml(raw) as Partial<MiphamConfig>
46
48
  } catch (err: unknown) {
47
49
  const msg = err instanceof Error ? err.message : String(err)
@@ -145,13 +147,43 @@ function mergeConfig(
145
147
  return merged
146
148
  }
147
149
 
150
+ /**
151
+ * Drop `permission` from a **project-level** `config.yml` before it is merged,
152
+ * reporting it instead of applying it.
153
+ *
154
+ * `permission` is a ceiling, not a rule: it decides what the approval gate lets
155
+ * through without asking. Every other project-level key is a preference that a
156
+ * repository may reasonably commit; this one means "and do not ask me about the
157
+ * commands in here" — which is a decision about the operator, made by whoever
158
+ * wrote the repository. The same reasoning admits project-level `permissions.deny`
159
+ * (it narrows) and withholds `permissions.defaultMode` in `settings.json` (it
160
+ * widens) — the two files are the same door, so both are closed here.
161
+ *
162
+ * `mergeConfig` has no per-key allowlist, so this has to happen at the two points
163
+ * the project file enters. Reported rather than dropped silently: a repo whose
164
+ * setting stopped working should say so out loud, and silence is exactly what
165
+ * "your config was ignored" looks like from the outside.
166
+ */
167
+ function stripProjectPermission(cfg: Partial<MiphamConfig>, path: string): Partial<MiphamConfig> {
168
+ if (cfg.permission === undefined) return cfg
169
+ const { permission, ...rest } = cfg
170
+ const shown = typeof permission === 'string' ? permission : JSON.stringify(permission)
171
+ process.stderr.write(
172
+ `⚠ Mipham Code: ignored permission: ${shown} from project config ${path}\n` +
173
+ ` (a repository must not choose the approval gate — set it in ~/.mipham/config.yml,\n` +
174
+ ` or pass --permission <mode> for this invocation)\n`,
175
+ )
176
+ return rest as Partial<MiphamConfig>
177
+ }
178
+
148
179
  /**
149
180
  * Save a timestamped backup of config.yml to ~/.mipham/.
150
181
  * Keeps at most 5 backups; older ones are pruned.
151
182
  */
152
183
  function backupConfig(configPath: string): void {
153
184
  try {
154
- if (!existsSync(configPath)) return
185
+ // 备份源同样要过类型闸:`copyFileSync` 打开 FIFO 读端一样会等到有写者为止。
186
+ if (!isRegularFile(configPath)) return
155
187
  mkdirSync(MIPHAM_HOME, { recursive: true, mode: 0o700 })
156
188
 
157
189
  const ts = new Date().toISOString().replace(/[:.]/g, '-')
@@ -190,6 +222,10 @@ export function tryRestoreFromBackup(configPath: string): boolean {
190
222
 
191
223
  if (files.length === 0) return false
192
224
 
225
+ // 目标已经在那儿而且不是普通文件(FIFO/socket/目录)时不还原:往 FIFO 写
226
+ // 会挡在「等读者」上,把「恢复」变成又一次挂死;目录则会 EISDIR。缺席才写。
227
+ if (existsSync(configPath) && !isRegularFile(configPath)) return false
228
+
193
229
  const latestBackup = join(MIPHAM_HOME, files[0]!)
194
230
  copyFileSync(latestBackup, configPath)
195
231
  process.stderr.write(`⚠ Mipham Code: restored config from backup (${files[0]})\n`)
@@ -220,8 +256,8 @@ function loadMcpJson(cwd: string): McpServerConfig[] {
220
256
 
221
257
  for (const path of searchPaths) {
222
258
  try {
223
- if (!existsSync(path)) continue
224
- const raw = readFileSync(path, 'utf-8')
259
+ const raw = readRegularFileSync(path)
260
+ if (raw === null) continue
225
261
  // Every McpServerConfig field is optional here (the name comes from the
226
262
  // key), so a field added to the type is accepted without touching this.
227
263
  const parsed = JSON.parse(raw) as {
@@ -247,11 +283,21 @@ function loadMcpJson(cwd: string): McpServerConfig[] {
247
283
 
248
284
  /**
249
285
  * Parsed `settings.json` (Claude Code convention): hooks + permissions.
250
- * Hooks are additive across levels; permissions allow/deny are deduped unions.
286
+ * Hooks are additive across levels; permissions allow/deny are deduped unions —
287
+ * and `defaultMode` is the one member that is **not** merged, because it is a
288
+ * ceiling rather than a rule (see `loadSettingsJson`).
251
289
  */
252
290
  export interface SettingsJson {
253
291
  hooks: SettingsHooks
254
- permissions: { allow: string[]; deny: string[] }
292
+ /**
293
+ * `defaultMode` is present only when the **user-level** file named one, and it
294
+ * is passed through **raw** (not validated here): the accepted spelling is
295
+ * `ALL_MODES`, and the single place that enforces it — plus the warning for a
296
+ * value that is not a mode — is `PermissionSystem.setDefaultLevel`. A second
297
+ * validator here would be a second value domain, and the two would drift the
298
+ * day a mode is added.
299
+ */
300
+ permissions: { allow: string[]; deny: string[]; defaultMode?: string }
255
301
  /**
256
302
  * Present (and `true`) only when the project-level file really did declare
257
303
  * hooks and they were withheld because the caller did not vouch for the
@@ -259,6 +305,13 @@ export interface SettingsJson {
259
305
  * so a caller announcing the skip cannot announce one that never happened.
260
306
  */
261
307
  projectHooksSkipped?: true
308
+ /**
309
+ * Same shape, for `permissions.defaultMode`: present only when the
310
+ * project-level file really declared a mode and it was **withheld**. Unlike
311
+ * `projectHooksSkipped` this is not conditioned on trust — the ceiling does not
312
+ * move for a file that arrives with the code, trusted workspace or not.
313
+ */
314
+ projectModeSkipped?: true
262
315
  /**
263
316
  * The subset of `hooks` that came from the project-level file — the entries
264
317
  * the workspace-trust gate governs. Absent unless the caller vouched for the
@@ -282,14 +335,35 @@ export interface SettingsJson {
282
335
  * established trust (or that only *display* the configured list) opt in
283
336
  * explicitly. The flag gates hooks only: `permissions` still merge from both
284
337
  * levels, since that question is answered by the mode ceiling, not by trust.
338
+ *
339
+ * **That last sentence is the reason `defaultMode` is the exception.** The
340
+ * ceiling only answers the trust question while repository-controlled files
341
+ * cannot move it; a repo that ships `.mipham/settings.json` with
342
+ * `permissions.defaultMode: "auto"` would otherwise hand itself the approval
343
+ * gate. So `defaultMode` is read from the **user-level file only**, and a
344
+ * project-level one is withheld and *reported* (`projectModeSkipped`) rather
345
+ * than dropped in silence — same treatment as a `plan`/`acceptEdits` value that
346
+ * used to be dropped entirely, except that the drop there fell back to the
347
+ * *wider* `default`. The upstream convention says the same thing in its own
348
+ * words: repo-level settings cannot grant `defaultMode`; adopt it in user
349
+ * settings instead.
350
+ *
351
+ * One consequence to keep in mind when reading a merged result: `permissions`
352
+ * is still provenance-free for allow/deny, so a caller cannot tell which of
353
+ * those two rules came from the repository. That is deliberate (above), and it
354
+ * is why the ceiling has to be the thing that repels the repo-controlled half.
285
355
  */
286
356
  export function loadSettingsJson(
287
357
  cwd: string = process.cwd(),
288
358
  options: { includeProjectHooks?: boolean } = {},
289
359
  ): SettingsJson {
290
360
  const hooks: SettingsHooks = {}
291
- const permissions = { allow: [] as string[], deny: [] as string[] }
361
+ const permissions: { allow: string[]; deny: string[]; defaultMode?: string } = {
362
+ allow: [],
363
+ deny: [],
364
+ }
292
365
  let projectHooksSkipped = false
366
+ let projectModeSkipped = false
293
367
  // The project file's entries, kept out of the merge so provenance survives it.
294
368
  const projectHooks: SettingsHooks = {}
295
369
 
@@ -304,11 +378,11 @@ export function loadSettingsJson(
304
378
 
305
379
  for (const { path, readHooks, isProject } of searchPaths) {
306
380
  try {
307
- if (!existsSync(path)) continue
308
- const raw = readFileSync(path, 'utf-8')
381
+ const raw = readRegularFileSync(path)
382
+ if (raw === null) continue
309
383
  const parsed = JSON.parse(raw) as {
310
384
  hooks?: Record<string, unknown>
311
- permissions?: { allow?: unknown; deny?: unknown }
385
+ permissions?: { allow?: unknown; deny?: unknown; defaultMode?: unknown }
312
386
  }
313
387
 
314
388
  if (!readHooks) {
@@ -336,6 +410,20 @@ export function loadSettingsJson(
336
410
  }
337
411
 
338
412
  if (parsed.permissions) {
413
+ // A mode counts as *declared* only when it is a non-empty string — the
414
+ // same test the hooks branch above uses, so a marker cannot outrun the
415
+ // fact it reports. What the string says is not checked here (see the
416
+ // `permissions` field doc): a typo must reach the applier, which is the
417
+ // one place that knows the accepted spellings and issues the warning.
418
+ const declaredMode =
419
+ typeof parsed.permissions.defaultMode === 'string' &&
420
+ parsed.permissions.defaultMode.trim() !== ''
421
+ ? parsed.permissions.defaultMode
422
+ : undefined
423
+ if (declaredMode !== undefined) {
424
+ if (isProject) projectModeSkipped = true
425
+ else permissions.defaultMode = declaredMode
426
+ }
339
427
  for (const key of ['allow', 'deny'] as const) {
340
428
  const list = parsed.permissions[key]
341
429
  if (!Array.isArray(list)) continue
@@ -355,6 +443,7 @@ export function loadSettingsJson(
355
443
  // gated on the strength of a file with nothing in it.
356
444
  const result: SettingsJson = { hooks, permissions }
357
445
  if (projectHooksSkipped) result.projectHooksSkipped = true
446
+ if (projectModeSkipped) result.projectModeSkipped = true
358
447
  if (Object.values(projectHooks).some((entries) => Array.isArray(entries) && entries.length > 0)) {
359
448
  result.projectHooks = projectHooks
360
449
  }
@@ -376,10 +465,11 @@ export function settingsPathFor(scope: SettingsScope, cwd: string = process.cwd(
376
465
  * not something to clobber — the user's other settings live in the same file.
377
466
  */
378
467
  export function readSettingsDoc(path: string): Record<string, unknown> {
379
- if (!existsSync(path)) return {}
468
+ const raw = readRegularFileSync(path)
469
+ if (raw === null) return {}
380
470
  let parsed: unknown
381
471
  try {
382
- parsed = JSON.parse(readFileSync(path, 'utf-8'))
472
+ parsed = JSON.parse(raw)
383
473
  } catch {
384
474
  throw new Error(`${path} is not valid JSON. Fix or remove it, then retry.`)
385
475
  }
@@ -448,12 +538,24 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
448
538
 
449
539
  let config = { ...DEFAULT_CONFIG }
450
540
 
541
+ // The project file enters `loadConfig` at **two** points (fresh parse, and the
542
+ // parse that follows a restore from backup). Both go through here, so the guard
543
+ // on `permission` cannot be present on one path and missing on the other — a
544
+ // corrupted config that recovers from a backup is still the same repository's
545
+ // file, and "corrupt it once, get the setting honored" would be a bypass of the
546
+ // one key that is refused.
547
+ const applyProjectConfig = (parsed: Partial<MiphamConfig>): void => {
548
+ config = mergeConfig(config, stripProjectPermission(parsed, configPath), false)
549
+ }
550
+
451
551
  // ── Load project-level config ──
452
552
  const projectConfig = safeParseYaml(configPath, 'project config')
453
553
  if (projectConfig) {
454
- config = mergeConfig(config, projectConfig, false)
455
- } else if (existsSync(configPath)) {
456
- // File exists but failed to parse — try to restore from backup
554
+ applyProjectConfig(projectConfig)
555
+ } else if (isRegularFile(configPath)) {
556
+ // A real file is there but failed to parse — try to restore from backup.
557
+ // 非普通文件走不到这里:那不是「损坏的配置」,而是根本不该当配置读的东西
558
+ // (FIFO/socket/目录),「恢复」对它会变成往 FIFO 里写、又一次挂死。
457
559
  process.stderr.write(`⚠ Mipham Code: project config is corrupted, attempting recovery...\n`)
458
560
  if (!tryRestoreFromBackup(configPath)) {
459
561
  process.stderr.write(`⚠ Mipham Code: no backup available for project config. Skipping.\n`)
@@ -461,7 +563,7 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
461
563
  // Retry parsing after restore
462
564
  const restored = safeParseYaml(configPath, 'restored project config')
463
565
  if (restored) {
464
- config = mergeConfig(config, restored, false)
566
+ applyProjectConfig(restored)
465
567
  }
466
568
  }
467
569
  }
@@ -470,8 +572,9 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
470
572
  const userConfig = safeParseYaml(userConfigPath, 'user config')
471
573
  if (userConfig) {
472
574
  config = mergeConfig(config, userConfig, true)
473
- } else if (existsSync(userConfigPath)) {
474
- // File exists but failed to parse — try to restore from backup
575
+ } else if (isRegularFile(userConfigPath)) {
576
+ // 用户的 config.yml 是真文件但解析不了 —— 才谈得上「恢复」。判据同上:
577
+ // 非普通文件不是损坏的配置。
475
578
  process.stderr.write(`⚠ Mipham Code: user config is corrupted, attempting recovery...\n`)
476
579
  if (!tryRestoreFromBackup(userConfigPath)) {
477
580
  process.stderr.write(`⚠ Mipham Code: no backup available for user config. Skipping.\n`)
@@ -545,8 +648,8 @@ export function loadInferenceHookConfig(): InferenceHookConfig {
545
648
  const paths = [userConfigPath]
546
649
  for (const path of paths) {
547
650
  try {
548
- if (!existsSync(path)) continue
549
- const raw = readFileSync(path, 'utf-8')
651
+ const raw = readRegularFileSync(path)
652
+ if (raw === null) continue
550
653
  const parsed = parseYaml(raw) as Record<string, unknown>
551
654
  const section = parsed.inference_hooks as Partial<InferenceHookConfig> | undefined
552
655
  if (section) {
@@ -587,8 +690,8 @@ function mergeCredentialMaskingFile(
587
690
  allowLoosening: boolean,
588
691
  ): CredentialMaskingConfig {
589
692
  try {
590
- if (!existsSync(path)) return merged
591
- const raw = readFileSync(path, 'utf-8')
693
+ const raw = readRegularFileSync(path)
694
+ if (raw === null) return merged
592
695
  const parsed = parseYaml(raw) as Record<string, unknown>
593
696
  const section = parsed.credential_masking as Partial<CredentialMaskingConfig> | undefined
594
697
  if (!section) return merged
@@ -664,8 +767,8 @@ export function loadBackgroundAgentConfig(cwd: string = process.cwd()): Backgrou
664
767
  const paths = [userConfigPath, configPath]
665
768
  for (const path of paths) {
666
769
  try {
667
- if (!existsSync(path)) continue
668
- const raw = readFileSync(path, 'utf-8')
770
+ const raw = readRegularFileSync(path)
771
+ if (raw === null) continue
669
772
  const parsed = parseYaml(raw) as Record<string, unknown>
670
773
  const section = parsed.background_agent as Partial<BackgroundAgentConfig> | undefined
671
774
  if (section) {
@@ -697,8 +800,8 @@ export function loadCrossSessionConfig(cwd: string = process.cwd()): CrossSessio
697
800
  const paths = [userConfigPath, configPath] // project wins (loaded last)
698
801
  for (const path of paths) {
699
802
  try {
700
- if (!existsSync(path)) continue
701
- const raw = readFileSync(path, 'utf-8')
803
+ const raw = readRegularFileSync(path)
804
+ if (raw === null) continue
702
805
  const parsed = parseYaml(raw) as Record<string, unknown>
703
806
  const section = parsed.cross_session as Partial<CrossSessionConfig> | undefined
704
807
  if (section) {
@@ -753,11 +856,11 @@ function decryptProviderApiKeys(providers: ProviderConfig[] | undefined): void {
753
856
  export function getProviderApiKey(providerId: string, cwd: string = process.cwd()): string | null {
754
857
  const userConfigPath = join(MIPHAM_HOME, 'config.yml')
755
858
  const projectConfigPath = join(cwd, MIPHAM_DIR, 'config.yml')
756
- const configPath = existsSync(userConfigPath) ? userConfigPath : projectConfigPath
859
+ const configPath = isRegularFile(userConfigPath) ? userConfigPath : projectConfigPath
757
860
 
758
861
  try {
759
- if (!existsSync(configPath)) return null
760
- const raw = readFileSync(configPath, 'utf-8')
862
+ const raw = readRegularFileSync(configPath)
863
+ if (raw === null) return null
761
864
  const doc = (parseYaml(raw) as Record<string, unknown>) || {}
762
865
  const providers = (doc.providers as Array<Record<string, unknown>>) || []
763
866
  const p = providers.find((x) => x.id === providerId)
@@ -788,9 +891,9 @@ export function saveProviderApiKey(providerId: string, apiKey: string): boolean
788
891
 
789
892
  // Read existing config (or start fresh)
790
893
  let doc: Record<string, unknown> = {}
791
- if (existsSync(configPath)) {
792
- const raw = readFileSync(configPath, 'utf-8')
793
- doc = (parseYaml(raw) as Record<string, unknown>) || {}
894
+ const existing = readRegularFileSync(configPath)
895
+ if (existing !== null) {
896
+ doc = (parseYaml(existing) as Record<string, unknown>) || {}
794
897
  }
795
898
 
796
899
  // Find and update the provider in the providers array
@@ -5,16 +5,19 @@
5
5
  * NOT for config.yml settings — those belong in the YAML config system.
6
6
  * NOT for secrets — this file is plain JSON, not encrypted.
7
7
  */
8
- import { readFileSync, existsSync, mkdirSync } from 'node:fs'
8
+ import { existsSync, mkdirSync } from 'node:fs'
9
9
  import { atomicWriteFileSync } from '../shared/atomic-write'
10
+ import { readRegularFileSync } from '../shared/regular-file'
10
11
  import { miphamHome } from '../core/paths.ts'
11
12
 
12
13
  const PREFS_PATH = miphamHome('preferences.json')
13
14
 
14
15
  function readPrefs(): Record<string, string> {
15
16
  try {
16
- if (!existsSync(PREFS_PATH)) return {}
17
- const raw = readFileSync(PREFS_PATH, 'utf-8')
17
+ // 类型闸在读取之前 —— FIFO 上的 `readFileSync` 会一直等写者(见
18
+ // shared/regular-file.ts)。
19
+ const raw = readRegularFileSync(PREFS_PATH)
20
+ if (raw === null) return {}
18
21
  const parsed: unknown = JSON.parse(raw)
19
22
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}
20
23
  return parsed as Record<string, string>
@@ -13,7 +13,8 @@
13
13
  * Format: YAML with schema validation
14
14
  */
15
15
 
16
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
16
+ import { readFileSync, mkdirSync, existsSync } from 'node:fs'
17
+ import { atomicWriteFileSync } from '../shared/atomic-write'
17
18
  import { miphamHome } from './paths.ts'
18
19
  import alignmentVocabulary from './alignment-vocabulary.json' with { type: 'json' }
19
20
 
@@ -103,7 +104,7 @@ export class ConstitutionLoader {
103
104
  try {
104
105
  const dir = miphamHome()
105
106
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
106
- writeFileSync(this.path, this.serializeToYaml(DEFAULT_CONSTITUTION), 'utf-8')
107
+ atomicWriteFileSync(this.path, this.serializeToYaml(DEFAULT_CONSTITUTION), { mode: 0o644 })
107
108
  } catch {
108
109
  // Best-effort — default constitution works in-memory
109
110
  }
@@ -37,6 +37,14 @@ interface Checkpoint {
37
37
  export class ContextManager {
38
38
  private messages: Message[] = []
39
39
  private systemPrompt = ''
40
+ /**
41
+ * 系统提示里的**权限段**是读时派生的,不是组装时烘进 `systemPrompt` 的一份拷贝。
42
+ *
43
+ * 烘进去的那份拷贝会与执行分叉:Shift+Tab 之后闸门与页脚都变了,模型手里还是旧指令
44
+ * —— 往窄切是自纠正的(模型比闸门更保守),**往宽切**则让模型拒绝做它已经被允许做的事。
45
+ * `index.tsx` 把它接到 live `PermissionSystem.getMode()` 上,于是切一次档,下一次请求就变。
46
+ */
47
+ private permissionContextSource: (() => string) | null = null
40
48
  private estimatedTokens = 0
41
49
  private checkpoints: Checkpoint[] = []
42
50
  private checkpointCounter = 0
@@ -116,11 +124,32 @@ export class ContextManager {
116
124
 
117
125
  setSystemPrompt(prompt: string): void {
118
126
  this.systemPrompt = prompt
119
- this.estimatedTokens = this.estimateTokens(prompt)
127
+ this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
128
+ }
129
+
130
+ /**
131
+ * 接线点(`index.tsx`):把权限段接到 live `PermissionSystem.getMode()` 上。
132
+ *
133
+ * 传 `null` 撤销。**不接**时系统提示里没有权限段(这里是唯一的施加点,故
134
+ * `test/integrity/permission-status-parity.test.ts` 从源码侧断这一行在场)。
135
+ */
136
+ setPermissionContextSource(fn: (() => string) | null): void {
137
+ this.permissionContextSource = fn
138
+ }
139
+
140
+ /**
141
+ * 存储的提示 + 读时派生的权限段。
142
+ *
143
+ * 段尾追加(而非插回原来的中段位置)是刻意的:只切档时**前缀保持不变**,
144
+ * 提供方的 prefix cache 仍能命中到权限段之前的部分。
145
+ */
146
+ private composedSystemPrompt(): string {
147
+ const block = this.permissionContextSource?.() ?? ''
148
+ return block ? `${this.systemPrompt}\n\n---\n\n${block}` : this.systemPrompt
120
149
  }
121
150
 
122
151
  getSystemPrompt(): string {
123
- return this.systemPrompt
152
+ return this.composedSystemPrompt()
124
153
  }
125
154
 
126
155
  addMessage(msg: Message): void {
@@ -239,7 +268,7 @@ export class ContextManager {
239
268
  }
240
269
 
241
270
  // Re-estimate tokens
242
- this.estimatedTokens = this.estimateTokens(this.systemPrompt)
271
+ this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
243
272
  for (const msg of this.messages) {
244
273
  this.estimatedTokens += this.estimateTokens(
245
274
  typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
@@ -257,7 +286,7 @@ export class ContextManager {
257
286
  this.messages = []
258
287
  this.checkpoints = []
259
288
  this.checkpointCounter = 0
260
- this.estimatedTokens = this.estimateTokens(this.systemPrompt)
289
+ this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
261
290
  }
262
291
 
263
292
  getMessageCount(): number {
@@ -273,7 +302,7 @@ export class ContextManager {
273
302
  replaceMessages(messages: Message[]): void {
274
303
  this.messages = messages
275
304
  // Re-estimate tokens
276
- this.estimatedTokens = this.estimateTokens(this.systemPrompt)
305
+ this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
277
306
  for (const msg of messages) {
278
307
  this.estimatedTokens += this.estimateTokens(
279
308
  typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
@@ -418,7 +447,7 @@ export class ContextManager {
418
447
 
419
448
  /** Re-estimate tokens from system prompt + current messages. */
420
449
  private reEstimateTokens(): void {
421
- this.estimatedTokens = this.estimateTokens(this.systemPrompt)
450
+ this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
422
451
  for (const msg of this.messages) {
423
452
  this.estimatedTokens += this.estimateTokens(
424
453
  typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
@@ -13,8 +13,9 @@
13
13
  import type { CrsiInsight } from './auto-memory'
14
14
  import type { MetaRule } from './meta-rule-engine'
15
15
  import type { Llm } from '../providers/llm'
16
- import { readdirSync, appendFileSync, readFileSync, existsSync, mkdirSync, rmSync } from 'node:fs'
16
+ import { readdirSync, readFileSync, existsSync, mkdirSync, rmSync } from 'node:fs'
17
17
  import { join } from 'node:path'
18
+ import { appendRegularFileSync } from '../shared/regular-file'
18
19
  import { miphamHome } from './paths.ts'
19
20
 
20
21
  /** 教训文件(相对仓库根)。预建,沙箱只能改已存在文件。 */
@@ -538,7 +539,8 @@ export function hasProposedProse(id: string): boolean {
538
539
  export function appendProseProposal(record: ProseProposalRecord): void {
539
540
  try {
540
541
  mkdirSync(miphamHome('crsi'), { recursive: true })
541
- appendFileSync(proseLedgerFile(), JSON.stringify(record) + '\n', 'utf-8')
542
+ // FIFO 路径上「写不进去」与下面的 catch 同级:ledger 非关键,但**不挂**。
543
+ appendRegularFileSync(proseLedgerFile(), JSON.stringify(record) + '\n')
542
544
  } catch {
543
545
  // ledger 非关键,失败不影响提议本身
544
546
  }
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { execSync } from 'node:child_process'
18
18
  import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
19
+ import { atomicWriteFileSync } from '../shared/atomic-write'
19
20
  import { join, resolve, sep, posix } from 'node:path'
20
21
  import { tmpdir } from 'node:os'
21
22
  import { randomUUID } from 'node:crypto'
@@ -642,7 +643,7 @@ export class CrsiSandbox {
642
643
  // Persist to ~/.mipham/crsi-sandbox/
643
644
  const reportPath = join(REPORT_DIR, `${this.sessionReport.sessionId}.json`)
644
645
  try {
645
- writeFileSync(reportPath, JSON.stringify(this.sessionReport, null, 2), 'utf-8')
646
+ atomicWriteFileSync(reportPath, JSON.stringify(this.sessionReport, null, 2), { mode: 0o644 })
646
647
  } catch {
647
648
  // Best-effort persistence
648
649
  }
@@ -25,15 +25,8 @@
25
25
  * → Write consolidated memory + generate DreamReport
26
26
  */
27
27
 
28
- import {
29
- readdirSync,
30
- readFileSync,
31
- writeFileSync,
32
- unlinkSync,
33
- existsSync,
34
- mkdirSync,
35
- statSync,
36
- } from 'node:fs'
28
+ import { readdirSync, readFileSync, unlinkSync, existsSync, mkdirSync, statSync } from 'node:fs'
29
+ import { atomicWriteFileSync } from '../shared/atomic-write'
37
30
  import { join } from 'node:path'
38
31
  import { miphamHome } from './paths.ts'
39
32
 
@@ -320,7 +313,8 @@ export class DreamEngine {
320
313
  try {
321
314
  const links = secondary.map((s) => `- See also: [[${s.name}]]`).join('\n')
322
315
  const updated = primary.content + `\n\n## Related\n${links}`
323
- writeFileSync(primary.path, updated, 'utf-8')
316
+ // 记忆正文:与 memory-manager 同权限语义(用户可读可编辑的内容)。
317
+ atomicWriteFileSync(primary.path, updated, { mode: 0o644 })
324
318
 
325
319
  for (const s of secondary) {
326
320
  try {
@@ -532,7 +526,7 @@ export class DreamEngine {
532
526
  log.unshift({ timestamp, actions })
533
527
  // Keep last 10 dream cycles
534
528
  if (log.length > 10) log = log.slice(0, 10)
535
- writeFileSync(this.dreamLog, JSON.stringify(log, null, 2), 'utf-8')
529
+ atomicWriteFileSync(this.dreamLog, JSON.stringify(log, null, 2), { mode: 0o644 })
536
530
  } catch {
537
531
  // Best-effort persistence
538
532
  }
@@ -488,10 +488,15 @@ export class QueryEngine {
488
488
  try {
489
489
  for await (const chunk of this.llmChat({
490
490
  model: this.registry.getActiveModel(),
491
- messages: [
492
- { role: 'system', content: summaryPrompt },
493
- { role: 'user', content: text },
494
- ],
491
+ // The instruction goes in `systemPrompt`, **not** as a `system` message
492
+ // in the array. A system *entry* is a header, and the providers do not
493
+ // agree on one that arrives inside `messages`: anthropic drops every
494
+ // system entry (its protocol wants the top-level param, anthropic.ts),
495
+ // so the array form reached the OpenAI-compatible providers and no one
496
+ // else — the summarizer's instruction was missing for anthropic users
497
+ // while it worked here. `systemPrompt` is the one spelling both read.
498
+ systemPrompt: summaryPrompt,
499
+ messages: [{ role: 'user', content: text }],
495
500
  maxTokens: 2000,
496
501
  })) {
497
502
  if (chunk.type === 'text' && chunk.content) {
@@ -14,7 +14,8 @@
14
14
  * PreFlightChecker.check() → ErrorSignatureDB.match()
15
15
  */
16
16
 
17
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
17
+ import { mkdirSync, readFileSync, existsSync } from 'node:fs'
18
+ import { atomicWriteFileSync } from '../shared/atomic-write'
18
19
  import { join } from 'node:path'
19
20
  import { randomUUID } from 'node:crypto'
20
21
  import { miphamHome } from './paths.ts'
@@ -101,7 +102,7 @@ export class ErrorSignatureDB {
101
102
  try {
102
103
  mkdirSync(join(this.storePath, '..'), { recursive: true })
103
104
  const arr = Array.from(this.signatures.values())
104
- writeFileSync(this.storePath, JSON.stringify(arr, null, 2), 'utf-8')
105
+ atomicWriteFileSync(this.storePath, JSON.stringify(arr, null, 2), { mode: 0o644 })
105
106
  this.dirty = false
106
107
  } catch {
107
108
  // Best-effort persistence — never crash on write failure
@@ -13,7 +13,8 @@
13
13
 
14
14
  import { join } from 'node:path'
15
15
  import { tmpdir } from 'node:os'
16
- import { mkdirSync, appendFileSync, readFileSync, existsSync } from 'node:fs'
16
+ import { mkdirSync, readFileSync, existsSync } from 'node:fs'
17
+ import { appendRegularFileSync } from '../shared/regular-file'
17
18
  import { ExperienceRuleEngine } from './rule-engine'
18
19
  import { ConstitutionLoader, DEFAULT_CONSTITUTION } from './constitution-loader'
19
20
  import { ErrorSignatureDB } from './error-signature-db'
@@ -119,7 +120,7 @@ export function appendEvalScore(
119
120
  ): void {
120
121
  try {
121
122
  mkdirSync(miphamHome('crsi'), { recursive: true })
122
- appendFileSync(
123
+ appendRegularFileSync(
123
124
  SCORES_FILE,
124
125
  JSON.stringify({
125
126
  name,
@@ -131,7 +132,6 @@ export function appendEvalScore(
131
132
  // 读取侧跳过 —— 这是向后兼容的承重判据,别改成 `results: []`。
132
133
  ...(report.results ? { results: report.results.map(toContractResultRecord) } : {}),
133
134
  }) + '\n',
134
- 'utf-8',
135
135
  )
136
136
  } catch {
137
137
  // rewards 日志是非关键的——失败不影响评估本身