@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 +1 -1
- package/src/agent-view/agent-view-manager.ts +30 -0
- package/src/agent-view/dashboard.tsx +40 -24
- package/src/core/engine.ts +81 -4
- package/src/core/permission.ts +16 -0
- package/src/providers/openai-compat.ts +30 -15
- package/src/providers/registry.ts +40 -0
- package/src/shared/types.ts +1 -0
- package/src/ui/app.tsx +11 -0
- package/src/ui/commands.ts +24 -6
- package/src/workflow/primitives/parallel.ts +21 -3
package/package.json
CHANGED
|
@@ -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 [
|
|
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';
|
|
45
|
+
| { type: 'header'; key: string; label: string; color: string; count: number }
|
|
47
46
|
| { type: 'session'; session: AgentSession }
|
|
48
47
|
> = []
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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 ${
|
|
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.
|
|
257
|
+
<Box key={`h-${item.key}`} marginY={1}>
|
|
242
258
|
<Text bold color={item.color}>
|
|
243
259
|
{' '}
|
|
244
260
|
{item.label} ({item.count})
|
package/src/core/engine.ts
CHANGED
|
@@ -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.
|
|
446
|
-
model: this.registry.getActiveModel(),
|
|
449
|
+
for await (const chunk of this.chatWithFallback(
|
|
447
450
|
messages,
|
|
448
451
|
systemPrompt,
|
|
449
|
-
|
|
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
|
}
|
package/src/core/permission.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
let match =
|
|
372
|
-
if (!match) match =
|
|
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
|
|
376
|
-
if (!
|
|
386
|
+
const envValue = process.env[varName]
|
|
387
|
+
if (!envValue) {
|
|
377
388
|
process.stderr.write(
|
|
378
|
-
`⚠
|
|
389
|
+
`⚠ ${warnPrefix} references $${varName} but that environment variable is not set\n`,
|
|
379
390
|
)
|
|
380
391
|
return ''
|
|
381
392
|
}
|
|
382
|
-
return
|
|
393
|
+
return envValue
|
|
383
394
|
}
|
|
384
|
-
return
|
|
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 })
|
package/src/shared/types.ts
CHANGED
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'
|
package/src/ui/commands.ts
CHANGED
|
@@ -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
|
|
455
|
-
(
|
|
456
|
-
|
|
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
|
|
6
|
-
*
|
|
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
|
-
|
|
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.
|