@miphamai/cli 0.9.0 → 0.11.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/dist/mipham ADDED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -1,4 +1,7 @@
1
1
  // apps/cli/src/agent/agent-context.ts
2
+ import { readdirSync, readFileSync, existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { homedir } from 'node:os'
2
5
  import { ContextManager } from '../core/context'
3
6
  import type { ToolDefinition } from '../shared/index.ts'
4
7
  import type { AgentDefinition } from './types'
@@ -8,6 +11,50 @@ export interface AgentContextResult {
8
11
  allowedTools: ToolDefinition[]
9
12
  }
10
13
 
14
+ /**
15
+ * Load agent memory files from the appropriate scope directory.
16
+ * Returns combined content for injection into the system prompt.
17
+ */
18
+ function loadAgentMemory(agentName: string, scope: 'user' | 'project' | 'local'): string {
19
+ let memoryDir: string
20
+ const home = homedir()
21
+
22
+ switch (scope) {
23
+ case 'user':
24
+ memoryDir = join(home, '.mipham', 'agent-memory', agentName)
25
+ break
26
+ case 'project':
27
+ memoryDir = join(process.cwd(), '.mipham', 'agent-memory', agentName)
28
+ break
29
+ case 'local':
30
+ memoryDir = join(process.cwd(), '.mipham', 'agent-memory-local', agentName)
31
+ break
32
+ }
33
+
34
+ if (!existsSync(memoryDir)) return ''
35
+
36
+ try {
37
+ const files = readdirSync(memoryDir).filter((f) => f.endsWith('.md'))
38
+ if (files.length === 0) return ''
39
+
40
+ const contents: string[] = []
41
+ for (const file of files.slice(0, 10)) {
42
+ // max 10 files
43
+ try {
44
+ const content = readFileSync(join(memoryDir, file), 'utf-8').trim()
45
+ if (content) contents.push(content)
46
+ } catch {
47
+ // skip unreadable
48
+ }
49
+ }
50
+
51
+ if (contents.length === 0) return ''
52
+ return [`[Agent Memory — ${scope} scope]`, ...contents].join('\n\n')
53
+ } catch {
54
+ return ''
55
+ }
56
+ }
57
+
11
58
  /**
12
59
  * Create an isolated context and tool set for a sub-agent.
13
60
  *
@@ -15,6 +62,9 @@ export interface AgentContextResult {
15
62
  * 1. If `tools` is set, only those tools are allowed.
16
63
  * 2. If `disallowedTools` is set, those are removed from the full set.
17
64
  * 3. If neither is set, all tools are available.
65
+ *
66
+ * Agent memory: if agentDef.memory is set, loads memory files from the
67
+ * appropriate scope and injects them into the system prompt.
18
68
  */
19
69
  export function createAgentContext(
20
70
  agentDef: AgentDefinition,
@@ -27,7 +77,16 @@ export function createAgentContext(
27
77
  compactionThreshold: 0.85,
28
78
  })
29
79
 
30
- context.setSystemPrompt(agentDef.systemPrompt)
80
+ // Build system prompt with optional agent memory
81
+ let systemPrompt = agentDef.systemPrompt
82
+ if (agentDef.memory) {
83
+ const memory = loadAgentMemory(agentDef.name, agentDef.memory)
84
+ if (memory) {
85
+ systemPrompt = `${systemPrompt}\n\n---\n\n${memory}`
86
+ }
87
+ }
88
+
89
+ context.setSystemPrompt(systemPrompt)
31
90
 
32
91
  // Scope tools
33
92
  let allowedTools = Array.from(toolRegistry.values())
@@ -74,6 +74,7 @@ export class AgentRegistry {
74
74
  .map((s) => s.trim())
75
75
  : undefined,
76
76
  background: (data.background as boolean) || false,
77
+ memory: data.memory as 'user' | 'project' | 'local' | undefined,
77
78
  source,
78
79
  filePath: fullPath,
79
80
  }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * BackgroundAgentRegistry — lifecycle manager for background agent tasks.
3
+ *
4
+ * Each background task runs as a detached Promise with an AbortController
5
+ * for cancellation. The registry tracks status and stores results, which
6
+ * can be queried via the Task tool (output/stop actions) or the Agent View.
7
+ */
8
+
9
+ export type BackgroundTaskStatus = 'running' | 'completed' | 'failed'
10
+
11
+ export interface BackgroundTask {
12
+ id: string
13
+ description: string
14
+ agentType: string
15
+ status: BackgroundTaskStatus
16
+ startedAt: Date
17
+ completedAt?: Date
18
+ result?: string
19
+ error?: string
20
+ abortController: AbortController
21
+ }
22
+
23
+ type CompleteCallback = (task: BackgroundTask) => void
24
+
25
+ export class BackgroundAgentRegistry {
26
+ private tasks: Map<string, BackgroundTask> = new Map()
27
+ private completeCallbacks: Map<string, CompleteCallback[]> = new Map()
28
+ private idCounter = 0
29
+
30
+ /**
31
+ * Spawn a background task. Returns the task ID immediately.
32
+ * The executor function runs asynchronously; its result is stored in the task.
33
+ *
34
+ * @param description - Human-readable description
35
+ * @param agentType - Sub-agent type (general, explore, plan, code-review)
36
+ * @param executor - Async function that performs the work
37
+ */
38
+ spawn(
39
+ description: string,
40
+ agentType: string,
41
+ executor: (signal: AbortSignal) => Promise<string>,
42
+ ): string {
43
+ const id = `bg-${++this.idCounter}-${Date.now().toString(36)}`
44
+
45
+ const task: BackgroundTask = {
46
+ id,
47
+ description,
48
+ agentType,
49
+ status: 'running',
50
+ startedAt: new Date(),
51
+ abortController: new AbortController(),
52
+ }
53
+
54
+ this.tasks.set(id, task)
55
+
56
+ // Execute in background — do NOT await
57
+ executor(task.abortController.signal)
58
+ .then((result) => {
59
+ task.status = 'completed'
60
+ task.completedAt = new Date()
61
+ task.result = result
62
+ this.fireComplete(id)
63
+ })
64
+ .catch((err) => {
65
+ // Don't mark as failed if it was intentionally aborted
66
+ if (err instanceof Error && err.name === 'AbortError') {
67
+ task.status = 'failed'
68
+ task.error = 'Task was cancelled.'
69
+ } else {
70
+ task.status = 'failed'
71
+ task.error = String(err)
72
+ }
73
+ task.completedAt = new Date()
74
+ this.fireComplete(id)
75
+ })
76
+
77
+ return id
78
+ }
79
+
80
+ /**
81
+ * Get a task by ID. Returns undefined if not found.
82
+ */
83
+ get(id: string): BackgroundTask | undefined {
84
+ return this.tasks.get(id)
85
+ }
86
+
87
+ /**
88
+ * List all background tasks, newest first.
89
+ */
90
+ list(): BackgroundTask[] {
91
+ return Array.from(this.tasks.values()).sort(
92
+ (a, b) => b.startedAt.getTime() - a.startedAt.getTime(),
93
+ )
94
+ }
95
+
96
+ /**
97
+ * List only running tasks.
98
+ */
99
+ listRunning(): BackgroundTask[] {
100
+ return this.list().filter((t) => t.status === 'running')
101
+ }
102
+
103
+ /**
104
+ * Stop a running task by aborting its controller.
105
+ * Returns true if the task was running and was stopped.
106
+ */
107
+ stop(id: string): boolean {
108
+ const task = this.tasks.get(id)
109
+ if (!task) return false
110
+ if (task.status !== 'running') return false
111
+
112
+ task.abortController.abort()
113
+ return true
114
+ }
115
+
116
+ /**
117
+ * Register a callback that fires when a task completes (success or failure).
118
+ * The callback is called once and then automatically removed.
119
+ */
120
+ onComplete(id: string, callback: CompleteCallback): void {
121
+ // If already complete, call immediately
122
+ const task = this.tasks.get(id)
123
+ if (task && task.status !== 'running') {
124
+ callback(task)
125
+ return
126
+ }
127
+
128
+ const cbs = this.completeCallbacks.get(id) || []
129
+ cbs.push(callback)
130
+ this.completeCallbacks.set(id, cbs)
131
+ }
132
+
133
+ /**
134
+ * Remove completed/failed tasks older than `maxAgeMs`.
135
+ * Returns the number of tasks pruned.
136
+ */
137
+ prune(maxAgeMs: number = 30 * 60 * 1000): number {
138
+ const now = Date.now()
139
+ let removed = 0
140
+ for (const [id, task] of this.tasks) {
141
+ if (task.status === 'running') continue
142
+ if (task.completedAt && now - task.completedAt.getTime() > maxAgeMs) {
143
+ this.tasks.delete(id)
144
+ this.completeCallbacks.delete(id)
145
+ removed++
146
+ }
147
+ }
148
+ return removed
149
+ }
150
+
151
+ /**
152
+ * Count tasks by status.
153
+ */
154
+ countByStatus(): Record<BackgroundTaskStatus, number> {
155
+ const counts: Record<BackgroundTaskStatus, number> = {
156
+ running: 0,
157
+ completed: 0,
158
+ failed: 0,
159
+ }
160
+ for (const [, task] of this.tasks) {
161
+ counts[task.status]++
162
+ }
163
+ return counts
164
+ }
165
+
166
+ private fireComplete(id: string): void {
167
+ const task = this.tasks.get(id)
168
+ if (!task) return
169
+
170
+ const cbs = this.completeCallbacks.get(id)
171
+ if (cbs) {
172
+ for (const cb of cbs) {
173
+ try {
174
+ cb(task)
175
+ } catch {
176
+ // Callback errors should not propagate
177
+ }
178
+ }
179
+ this.completeCallbacks.delete(id)
180
+ }
181
+ }
182
+ }
183
+
184
+ /** Singleton instance for the CLI process. */
185
+ let _instance: BackgroundAgentRegistry | null = null
186
+
187
+ export function getBackgroundAgentRegistry(): BackgroundAgentRegistry {
188
+ if (!_instance) {
189
+ _instance = new BackgroundAgentRegistry()
190
+ }
191
+ return _instance
192
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * AgentMessageBus — inter-agent communication channel.
3
+ *
4
+ * Agents (main session + background sub-agents) can post and poll messages.
5
+ * Messages are stored in-memory and keyed by recipient. Each message has a
6
+ * read flag so recipients can distinguish new from seen messages.
7
+ *
8
+ * Usage:
9
+ * const bus = getMessageBus()
10
+ * const msgId = bus.post('main', 'bg-1', 'hello', 'How is it going?')
11
+ * const unread = bus.poll('bg-1') // messages addressed TO bg-1
12
+ */
13
+
14
+ export interface AgentMessage {
15
+ id: string
16
+ from: string
17
+ to: string
18
+ summary: string
19
+ message: string
20
+ timestamp: Date
21
+ read: boolean
22
+ }
23
+
24
+ export class AgentMessageBus {
25
+ private messages: AgentMessage[] = []
26
+ private idCounter = 0
27
+
28
+ /**
29
+ * Post a message from one agent to another.
30
+ * Returns the message ID.
31
+ */
32
+ post(from: string, to: string, summary: string, message: string): string {
33
+ const id = `msg-${++this.idCounter}`
34
+ this.messages.push({
35
+ id,
36
+ from,
37
+ to,
38
+ summary: summary.slice(0, 200),
39
+ message,
40
+ timestamp: new Date(),
41
+ read: false,
42
+ })
43
+ return id
44
+ }
45
+
46
+ /**
47
+ * Get all unread messages addressed to the given agent.
48
+ * Does NOT mark them as read — use markRead() for that.
49
+ */
50
+ poll(agentId: string): AgentMessage[] {
51
+ return this.messages.filter((m) => m.to === agentId && !m.read)
52
+ }
53
+
54
+ /**
55
+ * Get all messages addressed to the given agent (read + unread).
56
+ */
57
+ list(agentId: string): AgentMessage[] {
58
+ return this.messages.filter((m) => m.to === agentId)
59
+ }
60
+
61
+ /**
62
+ * Mark a specific message as read by ID.
63
+ * Returns true if the message was found and marked.
64
+ */
65
+ markRead(messageId: string): boolean {
66
+ const msg = this.messages.find((m) => m.id === messageId)
67
+ if (!msg) return false
68
+ msg.read = true
69
+ return true
70
+ }
71
+
72
+ /**
73
+ * Mark all messages for a recipient as read.
74
+ * Returns the count of messages marked.
75
+ */
76
+ markAllRead(agentId: string): number {
77
+ let count = 0
78
+ for (const msg of this.messages) {
79
+ if (msg.to === agentId && !msg.read) {
80
+ msg.read = true
81
+ count++
82
+ }
83
+ }
84
+ return count
85
+ }
86
+
87
+ /**
88
+ * Get unread message count for an agent.
89
+ */
90
+ unreadCount(agentId: string): number {
91
+ return this.messages.filter((m) => m.to === agentId && !m.read).length
92
+ }
93
+
94
+ /**
95
+ * Prune messages older than maxAgeMs. Returns the number pruned.
96
+ */
97
+ prune(maxAgeMs: number = 60 * 60 * 1000): number {
98
+ const now = Date.now()
99
+ const before = this.messages.length
100
+ this.messages = this.messages.filter((m) => now - m.timestamp.getTime() < maxAgeMs)
101
+ return before - this.messages.length
102
+ }
103
+ }
104
+
105
+ /** Singleton instance. */
106
+ let _instance: AgentMessageBus | null = null
107
+
108
+ export function getMessageBus(): AgentMessageBus {
109
+ if (!_instance) {
110
+ _instance = new AgentMessageBus()
111
+ }
112
+ return _instance
113
+ }
@@ -2,6 +2,8 @@ import type { ProviderRegistry, ProviderInstance } from '../providers/registry'
2
2
  import type { ToolDefinition } from '../shared/index.ts'
3
3
  import type { SubAgentType, SubAgentOptions, AgentDefinition } from './types'
4
4
  import { createAgentContext } from './agent-context'
5
+ import { getBackgroundAgentRegistry } from './background-registry'
6
+ import type { HookEngine } from '../core/hooks'
5
7
 
6
8
  const TYPE_SYSTEM_PROMPTS: Record<SubAgentType, string> = {
7
9
  general: 'You are a focused sub-agent. Complete the assigned task thoroughly and return results.',
@@ -16,17 +18,91 @@ const TYPE_SYSTEM_PROMPTS: Record<SubAgentType, string> = {
16
18
  * Sub-agent engine — creates an isolated conversation context and processes
17
19
  * a single prompt independently via the active AI provider. Returns the
18
20
  * consolidated result text.
21
+ *
22
+ * Supports background execution via `runInBackground` option — when true,
23
+ * execution is spawned as a detached promise and the method returns immediately
24
+ * with a `[background-task:<id>]` marker.
19
25
  */
20
26
  export class SubAgent {
21
27
  constructor(
22
28
  private registry: ProviderRegistry,
23
29
  private toolRegistry: Map<string, ToolDefinition>,
30
+ private hookEngine?: HookEngine,
24
31
  ) {}
25
32
 
33
+ /**
34
+ * Execute a sub-agent task.
35
+ *
36
+ * When `options.runInBackground` is true:
37
+ * - The task runs asynchronously in BackgroundAgentRegistry
38
+ * - Returns immediately with `[background-task:<id>]`
39
+ * - Results are retrievable via Task output or Agent View
40
+ */
26
41
  async execute(
27
42
  prompt: string,
28
43
  description: string,
29
44
  options: SubAgentOptions = {},
45
+ ): Promise<string> {
46
+ const agentType = options.type || 'general'
47
+
48
+ // ── Fire SubagentStart hook ──
49
+ if (this.hookEngine) {
50
+ await this.hookEngine.executeSubagentStart(agentType, description, 'sub-agent')
51
+ }
52
+
53
+ // ── Background execution path ──
54
+ if (options.runInBackground) {
55
+ const bgRegistry = getBackgroundAgentRegistry()
56
+
57
+ const taskId = bgRegistry.spawn(description, agentType, async (signal) => {
58
+ // Run the synchronous execution inside the background executor
59
+ return this.runExecution(prompt, options, signal)
60
+ })
61
+
62
+ // Register completion callback for hook firing
63
+ bgRegistry.onComplete(taskId, (task) => {
64
+ if (this.hookEngine) {
65
+ this.hookEngine.executeSubagentStop(
66
+ agentType,
67
+ description,
68
+ taskId,
69
+ task.status === 'completed',
70
+ task.result || task.error,
71
+ )
72
+ }
73
+ })
74
+
75
+ return `[background-task:${taskId}]`
76
+ }
77
+
78
+ // ── Synchronous execution path ──
79
+ try {
80
+ const result = await this.runExecution(prompt, options)
81
+ if (this.hookEngine) {
82
+ await this.hookEngine.executeSubagentStop(agentType, description, 'sub-agent', true, result)
83
+ }
84
+ return result
85
+ } catch (err) {
86
+ if (this.hookEngine) {
87
+ await this.hookEngine.executeSubagentStop(
88
+ agentType,
89
+ description,
90
+ 'sub-agent',
91
+ false,
92
+ String(err),
93
+ )
94
+ }
95
+ throw err
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Internal execution method — shared by sync and background paths.
101
+ */
102
+ private async runExecution(
103
+ prompt: string,
104
+ options: SubAgentOptions,
105
+ signal?: AbortSignal,
30
106
  ): Promise<string> {
31
107
  const provider = this.registry.getActive()
32
108
  if (!provider) {
@@ -34,15 +110,16 @@ export class SubAgent {
34
110
  }
35
111
 
36
112
  const model = this.registry.getActiveModel()
37
- const type = options.type || 'general'
113
+ const agentType = options.type || 'general'
38
114
  const agentDef = options.agentDef
39
115
 
40
116
  // Resolve system prompt: agentDef > options.systemPrompt > builtin type
41
- const systemPrompt = agentDef?.systemPrompt || options.systemPrompt || TYPE_SYSTEM_PROMPTS[type]
117
+ const systemPrompt =
118
+ agentDef?.systemPrompt || options.systemPrompt || TYPE_SYSTEM_PROMPTS[agentType]
42
119
 
43
120
  // Create isolated context with tool scoping
44
121
  const resolvedDef: AgentDefinition = agentDef || {
45
- name: type,
122
+ name: agentType,
46
123
  description: '',
47
124
  systemPrompt,
48
125
  model: options.modelOverride || 'inherit',
@@ -82,6 +159,11 @@ export class SubAgent {
82
159
 
83
160
  try {
84
161
  for (let turn = 0; turn < MAX_TOOL_TURNS; turn++) {
162
+ // Check abort signal
163
+ if (signal?.aborted) {
164
+ throw new DOMException('Aborted', 'AbortError')
165
+ }
166
+
85
167
  const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
86
168
  let turnText = ''
87
169
 
@@ -92,8 +174,17 @@ export class SubAgent {
92
174
  tools: toolDefs,
93
175
  maxTokens: 4096,
94
176
  })) {
177
+ // Check abort signal mid-stream
178
+ if (signal?.aborted) {
179
+ throw new DOMException('Aborted', 'AbortError')
180
+ }
181
+
95
182
  if (chunk.type === 'text' && chunk.content) {
96
183
  turnText += chunk.content
184
+ // Stream progress to callback if provided
185
+ if (options.onProgress) {
186
+ options.onProgress(chunk.content)
187
+ }
97
188
  }
98
189
  if (chunk.type === 'tool_use' && chunk.toolUse) {
99
190
  toolUses.push({
@@ -125,6 +216,10 @@ export class SubAgent {
125
216
  ]
126
217
 
127
218
  for (const tu of toolUses) {
219
+ if (signal?.aborted) {
220
+ throw new DOMException('Aborted', 'AbortError')
221
+ }
222
+
128
223
  const tool = this.toolRegistry.get(tu.name)
129
224
  if (!tool) {
130
225
  currentMessages.push({
@@ -168,6 +263,9 @@ export class SubAgent {
168
263
  currentSystemPrompt = ''
169
264
  }
170
265
  } catch (err) {
266
+ if (err instanceof DOMException && err.name === 'AbortError') {
267
+ throw err
268
+ }
171
269
  if (err instanceof Error && err.message.startsWith('Sub-agent')) {
172
270
  throw err
173
271
  }
@@ -12,6 +12,7 @@ export interface AgentFrontmatter {
12
12
  maxTurns?: number
13
13
  skills?: string
14
14
  background?: boolean
15
+ memory?: 'user' | 'project' | 'local' // agent memory scope
15
16
  }
16
17
 
17
18
  export interface AgentDefinition {
@@ -27,6 +28,7 @@ export interface AgentDefinition {
27
28
  background: boolean
28
29
  source: 'builtin' | 'project' | 'user'
29
30
  filePath?: string
31
+ memory?: 'user' | 'project' | 'local' // agent memory scope
30
32
  }
31
33
 
32
34
  export interface SubAgentOptions {
@@ -38,4 +40,8 @@ export interface SubAgentOptions {
38
40
  modelOverride?: string
39
41
  /** Maximum tool-calling turns (default: 5) to prevent infinite loops. */
40
42
  maxTurns?: number
43
+ /** When true, execute in background and return immediately with a task ID. */
44
+ runInBackground?: boolean
45
+ /** Optional callback for streaming progress chunks during background execution. */
46
+ onProgress?: (chunk: string) => void
41
47
  }
@@ -200,6 +200,16 @@ export class AgentViewManager {
200
200
  return counts
201
201
  }
202
202
 
203
+ /**
204
+ * Rename a session's title. Returns true if found and renamed.
205
+ */
206
+ rename(id: string, newTitle: string): boolean {
207
+ const session = this.sessions.get(id)
208
+ if (!session) return false
209
+ session.title = newTitle
210
+ return true
211
+ }
212
+
203
213
  /**
204
214
  * Remove all completed and failed sessions (cleanup).
205
215
  */
@@ -30,6 +30,7 @@ const STATUS_HEADERS: Record<string, { label: string; color: string }> = {
30
30
  export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps) {
31
31
  const [selectedIndex, setSelectedIndex] = useState(0)
32
32
  const [peekingSessionId, setPeekingSessionId] = useState<string | null>(null)
33
+ const [groupBy, setGroupBy] = useState<'status' | 'directory'>('status')
33
34
 
34
35
  // Build a flat list of sessions in group order, with group headers
35
36
  const flatList = useMemo(() => {
@@ -95,6 +96,22 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
95
96
  return
96
97
  }
97
98
 
99
+ // Ctrl+S — toggle group by (status ↔ directory)
100
+ if (input === '\x13') {
101
+ setGroupBy((prev) => (prev === 'status' ? 'directory' : 'status'))
102
+ return
103
+ }
104
+
105
+ // Ctrl+R — rename selected session
106
+ if (input === '\x12') {
107
+ if (sessionsOnly.length === 0) return
108
+ const current = sessionsOnly[selectedIndex]
109
+ if (!current) return
110
+ const newTitle = `session-${Date.now().toString(36)}`
111
+ manager.rename(current.session.id, newTitle)
112
+ return
113
+ }
114
+
98
115
  if (input === 'j') {
99
116
  setSelectedIndex((prev) => Math.min(prev + 1, sessionsOnly.length - 1))
100
117
  setPeekingSessionId(null)
@@ -163,7 +180,9 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
163
180
  </Text>
164
181
  </Box>
165
182
  <Box>
166
- <Text dimColor>j/k navigate · Space peek · Enter attach · Esc back</Text>
183
+ <Text dimColor>
184
+ j/k navigate · Space peek · Enter attach · Ctrl+S group · Ctrl+R rename · Esc back
185
+ </Text>
167
186
  </Box>
168
187
  </Box>
169
188