@miphamai/cli 0.4.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 +186 -0
- package/package.json +1 -1
- package/src/agent/agent-context.ts +56 -0
- package/src/agent/agent-registry.ts +134 -0
- package/src/agent/sub-agent.ts +73 -88
- 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/server.ts +31 -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 +144 -3
- 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 +11 -0
- 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 +221 -36
- package/src/index.tsx +20 -0
- 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/shared/types.ts +61 -1
- package/src/skills/fork-executor.ts +40 -0
- package/src/skills/loader.ts +46 -0
- package/src/tools/agent/agent.ts +20 -11
- package/src/tools/agent/skill.ts +32 -2
- 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 +3 -0
- package/src/ui/app.tsx +10 -1
- package/src/ui/commands.ts +99 -30
- package/src/ui/input.tsx +123 -8
- 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,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 "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
|
+
}
|
package/src/artifacts/server.ts
CHANGED
|
@@ -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 {
|