@miphamai/cli 0.36.1 → 0.37.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.36.1",
3
+ "version": "0.37.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -73,7 +73,7 @@ export class AgentRegistry {
73
73
  .split(',')
74
74
  .map((s) => s.trim())
75
75
  : undefined,
76
- background: (data.background as boolean) || false,
76
+ background: typeof data.background === 'boolean' ? data.background : undefined,
77
77
  memory: data.memory as 'user' | 'project' | 'local' | undefined,
78
78
  source,
79
79
  filePath: fullPath,
@@ -125,7 +125,6 @@ export class AgentRegistry {
125
125
  systemPrompt: BUILTIN_SYSTEM_PROMPTS[builtinType],
126
126
  model: 'inherit',
127
127
  permissionMode: 'inherit',
128
- background: false,
129
128
  source: 'builtin',
130
129
  }
131
130
  }
@@ -2,6 +2,7 @@ import { getMessageBus } from './message-bus'
2
2
  import { getFileInboxTransport } from './cross-session/file-inbox'
3
3
  import { discoverSessions, createSessionInfo } from './cross-session/discovery'
4
4
  import type { AgentMessage } from './message-bus'
5
+ import type { SessionInfo } from '../shared/types'
5
6
 
6
7
  export interface RouteResult {
7
8
  success: boolean
@@ -10,6 +11,35 @@ export interface RouteResult {
10
11
  messageId?: string
11
12
  }
12
13
 
14
+ export interface SessionResolution {
15
+ session?: SessionInfo
16
+ error?: string
17
+ }
18
+
19
+ /**
20
+ * Resolve a recipient among live sessions by bare name — session ID first,
21
+ * then session name. A name must uniquely match exactly one session; ambiguity
22
+ * and non-matches are reported as errors.
23
+ */
24
+ export function resolveRecipientSession(sessions: SessionInfo[], to: string): SessionResolution {
25
+ const byId = sessions.find((s) => s.id === to)
26
+ if (byId) return { session: byId }
27
+
28
+ const byName = sessions.filter((s) => s.name === to)
29
+ if (byName.length === 1) return { session: byName[0] }
30
+ if (byName.length > 1) {
31
+ return {
32
+ error:
33
+ `Ambiguous session name "${to}" matches ${byName.length} live sessions. ` +
34
+ `Use a session ID to disambiguate (ListAgents).`,
35
+ }
36
+ }
37
+
38
+ return {
39
+ error: `No active session found matching "${to}". Use ListAgents to discover available sessions.`,
40
+ }
41
+ }
42
+
13
43
  /**
14
44
  * MessageRouter decides how to deliver a message based on the recipient.
15
45
  *
@@ -33,17 +63,14 @@ export class MessageRouter {
33
63
  }
34
64
  }
35
65
 
36
- // Cross-session routing
66
+ // Cross-session routing (by session ID or bare name)
37
67
  const sessions = discoverSessions()
38
- const targetSession = sessions.find((s) => s.id === to)
68
+ const resolution = resolveRecipientSession(sessions, to)
39
69
 
40
- if (!targetSession) {
41
- return {
42
- success: false,
43
- routedTo: 'unknown',
44
- error: `No active session found with ID "${to}". Use ListAgents to discover available sessions.`,
45
- }
70
+ if (resolution.error) {
71
+ return { success: false, routedTo: 'unknown', error: resolution.error }
46
72
  }
73
+ const targetSession = resolution.session!
47
74
 
48
75
  // Get sender info
49
76
  const senderSession = createSessionInfo(from, from)
@@ -53,7 +80,7 @@ export class MessageRouter {
53
80
  const msg: AgentMessage = {
54
81
  id: `xmsg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
55
82
  from,
56
- to,
83
+ to: targetSession.id,
57
84
  summary: summary.slice(0, 200),
58
85
  message,
59
86
  timestamp: new Date(),
@@ -61,7 +88,7 @@ export class MessageRouter {
61
88
  type: 'message',
62
89
  }
63
90
 
64
- const delivered = await transport.send(senderSession, to, msg)
91
+ const delivered = await transport.send(senderSession, targetSession.id, msg)
65
92
  if (!delivered) {
66
93
  return { success: false, routedTo: 'inbox', error: 'Failed to write message to inbox.' }
67
94
  }
@@ -270,6 +270,13 @@ export class SubAgent {
270
270
  )
271
271
 
272
272
  context.setSystemPrompt(systemPrompt)
273
+
274
+ // Seed inherited parent conversation (fork inheritance) as a byte-identical
275
+ // prefix so the provider prompt cache is reused.
276
+ if (options.inheritContext && options.inheritContext.messages.length > 0) {
277
+ context.seedMessages(options.inheritContext.messages)
278
+ }
279
+
273
280
  context.addMessage({ role: 'user', content: prompt })
274
281
 
275
282
  const messages = context.getMessages()
@@ -1,5 +1,7 @@
1
1
  // apps/cli/src/agent/types.ts
2
2
 
3
+ import type { Message } from '../shared/index.ts'
4
+
3
5
  export type SubAgentType = 'general' | 'explore' | 'plan' | 'code-review'
4
6
 
5
7
  export interface AgentFrontmatter {
@@ -26,7 +28,8 @@ export interface AgentDefinition {
26
28
  permissionMode: string
27
29
  maxTurns?: number
28
30
  skills?: string[]
29
- background: boolean
31
+ /** Force background (true) or sync (false) execution. Unset inherits the tool default. */
32
+ background?: boolean
30
33
  source: 'builtin' | 'project' | 'user'
31
34
  filePath?: string
32
35
  memory?: 'user' | 'project' | 'local' // agent memory scope
@@ -47,6 +50,8 @@ export interface SubAgentOptions {
47
50
  onProgress?: (chunk: string) => void
48
51
  /** When set, tool executions use this path as cwd (git worktree isolation). */
49
52
  worktreePath?: string
53
+ /** Seed the sub-agent with a parent conversation prefix (e.g., fork inheritance). */
54
+ inheritContext?: { messages: Message[] }
50
55
  /** CRSI: when false, skip pattern analysis after agent execution. Default true. */
51
56
  autoPatternAnalysis?: boolean
52
57
  }
@@ -12,6 +12,8 @@ export interface CacheTracker {
12
12
  isInCache(msg: Message): boolean
13
13
  /** Returns a snapshot of the cache state for metrics / logging. */
14
14
  getStatus(): CacheStatus
15
+ /** Record the set of messages currently held in the provider prompt cache. */
16
+ markCached?(messages: Message[]): void
15
17
  /** Clear the tracker state (does NOT evict from provider cache). */
16
18
  invalidate(): void
17
19
  }
@@ -41,11 +43,53 @@ export class NoopCacheTracker implements CacheTracker {
41
43
  this.messageCount = messages.length
42
44
  }
43
45
 
46
+ markCached(_messages: Message[]): void {
47
+ // No-op — prompt caching is disabled with this tracker.
48
+ }
49
+
44
50
  invalidate(): void {
45
51
  this.messageCount = 0
46
52
  }
47
53
  }
48
54
 
55
+ /**
56
+ * Tracks which messages are currently held in the provider's prompt cache.
57
+ * The engine informs it of the cached prefix after each successful request
58
+ * (Anthropic caches "all messages except the newest"; DeepSeek/OpenAI auto-cache
59
+ * the same prefix). Object-identity based, so it stays valid across the
60
+ * ContextManager's shallow-copied message arrays.
61
+ */
62
+ export class PrefixCacheTracker implements CacheTracker {
63
+ private cachedMessages = new WeakSet<Message>()
64
+ private cachedCount = 0
65
+ private cachedTokens = 0
66
+
67
+ markCached(messages: Message[]): void {
68
+ this.cachedMessages = new WeakSet(messages)
69
+ this.cachedCount = messages.length
70
+ this.cachedTokens = messages.reduce((sum, m) => sum + estimateMessageTokens(m), 0)
71
+ }
72
+
73
+ isInCache(msg: Message): boolean {
74
+ return this.cachedMessages.has(msg)
75
+ }
76
+
77
+ getStatus(): CacheStatus {
78
+ return {
79
+ totalMessages: this.cachedCount,
80
+ cachedMessages: this.cachedCount,
81
+ cachedTokens: this.cachedTokens,
82
+ uncachedTokens: 0,
83
+ }
84
+ }
85
+
86
+ invalidate(): void {
87
+ this.cachedMessages = new WeakSet()
88
+ this.cachedCount = 0
89
+ this.cachedTokens = 0
90
+ }
91
+ }
92
+
49
93
  /**
50
94
  * Estimate the token count for a single message.
51
95
  *
@@ -116,6 +116,17 @@ export class ContextManager {
116
116
  this.checkCompression()
117
117
  }
118
118
 
119
+ /**
120
+ * Seed a batch of pre-existing messages (e.g., an inherited parent
121
+ * conversation). Unlike addMessage, this does not trigger compaction so the
122
+ * byte-identical prefix is preserved for prompt-cache hits.
123
+ */
124
+ seedMessages(messages: Message[]): void {
125
+ if (messages.length === 0) return
126
+ this.messages.push(...messages)
127
+ this.reEstimateTokens()
128
+ }
129
+
119
130
  getMessages(): Message[] {
120
131
  return [...this.messages]
121
132
  }
@@ -252,6 +263,11 @@ export class ContextManager {
252
263
  this.cacheTracker = tracker
253
264
  }
254
265
 
266
+ /** Mark a set of messages as cached by the provider (called after a request). */
267
+ markCached(messages: Message[]): void {
268
+ this.cacheTracker.markCached?.(messages)
269
+ }
270
+
255
271
  /** Get a snapshot of the provider prompt-cache state. */
256
272
  getCacheStatus(): CacheStatus {
257
273
  return this.cacheTracker.getStatus()
@@ -1,6 +1,15 @@
1
1
  import { CREDENTIAL_SENTINEL } from './types'
2
2
  import type { CredentialMaskingConfig } from '../../shared/index.ts'
3
3
 
4
+ /**
5
+ * Well-known secret token prefixes. These are high-signal formats that must
6
+ * never leak into output, independent of the configured patterns.
7
+ * - GitHub: ghp_ (personal access), gho_ (OAuth), ghs_ (server), ghu_/ghr_ (user/server-to-server), github_pat_ (fine-grained)
8
+ * - GitLab: glpat- (personal access), gldt- (deploy), glrt- (runner), gloas- (OAuth app)
9
+ */
10
+ const TOKEN_REDACTION_PATTERN =
11
+ /\b(?:ghp_|gho_|ghs_|ghu_|ghr_|github_pat_|glpat-|gldt-|glrt-|gloas-)[A-Za-z0-9_-]{8,}/g
12
+
4
13
  /**
5
14
  * Scrub credential patterns from stdout/stderr output.
6
15
  * Uses the configured output_scrubbing patterns to detect and replace
@@ -10,6 +19,10 @@ export function maskOutput(output: string, config: CredentialMaskingConfig): str
10
19
  if (!config.enabled || !config.output_scrubbing.enabled) return output
11
20
 
12
21
  let masked = output
22
+
23
+ // Redact bare secret tokens by prefix (always on, independent of config patterns).
24
+ masked = masked.replace(TOKEN_REDACTION_PATTERN, CREDENTIAL_SENTINEL)
25
+
13
26
  for (const pattern of config.output_scrubbing.patterns) {
14
27
  try {
15
28
  // Strip (?i) inline flags — JS uses the 'i' flag instead
@@ -446,13 +446,12 @@ export class QueryEngine {
446
446
 
447
447
  // Stream model response
448
448
  try {
449
- for await (const chunk of this.registry.chat({
450
- model: this.registry.getActiveModel(),
449
+ for await (const chunk of this.chatWithFallback(
451
450
  messages,
452
451
  systemPrompt,
453
- tools: toolDefs.length > 0 ? toolDefs : undefined,
452
+ toolDefs.length > 0 ? toolDefs : undefined,
454
453
  signal,
455
- })) {
454
+ )) {
456
455
  yield chunk
457
456
 
458
457
  if (chunk.type === 'error') {
@@ -533,6 +532,12 @@ export class QueryEngine {
533
532
  return
534
533
  }
535
534
 
535
+ // Record the provider's cached prefix (all messages except the newest)
536
+ // for cache-aware microcompaction.
537
+ if (messages.length >= 2) {
538
+ this.context.markCached(messages.slice(0, -1))
539
+ }
540
+
536
541
  // Track last assistant content for goal checking
537
542
  if (assistantContent) {
538
543
  this.lastAssistantContent = assistantContent
@@ -1144,6 +1149,80 @@ export class QueryEngine {
1144
1149
  return this.registry
1145
1150
  }
1146
1151
 
1152
+ /**
1153
+ * Stream a chat response with graceful provider fallback (v2.1.229 alignment).
1154
+ *
1155
+ * On a connection/availability failure from the active provider (thrown
1156
+ * network error or an error chunk), switches to the configured default
1157
+ * provider and retries once, yielding a `warning` chunk so the UI can tell
1158
+ * the user the provider degraded. Abort errors propagate untouched.
1159
+ */
1160
+ private async *chatWithFallback(
1161
+ messages: import('../shared/types').Message[],
1162
+ systemPrompt: string,
1163
+ toolDefs: Record<string, unknown>[] | undefined,
1164
+ signal?: AbortSignal,
1165
+ ): AsyncGenerator<StreamChunk> {
1166
+ const activeId = this.registry.getActive().config.id
1167
+ const defaultId = this.registry.getDefaultProviderId()
1168
+
1169
+ // ── Attempt 1: active provider ──
1170
+ let failure: string | null = null
1171
+ try {
1172
+ for await (const chunk of this.registry.chat({
1173
+ model: this.registry.getActiveModel(),
1174
+ messages,
1175
+ systemPrompt,
1176
+ tools: toolDefs,
1177
+ signal,
1178
+ })) {
1179
+ if (chunk.type === 'error') {
1180
+ failure = chunk.error ?? 'Unknown error'
1181
+ break
1182
+ }
1183
+ yield chunk
1184
+ }
1185
+ if (failure === null) return
1186
+ } catch (err) {
1187
+ if (isAbortError(err)) throw err
1188
+ failure = String(err)
1189
+ }
1190
+
1191
+ // ── Fallback: configured default provider, once ──
1192
+ if (!defaultId || defaultId === activeId || !this.registry.get(defaultId)) {
1193
+ yield { type: 'error', error: failure }
1194
+ return
1195
+ }
1196
+ const fallbackModel = this.registry
1197
+ .get(defaultId)!
1198
+ .config.models.find((m) => m.status === 'active')?.id
1199
+ if (!fallbackModel) {
1200
+ yield { type: 'error', error: failure }
1201
+ return
1202
+ }
1203
+
1204
+ this.registry.switchProvider(defaultId, fallbackModel)
1205
+ yield {
1206
+ type: 'warning',
1207
+ content: `${activeId} unreachable — degraded to ${defaultId} (${fallbackModel})`,
1208
+ }
1209
+
1210
+ try {
1211
+ for await (const chunk of this.registry.chat({
1212
+ model: fallbackModel,
1213
+ messages,
1214
+ systemPrompt,
1215
+ tools: toolDefs,
1216
+ signal,
1217
+ })) {
1218
+ yield chunk
1219
+ }
1220
+ } catch (err) {
1221
+ if (isAbortError(err)) throw err
1222
+ yield { type: 'error', error: String(err) }
1223
+ }
1224
+ }
1225
+
1147
1226
  getTools(): Map<string, ToolDefinition> {
1148
1227
  return this.tools
1149
1228
  }
package/src/index.tsx CHANGED
@@ -21,6 +21,7 @@ import { bootstrapProviders } from './providers/bootstrap'
21
21
  import { InstructionsLoader } from './core/instructions'
22
22
  import { loadSessionMemories, getMemoryManager } from './core/memory/memory-loader'
23
23
  import { ContextManager } from './core/context'
24
+ import { PrefixCacheTracker } from './core/context-token'
24
25
  import { QueryEngine } from './core/engine'
25
26
  import { ExperienceRuleEngine } from './core/rule-engine.js'
26
27
  import { SessionStore } from './core/session-store'
@@ -325,6 +326,8 @@ export async function runApp(options: RunOptions): Promise<void> {
325
326
  compactionThreshold: 0.9,
326
327
  contextWindow: adaptiveThresholds ? modelContextWindow : undefined,
327
328
  })
329
+ // Cache-aware microcompaction: track the provider's prompt-cache prefix.
330
+ context.setCacheTracker(new PrefixCacheTracker())
328
331
 
329
332
  // Adaptive memory budget: scale with model's context window
330
333
  getMemoryManager().setContextWindow(modelContextWindow)
@@ -52,15 +52,20 @@ export class AnthropicProvider implements ProviderInstance {
52
52
  let currentToolId = ''
53
53
  let accumulatedToolInput = ''
54
54
 
55
+ const messages = this.convertMessages(req.messages)
56
+ this.markPrefixCacheBreakpoint(messages)
57
+
55
58
  const body: Record<string, unknown> = {
56
59
  model: req.model,
57
60
  max_tokens: req.maxTokens || 4096,
58
61
  stream: true,
59
- messages: this.convertMessages(req.messages),
62
+ messages,
60
63
  }
61
64
 
62
65
  if (req.systemPrompt) {
63
- body.system = req.systemPrompt
66
+ // Mark the system prompt for prompt caching — it's the largest stable
67
+ // block and byte-identical across turns, so it always hits the cache.
68
+ body.system = [{ type: 'text', text: req.systemPrompt, cache_control: { type: 'ephemeral' } }]
64
69
  }
65
70
 
66
71
  if (req.temperature !== undefined) {
@@ -68,11 +73,14 @@ export class AnthropicProvider implements ProviderInstance {
68
73
  }
69
74
 
70
75
  if (req.tools && req.tools.length > 0) {
71
- body.tools = req.tools.map((t) => ({
76
+ const tools: Record<string, unknown>[] = req.tools.map((t) => ({
72
77
  name: t.name,
73
78
  description: t.description,
74
79
  input_schema: t.parameters || t.input_schema || { type: 'object', properties: {} },
75
80
  }))
81
+ // Cache the tools: mark the last tool definition as a breakpoint.
82
+ tools[tools.length - 1]!.cache_control = { type: 'ephemeral' }
83
+ body.tools = tools
76
84
  }
77
85
 
78
86
  const response = await fetchWithRetry(`${this.baseUrl}/messages`, {
@@ -239,6 +247,20 @@ export class AnthropicProvider implements ProviderInstance {
239
247
  return apiKey.length > 0 && apiKey.startsWith('sk-ant-')
240
248
  }
241
249
 
250
+ /**
251
+ * Mark the stable conversation prefix for prompt caching. The breakpoint is
252
+ * placed on the last block of the second-to-last message, leaving only the
253
+ * newest message uncached.
254
+ */
255
+ private markPrefixCacheBreakpoint(messages: Record<string, unknown>[]): void {
256
+ if (messages.length < 2) return
257
+ const boundary = messages[messages.length - 2]!
258
+ const content = boundary.content
259
+ if (!Array.isArray(content) || content.length === 0) return
260
+ const lastBlock = content[content.length - 1] as Record<string, unknown>
261
+ lastBlock.cache_control = { type: 'ephemeral' }
262
+ }
263
+
242
264
  private convertMessages(messages: Message[]): Record<string, unknown>[] {
243
265
  const result: Record<string, unknown>[] = []
244
266
 
@@ -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
@@ -5,13 +5,26 @@ import { getBackgroundAgentRegistry } from '../../agent/background-registry'
5
5
 
6
6
  const VALID_TYPES: SubAgentType[] = ['general', 'explore', 'plan', 'code-review']
7
7
 
8
+ /**
9
+ * Resolve whether a sub-agent should run in the background.
10
+ * Precedence: explicit `run_in_background` param > agent frontmatter
11
+ * `background` field > default (background, Claude Code 2.1.232 parity).
12
+ */
13
+ export function resolveRunInBackground(
14
+ runInBackground: boolean | undefined,
15
+ agentDef?: { background?: boolean },
16
+ ): boolean {
17
+ return runInBackground ?? agentDef?.background ?? true
18
+ }
19
+
8
20
  export const agentTool: ToolDefinition = {
9
21
  name: 'Agent',
10
22
  description:
11
23
  'Launch a sub-agent to handle complex, multi-step tasks independently. ' +
12
24
  'Available types: general (default), explore (code search), plan (design), code-review. ' +
13
- 'Set run_in_background: true to execute asynchronously — returns a task ID immediately; ' +
14
- 'results are retrievable via the Task tool (output action) or Agent View.',
25
+ 'Runs in the background by default — returns a task ID immediately; results are ' +
26
+ 'retrievable via the Task tool (output action) or Agent View. ' +
27
+ 'Set run_in_background: false to run synchronously.',
15
28
  category: 'agent',
16
29
  permission: 'ask',
17
30
  parameters: {
@@ -26,7 +39,7 @@ export const agentTool: ToolDefinition = {
26
39
  run_in_background: {
27
40
  type: 'boolean',
28
41
  description:
29
- 'When true, execute asynchronously and return a task ID immediately. Use Task output to retrieve results. Default: false.',
42
+ 'When false, execute synchronously and return the result directly. Default: true (background).',
30
43
  },
31
44
  },
32
45
  required: ['description', 'prompt'],
@@ -35,7 +48,6 @@ export const agentTool: ToolDefinition = {
35
48
  const description = params.description as string
36
49
  const prompt = params.prompt as string
37
50
  const agentType = (params.subagent_type as SubAgentType) || 'general'
38
- const runInBackground = params.run_in_background === true
39
51
 
40
52
  if (!VALID_TYPES.includes(agentType)) {
41
53
  return {
@@ -58,6 +70,10 @@ export const agentTool: ToolDefinition = {
58
70
 
59
71
  // Resolve agent definition from registry (custom > builtin)
60
72
  const agentDef = ctx.agentRegistry?.resolve(agentType)
73
+ const runInBackground = resolveRunInBackground(
74
+ params.run_in_background as boolean | undefined,
75
+ agentDef,
76
+ )
61
77
 
62
78
  try {
63
79
  const sub = new SubAgent(
@@ -6,7 +6,7 @@ export const sendMessageTool: ToolDefinition = {
6
6
  description:
7
7
  'Send a message to another agent or session. ' +
8
8
  'Use "main" for the parent conversation, a background task ID for same-process agents, ' +
9
- 'or a session ID for cross-session messaging (use ListAgents to discover sessions).',
9
+ 'or a session ID (or unique session name) for cross-session messaging (use ListAgents to discover sessions).',
10
10
  category: 'agent',
11
11
  permission: 'auto',
12
12
  parameters: {
@@ -15,7 +15,7 @@ export const sendMessageTool: ToolDefinition = {
15
15
  to: {
16
16
  type: 'string',
17
17
  description:
18
- 'Recipient: "main" for the parent conversation, a background task ID, or a session ID for cross-session messaging.',
18
+ 'Recipient: "main" for the parent conversation, a background task ID, or a session ID / unique session name for cross-session messaging.',
19
19
  },
20
20
  summary: {
21
21
  type: 'string',
@@ -1,5 +1,6 @@
1
1
  import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
2
2
  import { sanitizeCommand } from '../../shared/sanitize.ts'
3
+ import { DANGEROUS_GIT_PATTERNS } from './git.ts'
3
4
 
4
5
  // Injected at startup — set via index.tsx
5
6
  let credentialConfig: CredentialMaskingConfig | undefined
@@ -124,6 +125,19 @@ function isBlocked(command: string): string | null {
124
125
  return `Command "${sanitizedFirstWord}" rejected by security policy.`
125
126
  }
126
127
 
128
+ // Detect dangerous git operations invoked via Bash. The Git tool guards these,
129
+ // but shelling out with `git push --force` would bypass it — apply the same
130
+ // pattern list to any Bash command that starts with a git invocation.
131
+ const gitInvocation = normalized.trim().match(/^(?:sudo\s+)?git(?:\s+|$)(.*)$/i)
132
+ if (gitInvocation) {
133
+ const subcommand = gitInvocation[1] || ''
134
+ for (const { pattern, description } of DANGEROUS_GIT_PATTERNS) {
135
+ if (pattern.test(subcommand)) {
136
+ return `Dangerous git command blocked: "${description}". Run manually if intended.`
137
+ }
138
+ }
139
+ }
140
+
127
141
  // Check dangerous patterns (on original, normalized, and sanitized)
128
142
  for (const pattern of BLOCKED_PATTERNS) {
129
143
  if (pattern.test(command) || pattern.test(normalized) || pattern.test(sanitized)) {
@@ -2,7 +2,7 @@ import type { ToolDefinition } from '../../shared/index.ts'
2
2
 
3
3
  // P0-4 (v2.1.222 alignment): Regex-based word-boundary patterns replace
4
4
  // fragile substring matching. Each pattern describes what it blocks.
5
- const DANGEROUS_GIT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
5
+ export const DANGEROUS_GIT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
6
6
  // Destructive push
7
7
  { pattern: /\bpush\s+.*--force(?:-with-lease)?\b/, description: 'push --force' },
8
8
  { pattern: /\bpush\s+.*-[fF]\b/, description: 'push -f (force)' },
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
  }
@@ -4023,8 +4041,10 @@ const forkCmd: CommandHandler = async (ctx, args) => {
4023
4041
  ctx.engine.getTools(),
4024
4042
  ctx.engine.getPermission(),
4025
4043
  )
4044
+ const parentContext = ctx.engine.getContext()
4026
4045
  const result = await sa.execute(prompt, 'fork: ' + prompt.slice(0, 60), {
4027
4046
  worktreePath: wtPath,
4047
+ inheritContext: { messages: parentContext.getMessages() },
4028
4048
  })
4029
4049
  try {
4030
4050
  const { execSync: ex } = await import('node:child_process')