@miphamai/cli 0.36.0 → 0.36.2

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.36.0",
3
+ "version": "0.36.2",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -34,11 +34,14 @@ export interface AgentSession {
34
34
  branch?: string
35
35
  /** Created PR URL (if requested) */
36
36
  prUrl?: string
37
+ /** Working directory the session was spawned from (used for directory grouping). */
38
+ directory: string
37
39
  }
38
40
 
39
41
  export interface CreateSessionOptions {
40
42
  provider?: string
41
43
  model?: string
44
+ directory?: string
42
45
  }
43
46
 
44
47
  export interface SessionPeek {
@@ -48,6 +51,11 @@ export interface SessionPeek {
48
51
 
49
52
  export type StatusGroups = Record<SessionStatus, AgentSession[]>
50
53
 
54
+ export interface DirectoryGroup {
55
+ directory: string
56
+ sessions: AgentSession[]
57
+ }
58
+
51
59
  export class AgentViewManager {
52
60
  private sessions: Map<string, AgentSession> = new Map()
53
61
  private sessionOrder: string[] = []
@@ -66,6 +74,7 @@ export class AgentViewManager {
66
74
  provider: options.provider ?? 'unknown',
67
75
  model: options.model ?? 'unknown',
68
76
  task,
77
+ directory: options.directory ?? process.cwd(),
69
78
  createdAt: new Date(),
70
79
  elapsedMs: 0,
71
80
  messages: [],
@@ -109,6 +118,27 @@ export class AgentViewManager {
109
118
  return groups
110
119
  }
111
120
 
121
+ /**
122
+ * Group all sessions by their working directory (v2.1.229 session-grouping alignment).
123
+ * Returns groups sorted alphabetically by directory path.
124
+ */
125
+ groupByDirectory(): DirectoryGroup[] {
126
+ const groups = new Map<string, AgentSession[]>()
127
+
128
+ for (const id of this.sessionOrder) {
129
+ const session = this.sessions.get(id)
130
+ if (!session) continue
131
+ const dir = session.directory || '(unknown)'
132
+ const existing = groups.get(dir)
133
+ if (existing) existing.push(session)
134
+ else groups.set(dir, [session])
135
+ }
136
+
137
+ return Array.from(groups.entries())
138
+ .map(([directory, sessions]) => ({ directory, sessions }))
139
+ .sort((a, b) => a.directory.localeCompare(b.directory))
140
+ }
141
+
112
142
  /**
113
143
  * Get a session by ID.
114
144
  */
@@ -30,7 +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
+ const [groupBy, setGroupBy] = useState<'status' | 'directory'>('status')
34
34
  const [feedback, setFeedback] = useState<string | null>(null)
35
35
 
36
36
  // Flash a brief feedback message that auto-clears
@@ -41,37 +41,53 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
41
41
 
42
42
  // Build a flat list of sessions in group order, with group headers
43
43
  const flatList = useMemo(() => {
44
- const groups = manager.groupByStatus()
45
44
  const result: Array<
46
- | { type: 'header'; status: string; label: string; color: string; count: number }
45
+ | { type: 'header'; key: string; label: string; color: string; count: number }
47
46
  | { type: 'session'; session: AgentSession }
48
47
  > = []
49
48
 
50
- const statusOrder: Array<keyof typeof STATUS_HEADERS> = [
51
- 'working',
52
- 'needs-input',
53
- 'completed',
54
- 'failed',
55
- ]
49
+ if (groupBy === 'directory') {
50
+ for (const group of manager.groupByDirectory()) {
51
+ result.push({
52
+ type: 'header',
53
+ key: `dir-${group.directory}`,
54
+ label: group.directory,
55
+ color: 'blue',
56
+ count: group.sessions.length,
57
+ })
56
58
 
57
- for (const _status of statusOrder) {
58
- const status = _status as SessionStatus
59
- const sessions = groups[status] ?? []
60
- result.push({
61
- type: 'header',
62
- status,
63
- label: STATUS_HEADERS[status]!.label,
64
- color: STATUS_HEADERS[status]!.color,
65
- count: sessions.length,
66
- })
59
+ for (const session of group.sessions) {
60
+ result.push({ type: 'session', session })
61
+ }
62
+ }
63
+ } else {
64
+ const groups = manager.groupByStatus()
65
+ const statusOrder: Array<keyof typeof STATUS_HEADERS> = [
66
+ 'working',
67
+ 'needs-input',
68
+ 'completed',
69
+ 'failed',
70
+ ]
71
+
72
+ for (const _status of statusOrder) {
73
+ const status = _status as SessionStatus
74
+ const sessions = groups[status] ?? []
75
+ result.push({
76
+ type: 'header',
77
+ key: `status-${status}`,
78
+ label: STATUS_HEADERS[status]!.label,
79
+ color: STATUS_HEADERS[status]!.color,
80
+ count: sessions.length,
81
+ })
67
82
 
68
- for (const session of sessions) {
69
- result.push({ type: 'session', session })
83
+ for (const session of sessions) {
84
+ result.push({ type: 'session', session })
85
+ }
70
86
  }
71
87
  }
72
88
 
73
89
  return result
74
- }, [manager])
90
+ }, [manager, groupBy])
75
91
 
76
92
  // Flatten sessions only for navigation (skip headers)
77
93
  const sessionsOnly = useMemo(
@@ -106,7 +122,7 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
106
122
  // Ctrl+T — toggle group by (status ↔ directory)
107
123
  if (key.ctrl && input === 't') {
108
124
  setGroupBy((prev) => (prev === 'status' ? 'directory' : 'status'))
109
- showFeedback(`Grouped by ${_groupBy === 'status' ? 'directory' : 'status'}`)
125
+ showFeedback(`Grouped by ${groupBy === 'status' ? 'directory' : 'status'}`)
110
126
  return
111
127
  }
112
128
 
@@ -238,7 +254,7 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
238
254
  {flatList.map((item, _flatIdx) => {
239
255
  if (item.type === 'header') {
240
256
  return (
241
- <Box key={`h-${item.status}`} marginY={1}>
257
+ <Box key={`h-${item.key}`} marginY={1}>
242
258
  <Text bold color={item.color}>
243
259
  {' '}
244
260
  {item.label} ({item.count})
@@ -377,6 +377,10 @@ export class QueryEngine {
377
377
  }
378
378
 
379
379
  async *process(userInput: string, signal?: AbortSignal): AsyncGenerator<StreamChunk> {
380
+ // v2.1.229 alignment: reject whitespace-only messages before they reach the
381
+ // provider API (which would otherwise 400 on an empty/blank message).
382
+ if (!userInput.trim()) return
383
+
380
384
  // Poll for cross-session messages before each turn
381
385
  await this.pollCrossSessionInbox()
382
386
 
@@ -442,13 +446,12 @@ export class QueryEngine {
442
446
 
443
447
  // Stream model response
444
448
  try {
445
- for await (const chunk of this.registry.chat({
446
- model: this.registry.getActiveModel(),
449
+ for await (const chunk of this.chatWithFallback(
447
450
  messages,
448
451
  systemPrompt,
449
- tools: toolDefs.length > 0 ? toolDefs : undefined,
452
+ toolDefs.length > 0 ? toolDefs : undefined,
450
453
  signal,
451
- })) {
454
+ )) {
452
455
  yield chunk
453
456
 
454
457
  if (chunk.type === 'error') {
@@ -1140,6 +1143,80 @@ export class QueryEngine {
1140
1143
  return this.registry
1141
1144
  }
1142
1145
 
1146
+ /**
1147
+ * Stream a chat response with graceful provider fallback (v2.1.229 alignment).
1148
+ *
1149
+ * On a connection/availability failure from the active provider (thrown
1150
+ * network error or an error chunk), switches to the configured default
1151
+ * provider and retries once, yielding a `warning` chunk so the UI can tell
1152
+ * the user the provider degraded. Abort errors propagate untouched.
1153
+ */
1154
+ private async *chatWithFallback(
1155
+ messages: import('../shared/types').Message[],
1156
+ systemPrompt: string,
1157
+ toolDefs: Record<string, unknown>[] | undefined,
1158
+ signal?: AbortSignal,
1159
+ ): AsyncGenerator<StreamChunk> {
1160
+ const activeId = this.registry.getActive().config.id
1161
+ const defaultId = this.registry.getDefaultProviderId()
1162
+
1163
+ // ── Attempt 1: active provider ──
1164
+ let failure: string | null = null
1165
+ try {
1166
+ for await (const chunk of this.registry.chat({
1167
+ model: this.registry.getActiveModel(),
1168
+ messages,
1169
+ systemPrompt,
1170
+ tools: toolDefs,
1171
+ signal,
1172
+ })) {
1173
+ if (chunk.type === 'error') {
1174
+ failure = chunk.error ?? 'Unknown error'
1175
+ break
1176
+ }
1177
+ yield chunk
1178
+ }
1179
+ if (failure === null) return
1180
+ } catch (err) {
1181
+ if (isAbortError(err)) throw err
1182
+ failure = String(err)
1183
+ }
1184
+
1185
+ // ── Fallback: configured default provider, once ──
1186
+ if (!defaultId || defaultId === activeId || !this.registry.get(defaultId)) {
1187
+ yield { type: 'error', error: failure }
1188
+ return
1189
+ }
1190
+ const fallbackModel = this.registry
1191
+ .get(defaultId)!
1192
+ .config.models.find((m) => m.status === 'active')?.id
1193
+ if (!fallbackModel) {
1194
+ yield { type: 'error', error: failure }
1195
+ return
1196
+ }
1197
+
1198
+ this.registry.switchProvider(defaultId, fallbackModel)
1199
+ yield {
1200
+ type: 'warning',
1201
+ content: `${activeId} unreachable — degraded to ${defaultId} (${fallbackModel})`,
1202
+ }
1203
+
1204
+ try {
1205
+ for await (const chunk of this.registry.chat({
1206
+ model: fallbackModel,
1207
+ messages,
1208
+ systemPrompt,
1209
+ tools: toolDefs,
1210
+ signal,
1211
+ })) {
1212
+ yield chunk
1213
+ }
1214
+ } catch (err) {
1215
+ if (isAbortError(err)) throw err
1216
+ yield { type: 'error', error: String(err) }
1217
+ }
1218
+ }
1219
+
1143
1220
  getTools(): Map<string, ToolDefinition> {
1144
1221
  return this.tools
1145
1222
  }
@@ -9,6 +9,18 @@ import type { PermissionRuleEntry } from '../shared/index.ts'
9
9
  import { matchBashRule, compileRule } from './permission-rules'
10
10
  import { loadPermissionConfig, nextMode, clampMode, MODE_CYCLE } from './permission-config'
11
11
 
12
+ /**
13
+ * Check if a Bash command is a git/gh operation using a destructive flag
14
+ * (--force, --amend, --no-verify, --hard) that must never be auto-approved.
15
+ * v2.1.229 alignment: these rewrite history / force-push, so they fall back
16
+ * to explicit user confirmation even in auto mode.
17
+ */
18
+ function hasDangerousGitFlags(input: Record<string, unknown>): boolean {
19
+ const cmd = (input.command as string) || ''
20
+ if (!/^(git|gh)\s+/.test(cmd.trim())) return false
21
+ return /--force(?!-with-lease)\b|--amend\b|--no-verify\b|--hard\b/.test(cmd)
22
+ }
23
+
12
24
  /**
13
25
  * Check if a Bash command is a "verification-only" command that should be
14
26
  * auto-approved in acceptEdits mode. These are non-destructive read/check
@@ -414,6 +426,10 @@ export class PermissionSystem {
414
426
  if (this.isSubAgent && ['Bash', 'Write', 'Edit'].includes(tool.name)) {
415
427
  return 'ask'
416
428
  }
429
+ // v2.1.229 alignment: destructive git/gh flags are never auto-approved.
430
+ if (tool.name === 'Bash' && input && hasDangerousGitFlags(input)) {
431
+ return 'ask'
432
+ }
417
433
  return 'bypass'
418
434
 
419
435
  case 'dontAsk':
@@ -8,9 +8,13 @@ export class OpenAICompatProvider implements ProviderInstance {
8
8
  constructor(public config: ProviderConfig) {}
9
9
 
10
10
  async *chat(req: ChatRequest): AsyncGenerator<StreamChunk> {
11
- // Accept both baseUrl and baseURL (common YAML typo)
11
+ // Accept both baseUrl and baseURL (common YAML typo); resolve env templates
12
12
  const rawBase = (this.config as any).baseUrl || (this.config as any).baseURL
13
- const baseUrl = rawBase?.replace(/\/+$/, '') || 'https://api.openai.com/v1'
13
+ const baseUrl =
14
+ this.resolveEnvTemplate(
15
+ rawBase?.replace(/\/+$/, '') || '',
16
+ 'OpenAI-compatible provider: baseUrl',
17
+ ) || 'https://api.openai.com/v1'
14
18
  const apiKey = this.resolveApiKey(this.config.apiKey)
15
19
 
16
20
  const body = {
@@ -224,11 +228,18 @@ export class OpenAICompatProvider implements ProviderInstance {
224
228
  async healthCheck(): Promise<boolean> {
225
229
  try {
226
230
  const rawBase = (this.config as any).baseUrl || (this.config as any).baseURL
227
- const baseUrl = rawBase?.replace(/\/+$/, '') || 'https://api.openai.com/v1'
231
+ const baseUrl =
232
+ this.resolveEnvTemplate(
233
+ rawBase?.replace(/\/+$/, '') || '',
234
+ 'OpenAI-compatible provider: baseUrl',
235
+ ) || 'https://api.openai.com/v1'
228
236
  const apiKey = this.resolveApiKey(this.config.apiKey)
229
- const res = await fetch(`${baseUrl}/models`, {
230
- headers: { Authorization: `Bearer ${apiKey}` },
231
- })
237
+ // 5s timeout so a down endpoint doesn't hang health checks (v2.1.229 alignment)
238
+ const res = await fetchWithRetry(
239
+ `${baseUrl}/models`,
240
+ { headers: { Authorization: `Bearer ${apiKey}` } },
241
+ { timeout: 5000, maxRetries: 0 },
242
+ )
232
243
  return res.ok
233
244
  } catch {
234
245
  return false
@@ -366,21 +377,25 @@ export class OpenAICompatProvider implements ProviderInstance {
366
377
  }
367
378
  }
368
379
 
369
- private resolveApiKey(keyTemplate: string): string {
370
- // Accept both ${VAR} and $VAR syntax
371
- let match = keyTemplate.match(/^\$\{(.+)\}$/)
372
- if (!match) match = keyTemplate.match(/^\$([A-Z_][A-Z0-9_]*)$/)
380
+ /** Resolve a `${VAR}` / `$VAR` template against the environment. */
381
+ private resolveEnvTemplate(value: string, warnPrefix: string): string {
382
+ let match = value.match(/^\$\{(.+)\}$/)
383
+ if (!match) match = value.match(/^\$([A-Z_][A-Z0-9_]*)$/)
373
384
  if (match?.[1]) {
374
385
  const varName = match[1]
375
- const value = process.env[varName]
376
- if (!value) {
386
+ const envValue = process.env[varName]
387
+ if (!envValue) {
377
388
  process.stderr.write(
378
- `⚠ OpenAI-compatible provider: apiKey references $${varName} but that environment variable is not set\n`,
389
+ `⚠ ${warnPrefix} references $${varName} but that environment variable is not set\n`,
379
390
  )
380
391
  return ''
381
392
  }
382
- return value
393
+ return envValue
383
394
  }
384
- return keyTemplate
395
+ return value
396
+ }
397
+
398
+ private resolveApiKey(keyTemplate: string): string {
399
+ return this.resolveEnvTemplate(keyTemplate, 'OpenAI-compatible provider: apiKey')
385
400
  }
386
401
  }
@@ -21,10 +21,24 @@ export class ProviderRegistry {
21
21
  private providers = new Map<string, ProviderInstance>()
22
22
  private activeProviderId: string
23
23
  private activeModelId: string
24
+ private defaultProviderId: string
25
+ private defaultModelId: string
24
26
 
25
27
  constructor(providers: ProviderConfig[], defaultProvider: string, defaultModel: string) {
26
28
  this.activeProviderId = defaultProvider
27
29
  this.activeModelId = defaultModel
30
+ this.defaultProviderId = defaultProvider
31
+ this.defaultModelId = defaultModel
32
+ }
33
+
34
+ /** The configured default provider id (used for fallback routing). */
35
+ getDefaultProviderId(): string {
36
+ return this.defaultProviderId
37
+ }
38
+
39
+ /** The configured default model id. */
40
+ getDefaultModelId(): string {
41
+ return this.defaultModelId
28
42
  }
29
43
 
30
44
  register(id: string, instance: ProviderInstance): void {
@@ -72,6 +86,32 @@ export class ProviderRegistry {
72
86
  return undefined
73
87
  }
74
88
 
89
+ /**
90
+ * Check a single provider's health. Returns undefined if not registered,
91
+ * otherwise the provider's healthCheck() result. Never throws.
92
+ */
93
+ async healthStatus(id: string): Promise<boolean | undefined> {
94
+ const provider = this.providers.get(id)
95
+ if (!provider) return undefined
96
+ try {
97
+ return await provider.healthCheck()
98
+ } catch {
99
+ return false
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Health of all registered providers, checked concurrently.
105
+ * Returns a Map of provider id → reachable boolean.
106
+ */
107
+ async healthMap(): Promise<Map<string, boolean>> {
108
+ const ids = this.listIds()
109
+ const results = await Promise.all(
110
+ ids.map(async (id) => [id, (await this.healthStatus(id)) ?? false] as const),
111
+ )
112
+ return new Map(results)
113
+ }
114
+
75
115
  async *chat(req: ChatRequest): AsyncGenerator<StreamChunk> {
76
116
  const provider = this.getActive()
77
117
  yield* provider.chat({ ...req, model: req.model || this.activeModelId })
@@ -129,6 +129,7 @@ export interface StreamChunk {
129
129
  | 'thinking'
130
130
  | 'stop'
131
131
  | 'error'
132
+ | 'warning'
132
133
  | 'task_notification'
133
134
  | 'usage'
134
135
  content?: string
package/src/ui/app.tsx CHANGED
@@ -658,6 +658,17 @@ export function App({
658
658
  ])
659
659
  }
660
660
 
661
+ if (chunk.type === 'warning' && chunk.content) {
662
+ setMessages((prev) => [...prev, { role: 'system', content: `⚠ ${chunk.content}` }])
663
+ // Keep the footer's provider/model in sync after an automatic fallback
664
+ // switch. RemoteEngine's registry is a stub without getActive.
665
+ const registry = engine.getRegistry()
666
+ if ('getActive' in registry) {
667
+ setProviderId(registry.getActive().config.id)
668
+ setModelId(registry.getActiveModel())
669
+ }
670
+ }
671
+
661
672
  if (chunk.type === 'task_notification' && chunk.taskNotification) {
662
673
  const tn = chunk.taskNotification
663
674
  const isDone = tn.status === 'completed'
@@ -317,12 +317,20 @@ const contextCmd: CommandHandler = (ctx) => {
317
317
  }
318
318
  }
319
319
 
320
- const statusCmd: CommandHandler = (ctx) => {
320
+ const statusCmd: CommandHandler = async (ctx) => {
321
321
  const t = resolveT(ctx)
322
322
  const c = ctx.engine.getContext()
323
323
  const tools = ctx.engine.getTools()
324
324
  const runtime = typeof Bun !== 'undefined' ? 'Bun' : 'Node.js'
325
325
  const runtimeVer = typeof Bun !== 'undefined' ? Bun.version : process.version
326
+ const health = ctx.engine.getRegistry()
327
+ ? await ctx.engine.getRegistry().healthMap()
328
+ : new Map<string, boolean>()
329
+ const providerHealth = ctx.config.providers
330
+ .filter((p) => p.status !== 'upcoming')
331
+ .map((p) => `${p.id} ${health.get(p.id) ? '🟢' : '🔴'}`)
332
+ .join(' ')
333
+
326
334
  return {
327
335
  content: stripIndent`
328
336
  ${t('commands.status.session_title')}
@@ -337,6 +345,9 @@ const statusCmd: CommandHandler = (ctx) => {
337
345
  ${t('commands.status.platform')} ${process.platform} ${process.arch}
338
346
  ${t('commands.status.runtime')} ${runtime} ${runtimeVer}
339
347
  ${t('commands.status.cwd')} ${process.cwd()}
348
+
349
+ Providers
350
+ ${providerHealth}
340
351
  `,
341
352
  }
342
353
  }
@@ -449,12 +460,19 @@ const providerCmd: CommandHandler = (ctx) => {
449
460
  }
450
461
  }
451
462
 
452
- const providersCmd: CommandHandler = (ctx) => {
463
+ const providersCmd: CommandHandler = async (ctx) => {
453
464
  const t = resolveT(ctx)
454
- const lines = ctx.config.providers.map(
455
- (p) =>
456
- ` ${p.id.padEnd(14)} ${p.name.padEnd(20)} ${p.protocol.padEnd(18)} ${p.models.length} models ${p.status === 'upcoming' ? '[upcoming]' : '✓'}`,
457
- )
465
+ const health = ctx.engine.getRegistry()
466
+ ? await ctx.engine.getRegistry().healthMap()
467
+ : new Map<string, boolean>()
468
+
469
+ const lines = ctx.config.providers.map((p) => {
470
+ if (p.status === 'upcoming') {
471
+ return ` ${p.id.padEnd(14)} ${p.name.padEnd(20)} ${p.protocol.padEnd(18)} ${p.models.length} models ⏳ [upcoming]`
472
+ }
473
+ const ok = health.get(p.id) ?? false
474
+ return ` ${p.id.padEnd(14)} ${p.name.padEnd(20)} ${p.protocol.padEnd(18)} ${p.models.length} models ${ok ? '🟢 reachable' : '🔴 unreachable'}`
475
+ })
458
476
  return {
459
477
  content: `${t('commands.providers.title')}\n\n${lines.join('\n')}\n\n${t('commands.providers.current', { provider: ctx.providerId, model: ctx.modelId })}`,
460
478
  }
@@ -1,11 +1,29 @@
1
+ import os from 'node:os'
2
+
1
3
  /**
2
4
  * parallel() — barrier: executes all thunks concurrently, waits for all.
3
5
  * Failed thunks resolve to null. Never throws.
4
6
  *
5
- * P0-2 (v2.1.223 alignment): Added concurrency cap (MAX_CONCURRENT = 16)
6
- * to prevent resource exhaustion from unbounded fan-out.
7
+ * P0-2 (v2.1.223 alignment): Added concurrency cap (MAX_CONCURRENT) to prevent
8
+ * resource exhaustion from unbounded fan-out.
9
+ *
10
+ * v2.1.229 alignment: detect parallelism via os.availableParallelism() so
11
+ * CPU-limited containers (cgroup quota) don't fan out to the host core count.
12
+ * Still capped at 16 to bound resource usage, with a floor of 1.
7
13
  */
8
- const MAX_CONCURRENT = 16
14
+ function detectParallelism(): number {
15
+ try {
16
+ if (typeof os.availableParallelism === 'function') {
17
+ const p = os.availableParallelism()
18
+ if (typeof p === 'number' && p > 0) return p
19
+ }
20
+ } catch {
21
+ /* fall through to cpus() */
22
+ }
23
+ return os.cpus().length || 1
24
+ }
25
+
26
+ const MAX_CONCURRENT = Math.max(1, Math.min(16, detectParallelism()))
9
27
 
10
28
  /**
11
29
  * Simple async semaphore for concurrency limiting.