@miphamai/cli 0.3.0 → 0.5.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/bin/mipham.ts +188 -0
- package/dist/mipham +0 -0
- package/package.json +9 -9
- package/skills/mipham/om-artifact.mipham-skill.md +69 -0
- package/skills/mipham/om-model-optimize.mipham-skill.md +9 -6
- package/skills/mipham/om-security.mipham-skill.md +11 -8
- package/skills/standard/doc-generator.SKILL.md +6 -1
- package/skills/standard/github-ops.SKILL.md +12 -7
- package/skills/standard/memory.SKILL.md +1 -0
- package/skills/standard/self-review.SKILL.md +1 -0
- package/skills/standard/superpower.SKILL.md +7 -7
- package/skills/standard/web-search.SKILL.md +3 -0
- package/src/agent/agent-context.ts +56 -0
- package/src/agent/agent-registry.ts +134 -0
- package/src/agent/sub-agent.ts +73 -89
- package/src/agent/types.ts +39 -0
- package/src/agent-view/agent-view-manager.ts +217 -0
- package/src/agent-view/dashboard.tsx +218 -0
- package/src/agent-view/session-peek.tsx +72 -0
- package/src/agent-view/session-row.tsx +70 -0
- package/src/artifacts/manifest.ts +101 -0
- package/src/artifacts/server.ts +374 -0
- package/src/artifacts/versioning.ts +127 -0
- package/src/core/context-compact.ts +50 -0
- package/src/core/context-drain.ts +54 -0
- package/src/core/context-microcompact.ts +132 -0
- package/src/core/context-snip.ts +74 -0
- package/src/core/context-token.ts +108 -0
- package/src/core/context.ts +169 -0
- package/src/core/engine.ts +207 -58
- package/src/core/hooks-config.ts +52 -0
- package/src/core/hooks-executor.ts +113 -0
- package/src/core/hooks.ts +90 -30
- package/src/core/instructions.ts +12 -1
- package/src/core/memory/memory-loader.ts +23 -0
- package/src/core/memory/memory-manager.ts +192 -0
- package/src/core/memory/memory-writer.ts +72 -0
- package/src/core/permission-config.ts +34 -0
- package/src/core/permission-rules.ts +66 -0
- package/src/core/permission.ts +224 -34
- package/src/index.tsx +30 -2
- package/src/mcp/transport.ts +42 -5
- package/src/plugin/plugin-loader.ts +36 -0
- package/src/plugin/plugin-manager.ts +125 -0
- package/src/plugin/plugin-validator.ts +41 -0
- package/src/providers/fetch-utils.ts +1 -3
- package/src/providers/openai-compat.ts +2 -11
- package/src/shared/constants.ts +8 -1
- package/src/shared/types.ts +82 -2
- package/src/skills/fork-executor.ts +40 -0
- package/src/skills/loader.ts +48 -2
- package/src/tools/agent/agent.ts +20 -11
- package/src/tools/agent/skill.ts +32 -2
- package/src/tools/artifact/artifact.ts +144 -0
- package/src/tools/computer/app-launcher.ts +39 -0
- package/src/tools/computer/browser.ts +48 -0
- package/src/tools/computer/computer-use.ts +88 -0
- package/src/tools/computer/playwright.d.ts +7 -0
- package/src/tools/computer/screenshot.ts +57 -0
- package/src/tools/index.ts +6 -0
- package/src/ui/app.tsx +20 -7
- package/src/ui/chat.tsx +9 -5
- package/src/ui/commands.ts +185 -40
- package/src/ui/input.tsx +203 -27
- package/src/ui/picker.tsx +2 -5
- package/src/ui/vim-motions.ts +96 -0
- package/src/workflow/budget.ts +33 -0
- package/src/workflow/journal.ts +105 -0
- package/src/workflow/primitives/agent.ts +51 -0
- package/src/workflow/primitives/parallel.ts +8 -0
- package/src/workflow/primitives/phase.ts +19 -0
- package/src/workflow/primitives/pipeline.ts +24 -0
- package/src/workflow/runtime.ts +87 -0
- package/src/workflow/sandbox.ts +75 -0
|
@@ -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 "run this in background" 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
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readFileSync,
|
|
3
|
+
writeFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
copyFileSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
} from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import type { ArtifactManifest, ArtifactEntry } from '../shared/types'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Read the artifact manifest from disk, or return an empty one if it doesn't exist.
|
|
15
|
+
*/
|
|
16
|
+
export function readManifest(dir: string): ArtifactManifest {
|
|
17
|
+
const path = join(dir, 'index.json')
|
|
18
|
+
if (!existsSync(path)) {
|
|
19
|
+
return { version: 1, artifacts: [] }
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(path, 'utf-8'))
|
|
23
|
+
} catch {
|
|
24
|
+
return { version: 1, artifacts: [] }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Write the manifest to disk, creating parent directories as needed.
|
|
30
|
+
*/
|
|
31
|
+
export function writeManifest(dir: string, manifest: ArtifactManifest): void {
|
|
32
|
+
mkdirSync(dir, { recursive: true })
|
|
33
|
+
writeFileSync(join(dir, 'index.json'), JSON.stringify(manifest, null, 2), 'utf-8')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Add an entry to the manifest and persist it.
|
|
38
|
+
* If an entry with the same name already exists, it is replaced.
|
|
39
|
+
*/
|
|
40
|
+
export function addToManifest(dir: string, entry: ArtifactEntry, port?: number): ArtifactManifest {
|
|
41
|
+
const manifest = readManifest(dir)
|
|
42
|
+
if (port !== undefined) manifest.port = port
|
|
43
|
+
|
|
44
|
+
const idx = manifest.artifacts.findIndex((a) => a.name === entry.name)
|
|
45
|
+
if (idx >= 0) {
|
|
46
|
+
manifest.artifacts[idx] = entry
|
|
47
|
+
} else {
|
|
48
|
+
manifest.artifacts.push(entry)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
writeManifest(dir, manifest)
|
|
52
|
+
return manifest
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get all artifacts for a specific session.
|
|
57
|
+
*/
|
|
58
|
+
export function getSessionArtifacts(dir: string, sessionId: string): ArtifactEntry[] {
|
|
59
|
+
const manifest = readManifest(dir)
|
|
60
|
+
return manifest.artifacts.filter((a) => a.sessionId === sessionId)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Archive an existing artifact file by renaming it with a version tag.
|
|
65
|
+
* e.g. dashboard.html → dashboard.v1.html, dashboard.v1.html → dashboard.v2.html.
|
|
66
|
+
*
|
|
67
|
+
* Returns the version tag assigned to the archived file.
|
|
68
|
+
*/
|
|
69
|
+
export function archiveVersion(dir: string, entry: ArtifactEntry): string {
|
|
70
|
+
const manifest = readManifest(dir)
|
|
71
|
+
const versionCount = (entry.versionCount || 1) + 1
|
|
72
|
+
const ext = entry.type === 'svg' ? '.svg' : '.html'
|
|
73
|
+
const versionTag = `v${versionCount}`
|
|
74
|
+
|
|
75
|
+
// Rename the current file to a versioned copy
|
|
76
|
+
const baseName = entry.name
|
|
77
|
+
const currentPath = join(dir, entry.sessionId, `${baseName}${ext}`)
|
|
78
|
+
const archivedPath = join(dir, entry.sessionId, `${baseName}.${versionTag}${ext}`)
|
|
79
|
+
|
|
80
|
+
if (existsSync(currentPath)) {
|
|
81
|
+
try {
|
|
82
|
+
renameSync(currentPath, archivedPath)
|
|
83
|
+
} catch {
|
|
84
|
+
// If rename fails (e.g. cross-device), copy instead
|
|
85
|
+
copyFileSync(currentPath, archivedPath)
|
|
86
|
+
unlinkSync(currentPath)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Update manifest entry
|
|
91
|
+
const artifact = manifest.artifacts.find((a) => a.name === entry.name)
|
|
92
|
+
if (artifact) {
|
|
93
|
+
const versions = artifact.versions || ['v1']
|
|
94
|
+
versions.push(versionTag)
|
|
95
|
+
artifact.versions = versions
|
|
96
|
+
artifact.versionCount = versionCount
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
writeManifest(dir, manifest)
|
|
100
|
+
return versionTag
|
|
101
|
+
}
|