@miphamai/cli 0.4.0 → 0.5.1

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 (60) hide show
  1. package/bin/mipham +51 -24
  2. package/bin/mipham.ts +186 -0
  3. package/package.json +1 -1
  4. package/src/agent/agent-context.ts +56 -0
  5. package/src/agent/agent-registry.ts +134 -0
  6. package/src/agent/sub-agent.ts +73 -88
  7. package/src/agent/types.ts +39 -0
  8. package/src/agent-view/agent-view-manager.ts +217 -0
  9. package/src/agent-view/dashboard.tsx +218 -0
  10. package/src/agent-view/session-peek.tsx +72 -0
  11. package/src/agent-view/session-row.tsx +70 -0
  12. package/src/artifacts/server.ts +31 -0
  13. package/src/artifacts/versioning.ts +127 -0
  14. package/src/config/defaults.ts +2 -1
  15. package/src/core/context-compact.ts +50 -0
  16. package/src/core/context-drain.ts +54 -0
  17. package/src/core/context-microcompact.ts +132 -0
  18. package/src/core/context-snip.ts +74 -0
  19. package/src/core/context-token.ts +108 -0
  20. package/src/core/context.ts +169 -0
  21. package/src/core/engine.ts +144 -3
  22. package/src/core/hooks-config.ts +52 -0
  23. package/src/core/hooks-executor.ts +113 -0
  24. package/src/core/hooks.ts +90 -30
  25. package/src/core/instructions.ts +11 -0
  26. package/src/core/memory/memory-loader.ts +23 -0
  27. package/src/core/memory/memory-manager.ts +192 -0
  28. package/src/core/memory/memory-writer.ts +72 -0
  29. package/src/core/permission-config.ts +34 -0
  30. package/src/core/permission-rules.ts +66 -0
  31. package/src/core/permission.ts +221 -36
  32. package/src/index.tsx +20 -0
  33. package/src/plugin/plugin-loader.ts +36 -0
  34. package/src/plugin/plugin-manager.ts +125 -0
  35. package/src/plugin/plugin-validator.ts +41 -0
  36. package/src/shared/types.ts +61 -1
  37. package/src/skills/fork-executor.ts +40 -0
  38. package/src/skills/loader.ts +46 -0
  39. package/src/tools/agent/agent.ts +20 -11
  40. package/src/tools/agent/skill.ts +32 -2
  41. package/src/tools/computer/app-launcher.ts +39 -0
  42. package/src/tools/computer/browser.ts +48 -0
  43. package/src/tools/computer/computer-use.ts +88 -0
  44. package/src/tools/computer/playwright.d.ts +7 -0
  45. package/src/tools/computer/screenshot.ts +57 -0
  46. package/src/tools/index.ts +3 -0
  47. package/src/tools/network/web-fetch.ts +1 -1
  48. package/src/ui/app.tsx +14 -3
  49. package/src/ui/commands.ts +101 -32
  50. package/src/ui/input.tsx +123 -8
  51. package/src/ui/vim-motions.ts +96 -0
  52. package/src/workflow/budget.ts +33 -0
  53. package/src/workflow/journal.ts +105 -0
  54. package/src/workflow/primitives/agent.ts +51 -0
  55. package/src/workflow/primitives/parallel.ts +8 -0
  56. package/src/workflow/primitives/phase.ts +19 -0
  57. package/src/workflow/primitives/pipeline.ts +24 -0
  58. package/src/workflow/runtime.ts +87 -0
  59. package/src/workflow/sandbox.ts +79 -0
  60. package/dist/mipham +0 -0
@@ -0,0 +1,39 @@
1
+ // apps/cli/src/agent/types.ts
2
+
3
+ export type SubAgentType = 'general' | 'explore' | 'plan' | 'code-review'
4
+
5
+ export interface AgentFrontmatter {
6
+ name: string
7
+ description: string
8
+ tools?: string // comma-separated allowlist
9
+ disallowedTools?: string
10
+ model?: string // 'sonnet' | 'opus' | 'haiku' | 'inherit' | full model ID
11
+ permissionMode?: 'default' | 'acceptEdits' | 'auto' | 'bypass' | 'plan'
12
+ maxTurns?: number
13
+ skills?: string
14
+ background?: boolean
15
+ }
16
+
17
+ export interface AgentDefinition {
18
+ name: string
19
+ description: string
20
+ systemPrompt: string // markdown body after frontmatter
21
+ tools?: string
22
+ disallowedTools?: string
23
+ model: string
24
+ permissionMode: string
25
+ maxTurns?: number
26
+ skills?: string[]
27
+ background: boolean
28
+ source: 'builtin' | 'project' | 'user'
29
+ filePath?: string
30
+ }
31
+
32
+ export interface SubAgentOptions {
33
+ type?: SubAgentType
34
+ agentDef?: AgentDefinition
35
+ systemPrompt?: string
36
+ maxContextMessages?: number
37
+ allowedTools?: string[]
38
+ modelOverride?: string
39
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * AgentViewManager — multi-session lifecycle manager for background agent sessions.
3
+ *
4
+ * Each session represents a background sub-agent task. The manager tracks
5
+ * status transitions, elapsed time, and provides grouping/peek/attach/kill
6
+ * operations used by the Agent View Dashboard TUI.
7
+ */
8
+
9
+ export type SessionStatus = 'needs-input' | 'working' | 'completed' | 'failed'
10
+
11
+ export interface SessionMessage {
12
+ role: 'user' | 'assistant' | 'system'
13
+ content: string
14
+ }
15
+
16
+ export interface AgentSession {
17
+ id: string
18
+ title: string
19
+ status: SessionStatus
20
+ provider: string
21
+ model: string
22
+ task: string
23
+ createdAt: Date
24
+ startedAt?: Date
25
+ completedAt?: Date
26
+ elapsedMs: number
27
+ messages: SessionMessage[]
28
+ }
29
+
30
+ export interface CreateSessionOptions {
31
+ provider?: string
32
+ model?: string
33
+ }
34
+
35
+ export interface SessionPeek {
36
+ session: AgentSession
37
+ recentMessages: SessionMessage[]
38
+ }
39
+
40
+ export type StatusGroups = Record<SessionStatus, AgentSession[]>
41
+
42
+ export class AgentViewManager {
43
+ private sessions: Map<string, AgentSession> = new Map()
44
+ private sessionOrder: string[] = []
45
+ private idCounter = 0
46
+
47
+ /**
48
+ * Create a new background agent session.
49
+ */
50
+ create(title: string, task: string, options: CreateSessionOptions = {}): AgentSession {
51
+ const id = `agent-${++this.idCounter}-${Date.now()}`
52
+ const session: AgentSession = {
53
+ id,
54
+ title,
55
+ status: 'needs-input',
56
+ provider: options.provider ?? 'unknown',
57
+ model: options.model ?? 'unknown',
58
+ task,
59
+ createdAt: new Date(),
60
+ elapsedMs: 0,
61
+ messages: [],
62
+ }
63
+
64
+ this.sessions.set(id, session)
65
+ this.sessionOrder.push(id)
66
+
67
+ return session
68
+ }
69
+
70
+ /**
71
+ * List all sessions in creation order (newest first).
72
+ */
73
+ list(): AgentSession[] {
74
+ return [...this.sessionOrder]
75
+ .reverse()
76
+ .map((id) => this.sessions.get(id)!)
77
+ .filter(Boolean)
78
+ }
79
+
80
+ /**
81
+ * Group all sessions by their current status.
82
+ * Returns a record with keys for all four statuses (empty arrays if none).
83
+ */
84
+ groupByStatus(): StatusGroups {
85
+ const groups: StatusGroups = {
86
+ 'needs-input': [],
87
+ working: [],
88
+ completed: [],
89
+ failed: [],
90
+ }
91
+
92
+ for (const id of this.sessionOrder) {
93
+ const session = this.sessions.get(id)
94
+ if (session) {
95
+ groups[session.status].push(session)
96
+ }
97
+ }
98
+
99
+ return groups
100
+ }
101
+
102
+ /**
103
+ * Get a session by ID.
104
+ */
105
+ get(id: string): AgentSession | undefined {
106
+ return this.sessions.get(id)
107
+ }
108
+
109
+ /**
110
+ * Peek at a session — returns the session metadata and up to 5 recent messages.
111
+ */
112
+ peek(id: string): SessionPeek | undefined {
113
+ const session = this.sessions.get(id)
114
+ if (!session) return undefined
115
+
116
+ const recentMessages = session.messages.slice(-5)
117
+ return { session, recentMessages }
118
+ }
119
+
120
+ /**
121
+ * Attach to a session — marks the session as working (if needs-input) and
122
+ * returns it so the caller can switch the main UI to this session.
123
+ */
124
+ attach(id: string): AgentSession | undefined {
125
+ const session = this.sessions.get(id)
126
+ if (!session) return undefined
127
+
128
+ // If the session was waiting for input, transition to working
129
+ if (session.status === 'needs-input') {
130
+ session.status = 'working'
131
+ session.startedAt = new Date()
132
+ }
133
+
134
+ return session
135
+ }
136
+
137
+ /**
138
+ * Kill (terminate) a session. Only kills sessions that are not already completed/failed.
139
+ * Returns true if the session was found and killed, false otherwise.
140
+ */
141
+ kill(id: string): boolean {
142
+ const session = this.sessions.get(id)
143
+ if (!session) return false
144
+
145
+ // Don't re-kill already terminal sessions
146
+ if (session.status === 'completed' || session.status === 'failed') {
147
+ return false
148
+ }
149
+
150
+ session.status = 'failed'
151
+ session.completedAt = new Date()
152
+ return true
153
+ }
154
+
155
+ /**
156
+ * Update a session's status and optionally add a message.
157
+ */
158
+ updateStatus(id: string, status: SessionStatus): boolean {
159
+ const session = this.sessions.get(id)
160
+ if (!session) return false
161
+
162
+ session.status = status
163
+
164
+ if (status === 'working' && !session.startedAt) {
165
+ session.startedAt = new Date()
166
+ }
167
+ if ((status === 'completed' || status === 'failed') && !session.completedAt) {
168
+ session.completedAt = new Date()
169
+ }
170
+
171
+ return true
172
+ }
173
+
174
+ /**
175
+ * Add a message to a session's message history.
176
+ */
177
+ addMessage(id: string, message: SessionMessage): boolean {
178
+ const session = this.sessions.get(id)
179
+ if (!session) return false
180
+
181
+ session.messages.push(message)
182
+ return true
183
+ }
184
+
185
+ /**
186
+ * Count sessions by status.
187
+ */
188
+ countByStatus(): Record<SessionStatus, number> {
189
+ const counts: Record<SessionStatus, number> = {
190
+ 'needs-input': 0,
191
+ working: 0,
192
+ completed: 0,
193
+ failed: 0,
194
+ }
195
+
196
+ for (const [, session] of this.sessions) {
197
+ counts[session.status]++
198
+ }
199
+
200
+ return counts
201
+ }
202
+
203
+ /**
204
+ * Remove all completed and failed sessions (cleanup).
205
+ */
206
+ prune(): number {
207
+ let removed = 0
208
+ for (const [id, session] of this.sessions) {
209
+ if (session.status === 'completed' || session.status === 'failed') {
210
+ this.sessions.delete(id)
211
+ this.sessionOrder = this.sessionOrder.filter((oid) => oid !== id)
212
+ removed++
213
+ }
214
+ }
215
+ return removed
216
+ }
217
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * AgentViewDashboard — Ink TUI for background agent session management.
3
+ *
4
+ * Displays sessions grouped by status (needs-input / working / completed / failed),
5
+ * with j/k navigation, Space peek, Enter attach, and Esc exit.
6
+ *
7
+ * Usage:
8
+ * mipham agents (from CLI)
9
+ * /agents (from slash command within a running session)
10
+ */
11
+ import React, { useState, useCallback, useMemo } from 'react'
12
+ import { Box, Text, useInput } from 'ink'
13
+ import { AgentViewManager, type AgentSession, type SessionStatus } from './agent-view-manager'
14
+ import { SessionRow } from './session-row'
15
+ import { SessionPeek } from './session-peek'
16
+
17
+ interface DashboardProps {
18
+ manager: AgentViewManager
19
+ onAttach?: (session: AgentSession) => void
20
+ onExit: () => void
21
+ }
22
+
23
+ const STATUS_HEADERS: Record<string, { label: string; color: string }> = {
24
+ 'needs-input': { label: 'Needs Input', color: 'yellow' },
25
+ working: { label: 'Working', color: 'cyan' },
26
+ completed: { label: 'Completed', color: 'green' },
27
+ failed: { label: 'Failed', color: 'red' },
28
+ }
29
+
30
+ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps) {
31
+ const [selectedIndex, setSelectedIndex] = useState(0)
32
+ const [peekingSessionId, setPeekingSessionId] = useState<string | null>(null)
33
+
34
+ // Build a flat list of sessions in group order, with group headers
35
+ const flatList = useMemo(() => {
36
+ const groups = manager.groupByStatus()
37
+ const result: Array<
38
+ | { type: 'header'; status: string; label: string; color: string; count: number }
39
+ | { type: 'session'; session: AgentSession }
40
+ > = []
41
+
42
+ const statusOrder: Array<keyof typeof STATUS_HEADERS> = [
43
+ 'working',
44
+ 'needs-input',
45
+ 'completed',
46
+ 'failed',
47
+ ]
48
+
49
+ for (const _status of statusOrder) {
50
+ const status = _status as SessionStatus
51
+ const sessions = groups[status] ?? []
52
+ result.push({
53
+ type: 'header',
54
+ status,
55
+ label: STATUS_HEADERS[status]!.label,
56
+ color: STATUS_HEADERS[status]!.color,
57
+ count: sessions.length,
58
+ })
59
+
60
+ for (const session of sessions) {
61
+ result.push({ type: 'session', session })
62
+ }
63
+ }
64
+
65
+ return result
66
+ }, [manager])
67
+
68
+ // Flatten sessions only for navigation (skip headers)
69
+ const sessionsOnly = useMemo(
70
+ () =>
71
+ flatList.filter((item) => item.type === 'session') as Array<{
72
+ type: 'session'
73
+ session: AgentSession
74
+ }>,
75
+ [flatList],
76
+ )
77
+
78
+ const handleAttach = useCallback(
79
+ (sessionId: string) => {
80
+ const session = manager.attach(sessionId)
81
+ if (session && onAttach) {
82
+ onAttach(session)
83
+ }
84
+ },
85
+ [manager, onAttach],
86
+ )
87
+
88
+ useInput((input, key) => {
89
+ if (key.escape) {
90
+ if (peekingSessionId) {
91
+ setPeekingSessionId(null)
92
+ return
93
+ }
94
+ onExit()
95
+ return
96
+ }
97
+
98
+ if (input === 'j') {
99
+ setSelectedIndex((prev) => Math.min(prev + 1, sessionsOnly.length - 1))
100
+ setPeekingSessionId(null)
101
+ return
102
+ }
103
+
104
+ if (input === 'k') {
105
+ setSelectedIndex((prev) => Math.max(prev - 1, 0))
106
+ setPeekingSessionId(null)
107
+ return
108
+ }
109
+
110
+ // Space — toggle peek
111
+ if (input === ' ') {
112
+ if (sessionsOnly.length === 0) return
113
+ const current = sessionsOnly[selectedIndex]
114
+ if (!current) return
115
+ setPeekingSessionId(peekingSessionId === current.session.id ? null : current.session.id)
116
+ return
117
+ }
118
+
119
+ // Enter — attach to selected session
120
+ if (key.return) {
121
+ if (sessionsOnly.length === 0) return
122
+ const current = sessionsOnly[selectedIndex]
123
+ if (!current) return
124
+ if (peekingSessionId) {
125
+ handleAttach(current.session.id)
126
+ } else {
127
+ handleAttach(current.session.id)
128
+ }
129
+ return
130
+ }
131
+ })
132
+
133
+ // Compute the peek data for the currently peeking session
134
+ const peekData = useMemo(() => {
135
+ if (!peekingSessionId) return null
136
+ return manager.peek(peekingSessionId) ?? null
137
+ }, [manager, peekingSessionId])
138
+
139
+ const totalSessions = sessionsOnly.length
140
+ const counts = manager.countByStatus()
141
+
142
+ return (
143
+ <Box flexDirection="column" padding={1} height="100%">
144
+ {/* Header */}
145
+ <Box marginBottom={1} flexDirection="column">
146
+ <Box>
147
+ <Text bold color="cyan">
148
+ Agent View
149
+ </Text>
150
+ <Text dimColor> — Background Agent Dashboard</Text>
151
+ </Box>
152
+ <Box>
153
+ <Text dimColor>
154
+ {totalSessions} session{totalSessions !== 1 ? 's' : ''}
155
+ {' · '}
156
+ <Text color="cyan">{counts.working} working</Text>
157
+ {' · '}
158
+ <Text color="yellow">{counts['needs-input']} input</Text>
159
+ {' · '}
160
+ <Text color="green">{counts.completed} done</Text>
161
+ {' · '}
162
+ <Text color="red">{counts.failed} failed</Text>
163
+ </Text>
164
+ </Box>
165
+ <Box>
166
+ <Text dimColor>j/k navigate · Space peek · Enter attach · Esc back</Text>
167
+ </Box>
168
+ </Box>
169
+
170
+ {/* Divider */}
171
+ <Box marginBottom={1}>
172
+ <Text dimColor>{'─'.repeat(70)}</Text>
173
+ </Box>
174
+
175
+ {/* Empty state */}
176
+ {totalSessions === 0 ? (
177
+ <Box flexDirection="column" paddingY={2} paddingLeft={2}>
178
+ <Text dimColor>No background agents.</Text>
179
+ <Text dimColor>
180
+ Use the Agent tool or type &quot;run this in background&quot; to spawn one.
181
+ </Text>
182
+ </Box>
183
+ ) : (
184
+ <Box flexDirection="column">
185
+ {/* Session list with group headers */}
186
+ {flatList.map((item, flatIdx) => {
187
+ if (item.type === 'header') {
188
+ return (
189
+ <Box key={`h-${item.status}`} marginY={1}>
190
+ <Text bold color={item.color}>
191
+ {' '}
192
+ {item.label} ({item.count})
193
+ </Text>
194
+ </Box>
195
+ )
196
+ }
197
+
198
+ // Map this session's position in sessionsOnly to selectedIndex
199
+ const sessionIdx = sessionsOnly.findIndex((s) => s.session.id === item.session.id)
200
+
201
+ return (
202
+ <SessionRow
203
+ key={item.session.id}
204
+ session={item.session}
205
+ isSelected={sessionIdx === selectedIndex}
206
+ />
207
+ )
208
+ })}
209
+ </Box>
210
+ )}
211
+
212
+ {/* Peek panel (shown below the list when peeking) */}
213
+ {peekData && (
214
+ <SessionPeek session={peekData.session} recentMessages={peekData.recentMessages} />
215
+ )}
216
+ </Box>
217
+ )
218
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * SessionPeek — a popup panel that shows the last few messages of
3
+ * a selected agent session. Invoked by pressing Space on a session row.
4
+ */
5
+ import React from 'react'
6
+ import { Box, Text } from 'ink'
7
+ import type { AgentSession, SessionMessage } from './agent-view-manager'
8
+
9
+ interface SessionPeekProps {
10
+ session: AgentSession
11
+ recentMessages: SessionMessage[]
12
+ }
13
+
14
+ const ROLE_COLORS: Record<string, string> = {
15
+ user: 'green',
16
+ assistant: 'cyan',
17
+ system: 'yellow',
18
+ }
19
+
20
+ export function SessionPeek({ session, recentMessages }: SessionPeekProps) {
21
+ return (
22
+ <Box flexDirection="column" borderStyle="single" borderColor="blue" padding={1} marginY={1}>
23
+ {/* Header */}
24
+ <Box marginBottom={1}>
25
+ <Text bold color="blue">
26
+ {' '}
27
+ Peek: {session.id}{' '}
28
+ </Text>
29
+ <Text dimColor>
30
+ — {session.title} · {session.provider}/{session.model}
31
+ </Text>
32
+ </Box>
33
+
34
+ {/* Divider */}
35
+ <Box marginBottom={1}>
36
+ <Text dimColor>{'─'.repeat(60)}</Text>
37
+ </Box>
38
+
39
+ {/* Messages */}
40
+ {recentMessages.length === 0 ? (
41
+ <Box paddingLeft={2}>
42
+ <Text dimColor>(no messages yet)</Text>
43
+ </Box>
44
+ ) : (
45
+ recentMessages.map((msg, i) => (
46
+ <Box key={i} flexDirection="column" marginBottom={1}>
47
+ <Text color={ROLE_COLORS[msg.role] || 'white'} bold>
48
+ [{msg.role}]
49
+ </Text>
50
+ <Box paddingLeft={4}>
51
+ <Text dimColor>
52
+ {msg.content.length > 120 ? msg.content.slice(0, 120) + '...' : msg.content}
53
+ </Text>
54
+ </Box>
55
+ </Box>
56
+ ))
57
+ )}
58
+
59
+ {/* Footer */}
60
+ <Box marginTop={1}>
61
+ <Text dimColor>{'─'.repeat(60)}</Text>
62
+ </Box>
63
+ <Box>
64
+ <Text dimColor>
65
+ {' '}
66
+ {recentMessages.length} message(s) · Total: {session.messages.length} · Status:{' '}
67
+ {session.status}
68
+ </Text>
69
+ </Box>
70
+ </Box>
71
+ )
72
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * SessionRow — a single row in the Agent View dashboard.
3
+ * Shows provider, model, task, and elapsed time for one background agent session.
4
+ */
5
+ import React from 'react'
6
+ import { Box, Text } from 'ink'
7
+ import type { AgentSession } from './agent-view-manager'
8
+
9
+ interface SessionRowProps {
10
+ session: AgentSession
11
+ isSelected: boolean
12
+ }
13
+
14
+ function formatElapsed(ms: number): string {
15
+ if (ms < 1000) return '<1s'
16
+ const seconds = Math.floor(ms / 1000)
17
+ if (seconds < 60) return `${seconds}s`
18
+ const minutes = Math.floor(seconds / 60)
19
+ const remainingSecs = seconds % 60
20
+ if (minutes < 60) return `${minutes}m ${remainingSecs}s`
21
+ const hours = Math.floor(minutes / 60)
22
+ const remainingMin = minutes % 60
23
+ return `${hours}h ${remainingMin}m`
24
+ }
25
+
26
+ const STATUS_COLORS: Record<string, string> = {
27
+ 'needs-input': 'yellow',
28
+ working: 'cyan',
29
+ completed: 'green',
30
+ failed: 'red',
31
+ }
32
+
33
+ const STATUS_LABELS: Record<string, string> = {
34
+ 'needs-input': '[INPUT]',
35
+ working: '[WORK] ',
36
+ completed: '[DONE] ',
37
+ failed: '[FAIL] ',
38
+ }
39
+
40
+ export function SessionRow({ session, isSelected }: SessionRowProps) {
41
+ const color = STATUS_COLORS[session.status] || 'white'
42
+ const label = STATUS_LABELS[session.status] || ''
43
+ const prefix = isSelected ? '❯' : ' '
44
+
45
+ const taskPreview = session.task.length > 60 ? session.task.slice(0, 60) + '...' : session.task
46
+
47
+ return (
48
+ <Box flexDirection="column">
49
+ <Box>
50
+ <Text color={isSelected ? 'blue' : undefined} bold={isSelected}>
51
+ {prefix}{' '}
52
+ </Text>
53
+ <Text color={color}>{label}</Text>
54
+ <Text> </Text>
55
+ <Text bold={isSelected}>{session.id}</Text>
56
+ <Text dimColor>
57
+ {' '}
58
+ · {session.provider}/{session.model}
59
+ </Text>
60
+ <Text dimColor> · {formatElapsed(session.elapsedMs)}</Text>
61
+ </Box>
62
+ <Box paddingLeft={isSelected ? 4 : 3}>
63
+ <Text dimColor={!isSelected} color={isSelected ? 'white' : undefined}>
64
+ {taskPreview}
65
+ </Text>
66
+ {session.title !== session.task.slice(0, 60) && <Text dimColor> — {session.title}</Text>}
67
+ </Box>
68
+ </Box>
69
+ )
70
+ }
@@ -3,6 +3,7 @@ import { createReadStream, existsSync, statSync } from 'node:fs'
3
3
  import { join, normalize, extname } from 'node:path'
4
4
  import { ARTIFACT_ALLOWED_EXTENSIONS } from '../shared/constants'
5
5
  import { readManifest } from './manifest'
6
+ import { ArtifactVersioning } from './versioning'
6
7
  import type { ArtifactEntry } from '../shared/types'
7
8
 
8
9
  // ── SSE client tracking ──
@@ -27,10 +28,12 @@ export class ArtifactServer {
27
28
  private started = false
28
29
  private sseClients: SseClient[] = []
29
30
  private sseIdCounter = 0
31
+ private versioning: ArtifactVersioning
30
32
 
31
33
  constructor(artifactsDir: string, preferredPort: number) {
32
34
  this.artifactsDir = artifactsDir
33
35
  this.port = preferredPort
36
+ this.versioning = new ArtifactVersioning(artifactsDir)
34
37
  }
35
38
 
36
39
  /** Notify all connected SSE clients to reload. Called after artifact changes. */
@@ -133,6 +136,15 @@ export class ArtifactServer {
133
136
  return
134
137
  }
135
138
 
139
+ // Name-based SSE endpoint: GET /:name/sse
140
+ if (urlPath.endsWith('/sse') && urlPath.length > 4) {
141
+ const name = urlPath.slice(1, -4) // strip leading '/' and trailing '/sse'
142
+ if (name.length > 0 && !name.includes('/')) {
143
+ this.handleNameSse(name, res)
144
+ return
145
+ }
146
+ }
147
+
136
148
  // Gallery page
137
149
  if (urlPath === '/' || urlPath === '/index.html') {
138
150
  this.serveGallery(res)
@@ -165,6 +177,25 @@ export class ArtifactServer {
165
177
  })
166
178
  }
167
179
 
180
+ /** Per-artifact SSE stream: pushes content updates to connected browsers every 500ms. */
181
+ private handleNameSse(name: string, res: any): void {
182
+ res.writeHead(200, {
183
+ 'Content-Type': 'text/event-stream',
184
+ 'Cache-Control': 'no-cache',
185
+ Connection: 'keep-alive',
186
+ 'Access-Control-Allow-Origin': '*',
187
+ })
188
+
189
+ const interval = setInterval(() => {
190
+ const content = this.versioning.getVersion(name)
191
+ if (content) {
192
+ res.write(`data: ${JSON.stringify({ type: 'update', name, content })}\n\n`)
193
+ }
194
+ }, 500)
195
+
196
+ res.on('close', () => clearInterval(interval))
197
+ }
198
+
168
199
  // ── Gallery Page ──
169
200
 
170
201
  private serveGallery(res: any): void {