@miphamai/cli 0.85.1 → 0.85.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) 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/tools/agent/memory.ts +4 -2
  42. package/src/tools/agent/workflow.ts +6 -3
  43. package/src/tools/exec/task.ts +82 -30
  44. package/src/tools/scheduling/cron.ts +3 -9
  45. package/src/ui/app.tsx +48 -13
  46. package/src/ui/commands.ts +5 -2
  47. package/src/ui/ctrl-c-confirm.ts +63 -0
  48. package/src/workflow/journal.ts +80 -26
@@ -1,4 +1,5 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
+ import type { BackgroundAgentRegistry, BackgroundTask } from '../../agent/background-registry'
2
3
  import { hasSupportedEvidenceSince } from '../../core/working-memory'
3
4
 
4
5
  export interface Task {
@@ -14,9 +15,7 @@ export interface Task {
14
15
  createdAt: string
15
16
  /** 完成证据:completed 时是否有 supported 决策接地(软门,unverified 只标记不阻断)。 */
16
17
  completionEvidence?: 'supported' | 'unverified'
17
- /** Background task output content (set when a background agent completes). */
18
- output?: string
19
- /** Background task error message (set when a background agent fails). */
18
+ /** Set by the `stop` action on a running task ("Task stopped by user."). */
20
19
  outputError?: string
21
20
  }
22
21
 
@@ -36,6 +35,66 @@ function formatTask(t: Task): string {
36
35
  return `[${t.status}] #${t.id}: ${t.subject}${owner}${blocks}${blocked}${meta}`
37
36
  }
38
37
 
38
+ /**
39
+ * Render a **background agent** the way the `output` action renders a session task.
40
+ *
41
+ * Background agents are not in the local `tasks` map: BackgroundAgentRegistry mints
42
+ * their ids in a separate space (`bg-<n>-<base36>`) and keeps results on the registry
43
+ * itself. The Agent tool hands those ids to the model with an explicit
44
+ * `Use Task output taskId="bg-…"` line, so this action has to answer for them —
45
+ * without this path the advertised call can only return `Task #bg-… not found`.
46
+ */
47
+ function formatBackgroundOutput(bg: BackgroundTask): {
48
+ success: boolean
49
+ content: string
50
+ error?: string
51
+ } {
52
+ const head = `Background task ${bg.id}`
53
+ const meta = `Status: ${bg.status}\nAgent type: ${bg.agentType}\nTask: ${bg.description}`
54
+
55
+ if (bg.status === 'running') {
56
+ return {
57
+ success: true,
58
+ content: `${head} is still running — output not yet available.\n${meta}\n\nUse Task output again once it completes.`,
59
+ }
60
+ }
61
+
62
+ if (bg.status === 'failed') {
63
+ return {
64
+ success: false,
65
+ content: '',
66
+ error: `${head} failed: ${bg.error ?? '(no error recorded)'}`,
67
+ }
68
+ }
69
+
70
+ return {
71
+ success: true,
72
+ content: `── ${head} Output ──\n${meta}\n\n${(bg.result ?? '(no output recorded)').slice(0, 5000)}`,
73
+ }
74
+ }
75
+
76
+ /** Stop a running background agent through the registry that owns it. */
77
+ function stopBackgroundTask(
78
+ bg: BackgroundTask,
79
+ registry: BackgroundAgentRegistry,
80
+ ): { success: boolean; content: string; error?: string } {
81
+ if (bg.status !== 'running') {
82
+ return {
83
+ success: true,
84
+ content: `Background task ${bg.id} is already ${bg.status}. Nothing to stop.`,
85
+ }
86
+ }
87
+ // `stop()` also returns false when the task is no longer running (it completed
88
+ // between the lookup above and this call), so the message must not claim why.
89
+ const aborted = registry.stop(bg.id)
90
+ return {
91
+ success: true,
92
+ content: aborted
93
+ ? `Background task ${bg.id} stopped.\nTask: ${bg.description}`
94
+ : `Background task ${bg.id} was not stopped — it is no longer running.`,
95
+ }
96
+ }
97
+
39
98
  /** Check if a task is blocked — has unresolved dependencies. */
40
99
  function isBlocked(task: Task): boolean {
41
100
  if (task.blockedBy.length === 0) return false
@@ -238,11 +297,6 @@ export const taskTool: ToolDefinition = {
238
297
  }
239
298
  }
240
299
  if (task.metadata) lines.push(`Metadata: ${JSON.stringify(task.metadata)}`)
241
- if (task.output) {
242
- lines.push('')
243
- lines.push('── Output ──')
244
- lines.push(task.output.slice(0, 2000))
245
- }
246
300
  if (task.outputError) {
247
301
  lines.push('')
248
302
  lines.push(`── Error ──`)
@@ -320,7 +374,14 @@ export const taskTool: ToolDefinition = {
320
374
  if (action === 'output') {
321
375
  const taskId = params.taskId as string
322
376
  const task = tasks.get(taskId)
323
- if (!task) return { success: false, content: '', error: `Task #${taskId} not found` }
377
+ if (!task) {
378
+ // Not a session task — it may be a background agent id (`bg-…`), which is
379
+ // the id space this action is advertised for. The registry is wired onto
380
+ // the tool context by the engine (`engine.ts` defaultToolContext).
381
+ const bg = _ctx.backgroundAgentRegistry?.get(taskId)
382
+ if (bg) return formatBackgroundOutput(bg)
383
+ return { success: false, content: '', error: `Task #${taskId} not found` }
384
+ }
324
385
 
325
386
  if (task.status === 'pending') {
326
387
  return {
@@ -336,13 +397,6 @@ export const taskTool: ToolDefinition = {
336
397
  }
337
398
  }
338
399
 
339
- if (task.status === 'completed' && task.output) {
340
- return {
341
- success: true,
342
- content: `── Task #${taskId} Output ──\nStatus: completed\nSubject: ${task.subject}\n\n${task.output.slice(0, 5000)}`,
343
- }
344
- }
345
-
346
400
  if (task.status === 'failed' && task.outputError) {
347
401
  return {
348
402
  success: false,
@@ -361,7 +415,15 @@ export const taskTool: ToolDefinition = {
361
415
  if (action === 'stop') {
362
416
  const taskId = params.taskId as string
363
417
  const task = tasks.get(taskId)
364
- if (!task) return { success: false, content: '', error: `Task #${taskId} not found` }
418
+ if (!task) {
419
+ // Background agent id (`bg-…`) — the other id space. The local branch
420
+ // below only marks a status; an actual running agent is aborted through
421
+ // the registry that owns it.
422
+ const registry = _ctx.backgroundAgentRegistry
423
+ const bg = registry?.get(taskId)
424
+ if (registry && bg) return stopBackgroundTask(bg, registry)
425
+ return { success: false, content: '', error: `Task #${taskId} not found` }
426
+ }
365
427
 
366
428
  if (task.status === 'completed' || task.status === 'deleted') {
367
429
  return {
@@ -376,22 +438,12 @@ export const taskTool: ToolDefinition = {
376
438
  return { success: true, content: `Task #${taskId} cancelled (was pending).` }
377
439
  }
378
440
 
379
- // For in_progress tasks: mark as failed — actual process termination is handled
380
- // by BackgroundAgentRegistry.stop() when wired via the engine context
441
+ // For in_progress session tasks: mark as failed. There is no process behind
442
+ // one — a *background agent* is aborted by stopBackgroundTask() above, which
443
+ // the registry owns.
381
444
  task.status = 'failed'
382
445
  task.outputError = 'Task stopped by user.'
383
446
 
384
- // If BackgroundAgentRegistry is available in context, try to abort
385
- if (_ctx.registry) {
386
- // Signal to any background registry that this task should stop
387
- // The background registry will be injected via the engine context
388
- const bgRegistry = (_ctx as unknown as Record<string, unknown>).backgroundAgentRegistry as
389
- { stop(id: string): boolean } | undefined
390
- if (bgRegistry) {
391
- bgRegistry.stop(taskId)
392
- }
393
- }
394
-
395
447
  return { success: true, content: `Task #${taskId} stopped.` }
396
448
  }
397
449
 
@@ -1,11 +1,5 @@
1
- import {
2
- mkdirSync,
3
- readFileSync,
4
- writeFileSync,
5
- unlinkSync,
6
- readdirSync,
7
- existsSync,
8
- } from 'node:fs'
1
+ import { mkdirSync, readFileSync, unlinkSync, readdirSync, existsSync } from 'node:fs'
2
+ import { atomicWriteFileSync } from '../../shared/atomic-write'
9
3
  import { join } from 'node:path'
10
4
  import { createHash } from 'node:crypto'
11
5
  import type { ToolDefinition } from '../../shared/index.ts'
@@ -78,7 +72,7 @@ export function readAllJobs(): CronJob[] {
78
72
  /** Persist a job (create or update) to its JSON file. */
79
73
  export function writeJob(job: CronJob): void {
80
74
  ensureCronDir()
81
- writeFileSync(jobPath(job.id), JSON.stringify(job, null, 2), 'utf-8')
75
+ atomicWriteFileSync(jobPath(job.id), JSON.stringify(job, null, 2), { mode: 0o644 })
82
76
  }
83
77
 
84
78
  /** Delete a job's file. Returns false when the job did not exist. */
package/src/ui/app.tsx CHANGED
@@ -3,6 +3,7 @@ import { Box, Text, useInput } from 'ink'
3
3
  import TextInput from 'ink-text-input'
4
4
  import { execSync } from 'node:child_process'
5
5
  import { ErrorBoundary } from './error-boundary'
6
+ import { useCtrlCConfirm } from './ctrl-c-confirm'
6
7
  import { formatThinking } from './thinking'
7
8
  import type { QueryEngine } from '../core/engine'
8
9
  import type { RemoteEngine } from '../daemon/remote-engine'
@@ -1136,22 +1137,55 @@ export function App({
1136
1137
  [engine, mkCtx, runTurn],
1137
1138
  )
1138
1139
 
1139
- useInput((_input, key) => {
1140
- // Escape: close apiKeyPrompt → close picker → abort loading (does NOT exit app)
1141
- if (key.escape) {
1142
- if (apiKeyPrompt) {
1143
- setApiKeyPrompt(null)
1144
- setApiKeyInput('')
1145
- return
1146
- }
1147
- if (pickerOpen) {
1148
- setPickerOpen(false)
1140
+ /**
1141
+ * 关掉「最上面那一层」—— 密钥提示框 / 选择器 / 正在跑的回合。关掉了返回 true。
1142
+ *
1143
+ * Esc 与 Ctrl+C 共用这一份:在用户心里这两个键是同一件事(「停」),分成两套
1144
+ * 判断迟早会漂移。没有可关的东西 ⇒ false,由调用方决定下一步。
1145
+ */
1146
+ const dismissTopmost = (): boolean => {
1147
+ if (apiKeyPrompt) {
1148
+ setApiKeyPrompt(null)
1149
+ setApiKeyInput('')
1150
+ return true
1151
+ }
1152
+ if (pickerOpen) {
1153
+ setPickerOpen(false)
1154
+ return true
1155
+ }
1156
+ if (isLoading && abortRef.current) {
1157
+ abortRef.current.abort()
1158
+ return true
1159
+ }
1160
+ return false
1161
+ }
1162
+
1163
+ // Ctrl+C 的「再按一次才退」,语义与计时器见 ui/ctrl-c-confirm.ts
1164
+ const ctrlC = useCtrlCConfirm()
1165
+
1166
+ useInput((input, key) => {
1167
+ // Ctrl+C:先关最上面那一层;没得关时**不退出**,只提示「再按一次」。
1168
+ //
1169
+ // 从前这里没有这个分支,而 Ink 自己的 Ctrl+C 处理(`exitOnCtrlC`,默认
1170
+ // true)在按键**到达任何 handler 之前**就把进程退了 —— 于是对话框里一次
1171
+ // 误按就把整个会话带走。`render()` 三处均已传 `exitOnCtrlC: false`,
1172
+ // 否则这个分支永远不会被调用(见 src/index.tsx 的注释)。
1173
+ if (key.ctrl && input === 'c') {
1174
+ if (dismissTopmost()) {
1175
+ // 这一按被「关掉最上面一层」消费掉了 —— 用户想的是「停」,不是「退」,
1176
+ // 所以待确认窗口要一并撤掉,免得下一次误按直接退出。
1177
+ ctrlC.reset()
1149
1178
  return
1150
1179
  }
1151
- if (isLoading && abortRef.current) {
1152
- abortRef.current.abort()
1153
- return
1180
+ if (ctrlC.isArmed()) {
1181
+ process.exit(0)
1154
1182
  }
1183
+ ctrlC.arm()
1184
+ return
1185
+ }
1186
+ // Escape: close apiKeyPrompt → close picker → abort loading (does NOT exit app)
1187
+ if (key.escape) {
1188
+ if (dismissTopmost()) return
1155
1189
  // Otherwise let InputBar handle Escape (clear draft)
1156
1190
  return
1157
1191
  }
@@ -1372,6 +1406,7 @@ export function App({
1372
1406
  {' · '}
1373
1407
  {t('ui.status.left_for_agents')}
1374
1408
  </Text>
1409
+ {ctrlC.armed && <Text color="yellow"> {t('ui.status.ctrl_c_again')}</Text>}
1375
1410
  </Box>
1376
1411
  </Box>
1377
1412
 
@@ -2504,7 +2504,8 @@ const marketplaceCmd: CommandHandler = async (_ctx, args) => {
2504
2504
  isValidMarketplaceRef,
2505
2505
  MARKETPLACES_PATH,
2506
2506
  } = await import('../skills/marketplace')
2507
- const { readFileSync, writeFileSync, existsSync, mkdirSync } = await import('node:fs')
2507
+ const { readFileSync, existsSync, mkdirSync } = await import('node:fs')
2508
+ const { atomicWriteFileSync } = await import('../shared/atomic-write')
2508
2509
  const { dirname } = await import('node:path')
2509
2510
 
2510
2511
  const readSources = () =>
@@ -2537,7 +2538,9 @@ const marketplaceCmd: CommandHandler = async (_ctx, args) => {
2537
2538
  const changed = 'added' in result ? result.added : result.removed
2538
2539
  try {
2539
2540
  mkdirSync(dirname(MARKETPLACES_PATH), { recursive: true })
2540
- writeFileSync(MARKETPLACES_PATH, JSON.stringify(result.sources, null, 2), 'utf-8')
2541
+ atomicWriteFileSync(MARKETPLACES_PATH, JSON.stringify(result.sources, null, 2), {
2542
+ mode: 0o644,
2543
+ })
2541
2544
  } catch (err) {
2542
2545
  return { content: `❌ Failed to save marketplaces: ${String(err)}` }
2543
2546
  }
@@ -0,0 +1,63 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+
3
+ /**
4
+ * Ctrl+C 的「再按一次才退出」窗口。
5
+ *
6
+ * 为什么需要它:Ink 默认的 Ctrl+C 语义是**立刻退出**,而 TUI 里 Ctrl+C 的合
7
+ * 理语义是「停下手上这件事」—— 对话框开着、回合跑着的时候,误按一次不该把
8
+ * 整个会话带走。所以 `render()` 处一律传 `exitOnCtrlC: false`(见
9
+ * `src/index.tsx`),退出的决定权收回到这里:第一次按只「上膛」,窗口内再按
10
+ * 一次才真退。
11
+ *
12
+ * 两个入口(主界面 `ui/app.tsx`、看板 `agent-view/dashboard.tsx`)共用这一份,
13
+ * 而不是各写一遍 —— 同一条语义有两份实现,迟早会长成两个东西。
14
+ *
15
+ * 注意 `isArmed()` 与 `armed` 是**两条路**,不是冗余:
16
+ * - 判据要走 `isArmed()`(ref)—— 在同一个按键回调里 `arm()` 之后 state 还没
17
+ * 重渲染,用 state 读的话第二次按键看到的仍是 false,就永远退不出去;
18
+ * - `armed`(state)只给渲染用(页脚那句提示)。
19
+ */
20
+ export interface CtrlCConfirm {
21
+ /** 渲染用:当前是否处于「待确认」窗口内。 */
22
+ armed: boolean
23
+ /** 判据用:同步读数,同一次按键回调里 `arm()` 之后立刻为 true。 */
24
+ isArmed: () => boolean
25
+ /** 上膛:进入待确认窗口,`windowMs` 后自动撤。 */
26
+ arm: () => void
27
+ /** 撤膛:关掉窗口并清掉计时器(例如这次按键被别的事情消费掉了)。 */
28
+ reset: () => void
29
+ }
30
+
31
+ export function useCtrlCConfirm(windowMs = 2000): CtrlCConfirm {
32
+ const armedRef = useRef(false)
33
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
34
+ const [armed, setArmed] = useState(false)
35
+
36
+ const clear = useCallback(() => {
37
+ if (timerRef.current) clearTimeout(timerRef.current)
38
+ timerRef.current = null
39
+ }, [])
40
+
41
+ const reset = useCallback(() => {
42
+ clear()
43
+ armedRef.current = false
44
+ setArmed(false)
45
+ }, [clear])
46
+
47
+ const arm = useCallback(() => {
48
+ clear()
49
+ armedRef.current = true
50
+ setArmed(true)
51
+ timerRef.current = setTimeout(() => {
52
+ armedRef.current = false
53
+ setArmed(false)
54
+ }, windowMs)
55
+ }, [clear, windowMs])
56
+
57
+ // 卸载时撤掉计时器 —— 否则它到点后会往已经卸掉的组件里 setState。
58
+ useEffect(() => clear, [clear])
59
+
60
+ const isArmed = useCallback(() => armedRef.current, [])
61
+
62
+ return { armed, isArmed, arm, reset }
63
+ }
@@ -1,12 +1,7 @@
1
- import {
2
- mkdirSync,
3
- writeFileSync,
4
- readFileSync,
5
- existsSync,
6
- appendFileSync,
7
- readdirSync,
8
- } from 'node:fs'
1
+ import { mkdirSync, existsSync, readdirSync } from 'node:fs'
9
2
  import { join } from 'node:path'
3
+ import { atomicWriteFileSync } from '../shared/atomic-write'
4
+ import { appendRegularFileSync, readRegularFileSync } from '../shared/regular-file'
10
5
  import { miphamHome } from '../core/paths.ts'
11
6
 
12
7
  const WORKFLOW_DIR = miphamHome('workflows')
@@ -33,15 +28,23 @@ export function createJournal(runId: string, script: string): string {
33
28
  const dir = join(WORKFLOW_DIR, runId)
34
29
  mkdirSync(dir, { recursive: true })
35
30
 
36
- writeFileSync(join(dir, 'script.js'), script, 'utf-8')
37
- writeFileSync(join(dir, 'journal.jsonl'), '', 'utf-8')
38
- writeFileSync(join(dir, 'state.json'), JSON.stringify({ seq: 0, phases: [] }), 'utf-8')
31
+ // 三份都走助手:新建 run 目录里本来没有旧内容可丢,但**路径上可能被放了个 FIFO**
32
+ // (`writeFileSync` 会打开它写 ⇒ 干等到有读者为止)。`atomicWriteFileSync` 的 rename
33
+ // 与 `appendRegularFileSync` 的类型闸都不打开目标,于是在这里也一并关掉。
34
+ atomicWriteFileSync(join(dir, 'script.js'), script, { mode: 0o644 })
35
+ if (!appendRegularFileSync(join(dir, 'journal.jsonl'), '')) {
36
+ throw new Error(`workflow journal for run "${runId}" is not appendable`)
37
+ }
38
+ atomicWriteFileSync(join(dir, 'state.json'), JSON.stringify({ seq: 0, phases: [] }))
39
39
 
40
40
  return dir
41
41
  }
42
42
 
43
43
  /**
44
44
  * Append an agent call to the journal. Returns the new sequence number.
45
+ *
46
+ * 先 append journal(权威记录)、后写 state(它的影子)—— 顺序有意义:中途被打断时
47
+ * 唯一的后果是影子落后,而不是记录丢失。
45
48
  */
46
49
  export function appendJournal(runId: string, entry: Omit<JournalEntry, 'seq'>): number {
47
50
  const dir = join(WORKFLOW_DIR, runId)
@@ -50,32 +53,84 @@ export function appendJournal(runId: string, entry: Omit<JournalEntry, 'seq'>):
50
53
  const seq = state.seq + 1
51
54
  const fullEntry: JournalEntry = { seq, ...entry }
52
55
 
53
- appendFileSync(join(dir, 'journal.jsonl'), JSON.stringify(fullEntry) + '\n', 'utf-8')
56
+ // 权威记录写不进去 = 这个 run 没法记账(含路径上是个 FIFO):**不做影子**、当场说清,
57
+ // 好过把 state 推进到一个 journal 里不存在的 seq 上。
58
+ if (!appendRegularFileSync(join(dir, 'journal.jsonl'), JSON.stringify(fullEntry) + '\n')) {
59
+ throw new Error(`workflow journal for run "${runId}" is not appendable`)
60
+ }
54
61
 
55
62
  state.seq = seq
56
- writeFileSync(join(dir, 'state.json'), JSON.stringify(state), 'utf-8')
63
+ atomicWriteFileSync(join(dir, 'state.json'), JSON.stringify(state))
57
64
 
58
65
  return seq
59
66
  }
60
67
 
61
68
  /**
62
69
  * Load all journal entries for a run. Returns empty array if run not found.
70
+ *
71
+ * **逐行**解析:`appendFileSync` 被打断会在尾部留下半截行,而从前一个半截行让整份
72
+ * 读取抛 `SyntaxError`(`/workflow` 那条链上没有兜它)。坏行丢掉、其余全留 —— 一份
73
+ * 日志里少一行,好过整份读不出来。
63
74
  */
64
75
  export function loadJournal(runId: string): JournalEntry[] {
65
- const journalPath = join(WORKFLOW_DIR, runId, 'journal.jsonl')
66
- if (!existsSync(journalPath)) return []
67
-
68
- const raw = readFileSync(journalPath, 'utf-8')
69
- return raw
70
- .split('\n')
71
- .filter((line) => line.trim())
72
- .map((line) => JSON.parse(line) as JournalEntry)
76
+ const raw = readRegularFileSync(join(WORKFLOW_DIR, runId, 'journal.jsonl'))
77
+ if (raw === null) return []
78
+
79
+ const entries: JournalEntry[] = []
80
+ for (const line of raw.split('\n')) {
81
+ if (!line.trim()) continue
82
+ try {
83
+ const parsed: unknown = JSON.parse(line)
84
+ if (isJournalEntry(parsed)) entries.push(parsed)
85
+ } catch {
86
+ // 半截行(append 被打断)/坏行 —— 只丢这一行
87
+ }
88
+ }
89
+ return entries
73
90
  }
74
91
 
92
+ function isJournalEntry(e: unknown): e is JournalEntry {
93
+ if (!e || typeof e !== 'object' || Array.isArray(e)) return false
94
+ const ev = e as Record<string, unknown>
95
+ return (
96
+ typeof ev.seq === 'number' && (ev.type === 'agent' || ev.type === 'phase' || ev.type === 'log')
97
+ )
98
+ }
99
+
100
+ /**
101
+ * 读 state.json。
102
+ *
103
+ * 从前这里是**裸**的 `JSON.parse(readFileSync(...))`:state.json 是非原子写的,半截写
104
+ * 会让 `appendJournal` 当场抛 `SyntaxError`,整个 run 停摆 —— 而 state 只是**派生**信息。
105
+ * 所以坏掉/缺字段时把 `seq` 从 journal 尾行**重新推出来**,而不是归零:归零会让后续
106
+ * append 复用已经在用的序号,而那正是这份文件存在的意义。
107
+ */
75
108
  function readState(runId: string): JournalState {
76
- const statePath = join(WORKFLOW_DIR, runId, 'state.json')
77
- if (!existsSync(statePath)) return { seq: 0, phases: [] }
78
- return JSON.parse(readFileSync(statePath, 'utf-8'))
109
+ const raw = readRegularFileSync(join(WORKFLOW_DIR, runId, 'state.json'))
110
+ if (raw !== null) {
111
+ try {
112
+ const parsed: unknown = JSON.parse(raw)
113
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
114
+ const { seq, phases } = parsed as { seq?: unknown; phases?: unknown }
115
+ if (typeof seq === 'number' && Number.isFinite(seq)) {
116
+ return {
117
+ seq,
118
+ phases: Array.isArray(phases)
119
+ ? phases.filter((p): p is string => typeof p === 'string')
120
+ : [],
121
+ }
122
+ }
123
+ }
124
+ } catch {
125
+ // 半截写/坏形状 —— 落到下面按 journal 推。
126
+ }
127
+ }
128
+ return { seq: lastJournalSeq(runId), phases: [] }
129
+ }
130
+
131
+ /** journal 尾行的 seq —— 权威记录在 journal.jsonl,state.json 只是它的影子。 */
132
+ function lastJournalSeq(runId: string): number {
133
+ return loadJournal(runId).reduce((max, e) => (e.seq > max ? e.seq : max), 0)
79
134
  }
80
135
 
81
136
  /**
@@ -83,8 +138,7 @@ function readState(runId: string): JournalState {
83
138
  */
84
139
  export function loadScript(runId: string): string {
85
140
  const scriptPath = join(WORKFLOW_DIR, runId, 'script.js')
86
- if (!existsSync(scriptPath)) return ''
87
- return readFileSync(scriptPath, 'utf-8')
141
+ return readRegularFileSync(scriptPath) ?? ''
88
142
  }
89
143
 
90
144
  /**