@miphamai/cli 0.20.3 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.20.3",
3
+ "version": "0.22.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -0,0 +1,74 @@
1
+ import type { CommandHandler } from '../ui/commands.js'
2
+ import { listRuns, loadJournal } from '../workflow/journal.js'
3
+
4
+ export const workflowViewCmd: CommandHandler = async (_ctx, args) => {
5
+ const runId = args[0]
6
+
7
+ // /workflow list — show recent runs
8
+ if (!runId || runId === 'list') {
9
+ const runs = listRuns()
10
+ if (runs.length === 0) {
11
+ return {
12
+ content:
13
+ 'No workflow runs found.\n\nRuns are saved to ~/.mipham/workflows/ after each Workflow tool invocation.',
14
+ }
15
+ }
16
+
17
+ const lines: string[] = ['── Recent Workflow Runs ──', '']
18
+
19
+ // Show last 10, newest first
20
+ const recent = runs.sort().reverse().slice(0, 10)
21
+ for (const id of recent) {
22
+ const entries = loadJournal(id)
23
+ const agentCount = entries.filter((e) => e.type === 'agent').length
24
+ const phaseCount = entries.filter((e) => e.type === 'phase').length
25
+ const shortId = id.slice(0, 20)
26
+ lines.push(` ${shortId}... — ${agentCount} agents, ${phaseCount} phases`)
27
+ }
28
+
29
+ lines.push('', `Total runs: ${runs.length}`)
30
+ lines.push('', 'Use /workflow view <runId> to see details.')
31
+ return { content: lines.join('\n') }
32
+ }
33
+
34
+ // /workflow view <runId> — show run details
35
+ const entries = loadJournal(runId)
36
+ if (entries.length === 0) {
37
+ return { content: `Run "${runId}" not found.\n\nUse /workflow list to see available runs.` }
38
+ }
39
+
40
+ const lines: string[] = [`── Workflow: ${runId.slice(0, 30)} ──`, '']
41
+
42
+ let currentPhase = ''
43
+ for (const entry of entries) {
44
+ if (entry.type === 'phase') {
45
+ currentPhase = entry.message || ''
46
+ lines.push(` ▸ Phase: ${currentPhase}`)
47
+ } else if (entry.type === 'agent') {
48
+ const label = entry.opts?.label || entry.prompt?.slice(0, 50) || 'agent'
49
+ lines.push(` ● ${label}`)
50
+ } else if (entry.type === 'log') {
51
+ lines.push(` ℹ ${entry.message}`)
52
+ }
53
+ }
54
+
55
+ lines.push('', `Total entries: ${entries.length}`)
56
+ return { content: lines.join('\n') }
57
+ }
58
+
59
+ export const workflowWatchCmd: CommandHandler = async (_ctx, _args) => {
60
+ // /workflow watch — monitor the currently active workflow
61
+ // This is a hint: the actual rendering is handled by WorkflowProgress component
62
+ // which auto-detects active workflows. This command just confirms watch mode.
63
+ return {
64
+ content: [
65
+ '── Workflow Watch Mode ──',
66
+ '',
67
+ 'Workflow progress is displayed automatically when a workflow is running.',
68
+ 'No active workflow detected.',
69
+ '',
70
+ 'Use /workflow list to see past runs.',
71
+ 'Use /workflow view <id> to replay a completed run.',
72
+ ].join('\n'),
73
+ }
74
+ }
@@ -17,6 +17,10 @@ export const DEFAULT_CONFIG: MiphamConfig = {
17
17
  strictKnownMarketplaces: [],
18
18
  blockedMarketplaces: [],
19
19
  },
20
+ features: {
21
+ mcp: { oauthEnabled: true },
22
+ context: { useRealTokenizer: true, adaptiveThresholds: true },
23
+ },
20
24
  }
21
25
 
22
26
  export const DEFAULT_INFERENCE_HOOK_CONFIG: InferenceHookConfig = {
@@ -4,6 +4,7 @@ import { homedir } from 'node:os'
4
4
 
5
5
  const MIPHAM_DIR = join(homedir(), '.mipham')
6
6
  const KEYS_FILE = join(MIPHAM_DIR, 'keys.json')
7
+ const EXPIRY_DAYS = 90
7
8
 
8
9
  export interface KeyEntry {
9
10
  createdAt: string
@@ -62,7 +63,7 @@ export class KeyManager {
62
63
  lastRotated: entry.lastRotated,
63
64
  rotationCount: entry.rotationCount,
64
65
  ageDays: daysSince(entry.lastRotated),
65
- expired: daysSince(entry.lastRotated) > 90,
66
+ expired: daysSince(entry.lastRotated) > EXPIRY_DAYS,
66
67
  }))
67
68
  }
68
69
 
@@ -114,8 +115,23 @@ export class KeyManager {
114
115
  if (expired.length === 0) return null
115
116
 
116
117
  const lines = expired.map(
117
- (k) => ` - ${k.provider}: last rotated ${k.ageDays} days ago (90-day threshold exceeded)`,
118
+ (k) =>
119
+ ` - ${k.provider}: last rotated ${k.ageDays} days ago (${EXPIRY_DAYS}-day threshold exceeded)`,
118
120
  )
119
121
  return `⚠️ API key rotation overdue:\n${lines.join('\n')}\n\nRun /keys rotate <provider> to rotate.`
120
122
  }
123
+
124
+ ensureEntry(provider: string): void {
125
+ const data = loadKeys()
126
+ if (!data[provider]) {
127
+ const now = new Date().toISOString()
128
+ data[provider] = {
129
+ provider,
130
+ createdAt: now,
131
+ lastRotated: now,
132
+ rotationCount: 0,
133
+ }
134
+ saveKeys(data)
135
+ }
136
+ }
121
137
  }
@@ -12,24 +12,34 @@ import { snipMessages } from './context-snip'
12
12
  * 2. The most recent N messages (kept intact)
13
13
  * 3. The system prompt (unchanged)
14
14
  */
15
+ /**
16
+ * Adaptive keep-recent count: scales with context window size.
17
+ * 128K → 20, 200K → 20, 500K → 40, 1M → 80
18
+ */
19
+ function getKeepRecent(contextWindow: number): number {
20
+ return Math.max(20, Math.floor(contextWindow / 12500))
21
+ }
22
+
15
23
  export async function reactiveCompact(
16
24
  context: ContextManager,
17
25
  summarizer: Summarizer,
18
26
  heading: string,
19
- keepRecent: number = 20,
27
+ keepRecent?: number,
20
28
  ): Promise<void> {
29
+ // Use explicit override, or adaptive default based on context window
30
+ const effectiveKeepRecent = keepRecent ?? getKeepRecent(context.getMaxTokens())
21
31
  const messages = context.getMessages()
22
32
 
23
- if (messages.length <= keepRecent + 4) return
33
+ if (messages.length <= effectiveKeepRecent + 4) return
24
34
 
25
35
  // First, run snip to remove empty pairs
26
36
  const { messages: snipped } = snipMessages(messages)
27
37
 
28
- if (snipped.length <= keepRecent + 4) return
38
+ if (snipped.length <= effectiveKeepRecent + 4) return
29
39
 
30
40
  // Split: old messages to summarize, recent messages to keep
31
- const toSummarize = snipped.slice(0, -keepRecent)
32
- const toKeep = snipped.slice(-keepRecent)
41
+ const toSummarize = snipped.slice(0, -effectiveKeepRecent)
42
+ const toKeep = snipped.slice(-effectiveKeepRecent)
33
43
 
34
44
  // Generate summary
35
45
  let summary: string
@@ -55,11 +55,24 @@ export class ContextManager {
55
55
  private compressionPending = false
56
56
 
57
57
  constructor(private config: ContextConfig) {
58
- // Adaptive thresholds: larger context → later compaction
59
- // 200K → 0.90, 500K → 0.93, 1M → 0.95
60
- if (config.contextWindow && config.contextWindow > 200_000) {
61
- config.compactionThreshold = Math.max(0.9, 1 - 50000 / config.contextWindow)
62
- }
58
+ this.calculateThresholds()
59
+ }
60
+
61
+ /**
62
+ * Calculate adaptive compaction thresholds based on the model's context window.
63
+ *
64
+ * compaction: 200K→0.75(→clamped 0.90), 500K→0.90, 1M→0.95
65
+ * microcompact: 200K→0.70(→clamped 0.70), 500K→0.80, 1M→0.85
66
+ */
67
+ calculateThresholds(): void {
68
+ const w = this.config.contextWindow
69
+ if (!w) return
70
+
71
+ // compaction: window越大越晚compact,floor at 0.90
72
+ this.config.compactionThreshold = Math.max(0.9, 1 - 50000 / w)
73
+
74
+ // microcompact threshold is computed inline in checkCompression()
75
+ // using this.config.contextWindow directly
63
76
  }
64
77
 
65
78
  getCompactionThreshold(): number {
@@ -71,8 +84,8 @@ export class ContextManager {
71
84
  this.config.maxTokens = maxTokens
72
85
  if (contextWindow) {
73
86
  this.config.contextWindow = contextWindow
74
- this.config.compactionThreshold = Math.max(0.9, 1 - 50000 / contextWindow)
75
87
  }
88
+ this.calculateThresholds()
76
89
  }
77
90
 
78
91
  getMaxTokens(): number {
@@ -26,15 +26,22 @@ export interface MemoryEntry {
26
26
  }
27
27
 
28
28
  const INDEX_FILE = 'MEMORY.md'
29
+ const LINKS_FILE = 'links.json'
29
30
 
30
31
  export class MemoryManager {
31
32
  private memories = new Map<string, MemoryEntry>()
32
33
  private linkGraph: Map<string, Set<string>> = new Map()
34
+ private contextMaxTokens = 200_000
33
35
 
34
36
  constructor(private memoryDir: string) {
35
37
  mkdirSync(memoryDir, { recursive: true })
36
38
  }
37
39
 
40
+ /** Set the model's context window size for adaptive memory budget. */
41
+ setContextWindow(tokens: number): void {
42
+ this.contextMaxTokens = tokens
43
+ }
44
+
38
45
  loadAll(): void {
39
46
  this.memories.clear()
40
47
  this.linkGraph.clear()
@@ -48,7 +55,7 @@ export class MemoryManager {
48
55
  }
49
56
 
50
57
  for (const entry of entries) {
51
- if (entry === INDEX_FILE || extname(entry) !== '.md') continue
58
+ if (entry === INDEX_FILE || entry === LINKS_FILE || extname(entry) !== '.md') continue
52
59
  const filePath = join(this.memoryDir, entry)
53
60
  try {
54
61
  const raw = readFileSync(filePath, 'utf-8')
@@ -60,7 +67,11 @@ export class MemoryManager {
60
67
  // skip unparseable
61
68
  }
62
69
  }
63
- this.rebuildLinkGraph()
70
+
71
+ // Try cached link graph first, fall back to rebuild
72
+ if (!this.loadLinkGraph()) {
73
+ this.rebuildLinkGraph()
74
+ }
64
75
  }
65
76
 
66
77
  write(name: string, content: string, metadata: MemoryMetadata): void {
@@ -172,10 +183,15 @@ export class MemoryManager {
172
183
  // file may already be gone
173
184
  }
174
185
  this.memories.delete(name)
186
+ this.linkGraph.delete(name)
187
+ this.saveLinkGraph()
175
188
  this.updateIndex()
176
189
  }
177
190
 
178
- buildSystemReminder(context: string, maxTokens: number = 15000): string {
191
+ buildSystemReminder(context: string, maxTokens?: number): string {
192
+ // Adaptive budget: 5% of context window, min 5000, max 75000
193
+ const effectiveMaxTokens =
194
+ maxTokens ?? Math.max(5000, Math.min(75000, Math.floor(this.contextMaxTokens * 0.05)))
179
195
  const relevant = this.recall(context, 10)
180
196
  if (relevant.length === 0) return ''
181
197
 
@@ -185,7 +201,7 @@ export class MemoryManager {
185
201
  for (const entry of relevant) {
186
202
  const line = `- ${entry.name}: ${entry.content.slice(0, 200)}`
187
203
  const lineTokens = Math.ceil(line.length / 4)
188
- if (tokenBudget + lineTokens > maxTokens) break
204
+ if (tokenBudget + lineTokens > effectiveMaxTokens) break
189
205
  lines.push(line)
190
206
  tokenBudget += lineTokens
191
207
  }
@@ -318,6 +334,8 @@ export class MemoryManager {
318
334
  this.linkGraph.set(otherName, otherSet)
319
335
  }
320
336
  }
337
+
338
+ this.saveLinkGraph()
321
339
  }
322
340
 
323
341
  private rebuildLinkGraph(): void {
@@ -343,6 +361,33 @@ export class MemoryManager {
343
361
  }
344
362
  }
345
363
 
364
+ private saveLinkGraph(): void {
365
+ const obj: Record<string, string[]> = {}
366
+ for (const [k, v] of this.linkGraph) {
367
+ obj[k] = Array.from(v)
368
+ }
369
+ try {
370
+ writeFileSync(join(this.memoryDir, LINKS_FILE), JSON.stringify(obj, null, 2), 'utf-8')
371
+ } catch {
372
+ // best-effort — never block on cache write
373
+ }
374
+ }
375
+
376
+ private loadLinkGraph(): boolean {
377
+ const path = join(this.memoryDir, LINKS_FILE)
378
+ if (!existsSync(path)) return false
379
+ try {
380
+ const raw = JSON.parse(readFileSync(path, 'utf-8'))
381
+ for (const [k, v] of Object.entries(raw)) {
382
+ this.linkGraph.set(k, new Set(v as string[]))
383
+ }
384
+ return this.linkGraph.size > 0
385
+ } catch {
386
+ // corrupt file — rebuild from source
387
+ return false
388
+ }
389
+ }
390
+
346
391
  private parseMemoryFile(raw: string, filePath: string): MemoryEntry | null {
347
392
  const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
348
393
  if (!match) return null
package/src/index.tsx CHANGED
@@ -135,12 +135,21 @@ export async function runApp(options: RunOptions): Promise<void> {
135
135
  )
136
136
  }
137
137
 
138
+ // Phase 9 feature flags (all default true — opt-out via config)
139
+ const features = config.features || {}
140
+ const adaptiveThresholds = features.context?.adaptiveThresholds !== false
141
+ // gated via TokenCounter — set to false to fall back to chars/4 heuristic
142
+ const _useRealTokenizer = features.context?.useRealTokenizer !== false
143
+
138
144
  const context = new ContextManager({
139
145
  maxTokens: contextMaxTokens,
140
146
  compactionThreshold: 0.9,
141
- contextWindow: modelContextWindow,
147
+ contextWindow: adaptiveThresholds ? modelContextWindow : undefined,
142
148
  })
143
149
 
150
+ // Adaptive memory budget: scale with model's context window
151
+ getMemoryManager().setContextWindow(modelContextWindow)
152
+
144
153
  if (options.resume) {
145
154
  const saved = SessionStore.load(options.resume)
146
155
  if (saved) {
@@ -3,7 +3,7 @@
3
3
  * Strips invisible/control characters that could hide command content
4
4
  * from visual inspection or enable homoglyph attacks.
5
5
  *
6
- * Claude Code v2.1.223 parity: hidden command text via tabs/invisible Unicode.
6
+ * Protects against hidden command text via tabs/invisible Unicode characters.
7
7
  */
8
8
 
9
9
  const DANGEROUS_UNICODE = /[​‌‍‎‏‪‫‬‭‮⁠⁦⁧⁨⁩]/g
@@ -160,6 +160,13 @@ export interface MiphamConfig {
160
160
  /** Block installs from matching repos (e.g. ["malicious-org/*"]) */
161
161
  blockedMarketplaces?: string[]
162
162
  }
163
+ /** Phase 9 feature flags. All default to true. */
164
+ features?: Partial<FeatureFlags>
165
+ }
166
+
167
+ export interface FeatureFlags {
168
+ mcp: { oauthEnabled: boolean }
169
+ context: { useRealTokenizer: boolean; adaptiveThresholds: boolean }
163
170
  }
164
171
 
165
172
  export interface McpServerConfig {
package/src/ui/app.tsx CHANGED
@@ -12,6 +12,7 @@ import { ChatPanel } from './chat'
12
12
  import { InputBar } from './input'
13
13
  import { ModelPicker } from './picker'
14
14
  import { AgentFooter, type AgentEntry } from './agent-footer'
15
+ import { WorkflowProgress } from './workflow-progress.js'
15
16
  import {
16
17
  getCommand,
17
18
  looksLikeSlashCommand,
@@ -576,6 +577,9 @@ export function App({
576
577
  <Text dimColor>{modelId}</Text>
577
578
  </Box>
578
579
 
580
+ {/* Workflow progress — auto-detects active workflows, renders nothing when idle */}
581
+ <WorkflowProgress />
582
+
579
583
  {/* Chat panel */}
580
584
  <ChatPanel messages={messages} focusMode={focusMode} />
581
585
 
@@ -39,6 +39,7 @@ import {
39
39
  import { themeCmd, releaseNotesCmd, ideCmd, terminalSetupCmd } from '../commands/environment.js'
40
40
  import { commitCmd, pushCmd, prCmd, issueCmd } from '../commands/git.js'
41
41
  import { keysCmd } from '../commands/keys'
42
+ import { workflowViewCmd, workflowWatchCmd } from '../commands/workflow-view.js'
42
43
 
43
44
  export interface CommandContext {
44
45
  engine: QueryEngine
@@ -3080,6 +3081,8 @@ const commandsListCmd: CommandHandler = () => {
3080
3081
  '/pr-comments': 'Workflow',
3081
3082
  '/diff': 'Workflow',
3082
3083
  '/workflows': 'Workflow',
3084
+ '/workflow view': 'Workflow',
3085
+ '/workflow watch': 'Workflow',
3083
3086
  '/deep-research': 'Workflow',
3084
3087
  '/loop': 'Workflow',
3085
3088
  '/init': 'Project',
@@ -3217,6 +3220,8 @@ registry.set('/loop', loopCmd)
3217
3220
  registry.set('/no-plan', noPlanCmd)
3218
3221
  registry.set('/workflow', workflowAutoCmd)
3219
3222
  registry.set('/workflows', workflowsCmd)
3223
+ registry.set('/workflow view', workflowViewCmd)
3224
+ registry.set('/workflow watch', workflowWatchCmd)
3220
3225
  registry.set('/deep-research', deepResearchCmd)
3221
3226
  registry.set('/review', reviewCmd)
3222
3227
  registry.set('/pr-comments', prCommentsCmd)
@@ -3363,6 +3368,8 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
3363
3368
  '/pr-comments': 'PR review summary',
3364
3369
  '/diff': 'Show git diff',
3365
3370
  '/workflows': 'List workflow scripts',
3371
+ '/workflow view': 'View workflow run details or list runs',
3372
+ '/workflow watch': 'Monitor active workflow execution',
3366
3373
  '/deep-research':
3367
3374
  'Deep research with multi-agent parallel search, verification, and cited synthesis',
3368
3375
  '/loop': 'Run prompt on interval',
@@ -0,0 +1,176 @@
1
+ import React, { useState, useEffect, useMemo } from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { getEventBus } from '../workflow/event-bus.js'
4
+ import type { WorkflowEvent } from '../workflow/event-bus.js'
5
+
6
+ interface AgentState {
7
+ label: string
8
+ phase: string
9
+ status: 'running' | 'done' | 'failed'
10
+ durationMs: number
11
+ }
12
+
13
+ interface WorkflowProgressProps {
14
+ /** If provided, only show progress for this runId. Otherwise auto-detect. */
15
+ runId?: string
16
+ }
17
+
18
+ export function WorkflowProgress({ runId }: WorkflowProgressProps) {
19
+ const [activeRunId, setActiveRunId] = useState<string | null>(null)
20
+ const [phases, setPhases] = useState<Array<{ name: string; done: boolean }>>([])
21
+ const [agents, setAgents] = useState<Map<string, AgentState>>(new Map())
22
+ const [elapsed, setElapsed] = useState(0)
23
+ const [done, setDone] = useState(false)
24
+ const [_totalAgents, setTotalAgents] = useState(0)
25
+ const [cacheHits, setCacheHits] = useState(0)
26
+
27
+ useEffect(() => {
28
+ const bus = getEventBus()
29
+
30
+ const handleEvent = (event: WorkflowEvent) => {
31
+ switch (event.type) {
32
+ case 'phase:start':
33
+ setPhases((prev) => [...prev, { name: event.phase, done: false }])
34
+ setActiveRunId(bus.getActiveRunId())
35
+ break
36
+
37
+ case 'agent:start':
38
+ setAgents((prev) => {
39
+ const next = new Map(prev)
40
+ next.set(event.agentId, {
41
+ label: event.label,
42
+ phase: event.phase,
43
+ status: 'running',
44
+ durationMs: 0,
45
+ })
46
+ return next
47
+ })
48
+ break
49
+
50
+ case 'agent:end':
51
+ setAgents((prev) => {
52
+ const next = new Map(prev)
53
+ const existing = next.get(event.agentId)
54
+ if (existing) {
55
+ next.set(event.agentId, {
56
+ ...existing,
57
+ status: event.success ? 'done' : 'failed',
58
+ durationMs: event.durationMs,
59
+ })
60
+ // Mark phase as done if all agents in it are done
61
+ const phaseName = existing.phase
62
+ const allInPhase = Array.from(next.values()).filter((a) => a.phase === phaseName)
63
+ const allDone = allInPhase.every((a) => a.status !== 'running')
64
+ if (allDone) {
65
+ setPhases((prev) =>
66
+ prev.map((p) => (p.name === phaseName ? { ...p, done: true } : p)),
67
+ )
68
+ }
69
+ }
70
+ return next
71
+ })
72
+ break
73
+
74
+ case 'done':
75
+ setDone(true)
76
+ setTotalAgents(event.totalAgents)
77
+ setCacheHits(event.cacheHits)
78
+ break
79
+
80
+ case 'error':
81
+ break
82
+ }
83
+ }
84
+
85
+ // Subscribe to all event types
86
+ const types = [
87
+ 'phase:start',
88
+ 'agent:start',
89
+ 'agent:end',
90
+ 'agent:result',
91
+ 'log',
92
+ 'error',
93
+ 'done',
94
+ ]
95
+ for (const type of types) {
96
+ bus.on(type, handleEvent)
97
+ }
98
+
99
+ // Elapsed timer (ticks every 100ms)
100
+ const timer = setInterval(() => {
101
+ setElapsed((prev) => prev + 100)
102
+ }, 100)
103
+
104
+ return () => {
105
+ for (const type of types) {
106
+ bus.off(type, handleEvent)
107
+ }
108
+ clearInterval(timer)
109
+ }
110
+ }, [])
111
+
112
+ const agentList = useMemo(() => Array.from(agents.values()), [agents])
113
+
114
+ if (!activeRunId && !runId) return null
115
+ if (done && agentList.length === 0) return null
116
+
117
+ const runningCount = agentList.filter((a) => a.status === 'running').length
118
+ const doneCount = agentList.filter((a) => a.status === 'done').length
119
+ const failedCount = agentList.filter((a) => a.status === 'failed').length
120
+
121
+ const formatDuration = (ms: number) => {
122
+ if (ms < 1000) return `${ms}ms`
123
+ return `${(ms / 1000).toFixed(1)}s`
124
+ }
125
+
126
+ const elapsedStr = formatDuration(elapsed)
127
+
128
+ return (
129
+ <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
130
+ <Box>
131
+ <Text bold color="cyan">
132
+ ═══ Workflow: {activeRunId || runId} ═══
133
+ </Text>
134
+ </Box>
135
+
136
+ {/* Phases */}
137
+ {phases.map((phase, i) => {
138
+ const phaseAgents = agentList.filter((a) => a.phase === phase.name)
139
+ return (
140
+ <Box key={i} flexDirection="column" marginTop={1}>
141
+ <Text>
142
+ {phase.done ? '●' : '◌'} Phase: {phase.name}
143
+ {phaseAgents.length > 0 &&
144
+ ` [${phaseAgents.filter((a) => a.status === 'done').length}/${phaseAgents.length} done]`}
145
+ </Text>
146
+ {phaseAgents.map((agent, j) => (
147
+ <Box key={j} marginLeft={2}>
148
+ <Text
149
+ color={
150
+ agent.status === 'done' ? 'green' : agent.status === 'failed' ? 'red' : 'yellow'
151
+ }
152
+ >
153
+ {agent.status === 'done' ? '✓' : agent.status === 'failed' ? '✗' : '⏳'}{' '}
154
+ {agent.label}
155
+ {agent.status !== 'running' && ` ${formatDuration(agent.durationMs)}`}
156
+ </Text>
157
+ </Box>
158
+ ))}
159
+ </Box>
160
+ )
161
+ })}
162
+
163
+ {/* Summary footer */}
164
+ <Box marginTop={1}>
165
+ <Text dimColor>
166
+ ═══ Elapsed: {elapsedStr} | Agents: {doneCount}/{agentList.length}
167
+ {runningCount > 0 && ` (${runningCount} running)`}
168
+ {failedCount > 0 && ` (${failedCount} failed)`}
169
+ {done && ` | Cache hits: ${cacheHits}`}
170
+ {done && ' | DONE ═══'}
171
+ {!done && ' | Status: running ═══'}
172
+ </Text>
173
+ </Box>
174
+ </Box>
175
+ )
176
+ }
@@ -0,0 +1,36 @@
1
+ import { EventEmitter } from 'node:events'
2
+
3
+ export type WorkflowEvent =
4
+ | { type: 'phase:start'; phase: string; timestamp: number }
5
+ | { type: 'phase:end'; phase: string; timestamp: number }
6
+ | { type: 'agent:start'; agentId: string; label: string; phase: string }
7
+ | { type: 'agent:end'; agentId: string; label: string; success: boolean; durationMs: number }
8
+ | { type: 'agent:result'; agentId: string; summary: string }
9
+ | { type: 'log'; message: string }
10
+ | { type: 'error'; agentId?: string; message: string }
11
+ | { type: 'done'; runId: string; totalAgents: number; cacheHits: number }
12
+
13
+ export class WorkflowEventBus extends EventEmitter {
14
+ private activeRunId: string | null = null
15
+
16
+ startRun(runId: string): void {
17
+ this.activeRunId = runId
18
+ }
19
+
20
+ emitEvent(event: WorkflowEvent): void {
21
+ this.emit(event.type, event)
22
+ }
23
+
24
+ getActiveRunId(): string | null {
25
+ return this.activeRunId
26
+ }
27
+ }
28
+
29
+ let instance: WorkflowEventBus | null = null
30
+
31
+ export function getEventBus(): WorkflowEventBus {
32
+ if (!instance) {
33
+ instance = new WorkflowEventBus()
34
+ }
35
+ return instance
36
+ }
@@ -6,6 +6,7 @@ import { workflowAgent } from './primitives/agent'
6
6
  import { parallel } from './primitives/parallel'
7
7
  import { pipeline } from './primitives/pipeline'
8
8
  import { phase as phasePrimitive } from './primitives/phase'
9
+ import { getEventBus } from './event-bus'
9
10
  import { verify, judge } from './primitives/verify'
10
11
  import { loopUntilConvergence } from './primitives/loop'
11
12
  import type { ProviderRegistry } from '../providers/registry'
@@ -96,6 +97,9 @@ export async function runWorkflow(
96
97
  createJournal(runId, script)
97
98
  }
98
99
 
100
+ const bus = getEventBus()
101
+ bus.startRun(runId)
102
+
99
103
  const registry: ProviderRegistry = engine.getRegistry()
100
104
  const toolRegistry = engine.getTools()
101
105
  const permission = engine.getPermission()
@@ -114,10 +118,20 @@ export async function runWorkflow(
114
118
  }
115
119
  cacheMisses++
116
120
 
121
+ const agentId = (opts?.label as string) || `agent-${cacheMisses}`
122
+ const phase = (opts?.phase as string) || 'default'
123
+ const startTime = Date.now()
124
+
125
+ bus.emitEvent({ type: 'agent:start', agentId, label: agentId, phase })
126
+
117
127
  const result = await workflowAgent(prompt, registry, toolRegistry, {
118
128
  ...(opts || {}),
119
129
  permissionSystem: permission,
120
130
  } as Record<string, unknown>)
131
+
132
+ const durationMs = Date.now() - startTime
133
+ bus.emitEvent({ type: 'agent:end', agentId, label: agentId, success: true, durationMs })
134
+
121
135
  appendJournal(runId, {
122
136
  type: 'agent',
123
137
  prompt,
@@ -129,10 +143,12 @@ export async function runWorkflow(
129
143
 
130
144
  const wrappedPhase = (title: string) => {
131
145
  phasePrimitive(title)
146
+ bus.emitEvent({ type: 'phase:start', phase: title, timestamp: Date.now() })
132
147
  appendJournal(runId, { type: 'phase', message: title })
133
148
  }
134
149
 
135
150
  const log = (message: string) => {
151
+ bus.emitEvent({ type: 'log', message })
136
152
  appendJournal(runId, { type: 'log', message })
137
153
  }
138
154
 
@@ -160,8 +176,16 @@ export async function runWorkflow(
160
176
  let result: unknown
161
177
  try {
162
178
  result = await vmScript.runInContext(sandboxCtx, { timeout: 120_000 })
179
+
180
+ bus.emitEvent({
181
+ type: 'done',
182
+ runId,
183
+ totalAgents: cacheMisses,
184
+ cacheHits,
185
+ })
163
186
  } catch (err) {
164
187
  const message = err instanceof Error ? err.message : String(err)
188
+ bus.emitEvent({ type: 'error', message })
165
189
  throw new Error(
166
190
  `Workflow script execution failed: ${message}\n\n` +
167
191
  `This is likely a bug in the workflow script, not in Mipham Code itself. ` +
package/dist/mipham DELETED
Binary file