@miphamai/cli 0.33.1 → 0.33.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.33.1",
3
+ "version": "0.33.2",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -23,12 +23,23 @@ const INBOX_DIR = join(MIPHAM_HOME, 'inbox')
23
23
  * Each message file is named: <ISO-timestamp>-<msg-id>.json
24
24
  */
25
25
  export class FileInboxTransport implements CrossSessionTransport {
26
+ private initialized = false
27
+
26
28
  private ensureDir(dir: string): void {
27
29
  mkdirSync(dir, { recursive: true })
28
30
  }
29
31
 
32
+ /** Ensure the inbox root directory exists (call on first use). */
33
+ private ensureInboxRoot(): void {
34
+ if (!this.initialized) {
35
+ this.ensureDir(INBOX_DIR)
36
+ this.initialized = true
37
+ }
38
+ }
39
+
30
40
  async send(from: SessionInfo, toSessionId: string, msg: AgentMessage): Promise<boolean> {
31
41
  try {
42
+ this.ensureInboxRoot()
32
43
  const inboxDir = join(INBOX_DIR, toSessionId)
33
44
  this.ensureDir(inboxDir)
34
45
 
@@ -36,13 +47,17 @@ export class FileInboxTransport implements CrossSessionTransport {
36
47
  const filename = `${timestamp}-${msg.id}.json`
37
48
  const filePath = join(inboxDir, filename)
38
49
 
50
+ // Serialize with structured sender fields
51
+ const envelope = {
52
+ ...msg,
53
+ senderName: from.id,
54
+ senderMachine: from.machine,
55
+ senderSession: from.id,
56
+ }
57
+
39
58
  // Atomic write: temp file then rename
40
59
  const tmpPath = filePath + '.tmp'
41
- writeFileSync(
42
- tmpPath,
43
- JSON.stringify({ ...msg, _fromSession: from.id, _fromMachine: from.machine }, null, 2),
44
- 'utf-8',
45
- )
60
+ writeFileSync(tmpPath, JSON.stringify(envelope, null, 2), 'utf-8')
46
61
  renameSync(tmpPath, filePath)
47
62
 
48
63
  return true
@@ -52,6 +67,8 @@ export class FileInboxTransport implements CrossSessionTransport {
52
67
  }
53
68
 
54
69
  async poll(sessionId: string): Promise<AgentMessage[]> {
70
+ // Fix 6: Ensure inbox root exists before checking subdirectory
71
+ this.ensureInboxRoot()
55
72
  const inboxDir = join(INBOX_DIR, sessionId)
56
73
  if (!existsSync(inboxDir)) return []
57
74
 
@@ -65,21 +82,22 @@ export class FileInboxTransport implements CrossSessionTransport {
65
82
  try {
66
83
  const raw = readFileSync(join(inboxDir, file), 'utf-8')
67
84
  const parsed = JSON.parse(raw) as AgentMessage & {
68
- _fromSession?: string
69
- _fromMachine?: string
85
+ senderName?: string
86
+ senderMachine?: string
87
+ senderSession?: string
70
88
  }
71
89
 
72
- // Build the message body with cross-session origin prefix
73
- const originPrefix = parsed._fromSession
74
- ? `[From: ${parsed._fromSession}@${parsed._fromMachine || 'unknown'}]\n\n`
75
- : ''
90
+ // Fix 5: Structured sender display — separate From: line before body
91
+ const senderName = parsed.senderName || parsed.from || 'unknown'
92
+ const senderMachine = parsed.senderMachine || ''
93
+ const originLine = `[From: ${senderName}${senderMachine ? ` @ ${senderMachine}` : ''}]`
76
94
 
77
95
  messages.push({
78
96
  id: parsed.id,
79
97
  from: parsed.from,
80
98
  to: parsed.to,
81
99
  summary: parsed.summary,
82
- message: `${originPrefix}${parsed.message}`,
100
+ message: `${originLine}\n${parsed.message}`,
83
101
  timestamp: new Date(parsed.timestamp),
84
102
  read: parsed.read,
85
103
  type: parsed.type,
@@ -0,0 +1,205 @@
1
+ /**
2
+ * P1.1: Compaction Progress Tracker
3
+ *
4
+ * Provides user-visible progress feedback during context compaction:
5
+ * - Phase indicator (which compaction layer is active)
6
+ * - Message count (how many messages being processed)
7
+ * - Retry countdown for API-based summarization
8
+ * - Stuck detection hints
9
+ *
10
+ * Designed as an event emitter that the UI layer subscribes to.
11
+ * Falls back to stderr output when no UI subscriber is registered.
12
+ */
13
+
14
+ // ── Types ──
15
+
16
+ export type CompactionPhase =
17
+ 'microcompact' | 'snip' | 'reactive-compact' | 'emergency-drain' | 'complete'
18
+
19
+ export interface CompactionStatus {
20
+ phase: CompactionPhase
21
+ /** Human-readable description of current action */
22
+ message: string
23
+ /** 0-100 percentage, or -1 if indeterminate */
24
+ percent: number
25
+ /** Total messages before compaction */
26
+ messagesBefore: number
27
+ /** Messages remaining after compaction */
28
+ messagesAfter: number
29
+ /** Tokens before compaction */
30
+ tokensBefore: number
31
+ /** Tokens after compaction */
32
+ tokensAfter: number
33
+ /** Elapsed ms since compaction started */
34
+ elapsedMs: number
35
+ /** Retry count for summarization API calls */
36
+ retryCount: number
37
+ /** Whether a retry is in progress */
38
+ retrying: boolean
39
+ /** Whether the compaction appears stuck (no progress for >30s) */
40
+ stuck: boolean
41
+ }
42
+
43
+ export type ProgressCallback = (status: CompactionStatus) => void
44
+
45
+ // ── Constants ──
46
+
47
+ /** Timeout in ms after which we consider compaction "stuck" */
48
+ const STUCK_TIMEOUT_MS = 30_000
49
+
50
+ /** Max retries for summarizer API calls */
51
+ const MAX_SUMMARIZER_RETRIES = 3
52
+
53
+ /** Retry delay in ms between summarizer attempts */
54
+ const RETRY_DELAY_MS = 2_000
55
+
56
+ // ── Tracker ──
57
+
58
+ export class CompactionProgressTracker {
59
+ private callback: ProgressCallback | null = null
60
+ private status: CompactionStatus
61
+ private startTime = 0
62
+ private lastProgressTime = 0
63
+
64
+ constructor(messagesBefore: number, tokensBefore: number) {
65
+ this.status = {
66
+ phase: 'microcompact',
67
+ message: 'Starting compaction...',
68
+ percent: 0,
69
+ messagesBefore,
70
+ messagesAfter: messagesBefore,
71
+ tokensBefore,
72
+ tokensAfter: tokensBefore,
73
+ elapsedMs: 0,
74
+ retryCount: 0,
75
+ retrying: false,
76
+ stuck: false,
77
+ }
78
+ this.startTime = Date.now()
79
+ this.lastProgressTime = Date.now()
80
+ }
81
+
82
+ /** Register a UI callback for progress updates. */
83
+ onProgress(cb: ProgressCallback): void {
84
+ this.callback = cb
85
+ }
86
+
87
+ /** Update phase with new values. */
88
+ update(partial: {
89
+ phase?: CompactionPhase
90
+ message?: string
91
+ percent?: number
92
+ messagesAfter?: number
93
+ tokensAfter?: number
94
+ retrying?: boolean
95
+ }): void {
96
+ if (partial.phase !== undefined) this.status.phase = partial.phase
97
+ if (partial.message !== undefined) this.status.message = partial.message
98
+ if (partial.percent !== undefined) this.status.percent = partial.percent
99
+ if (partial.messagesAfter !== undefined) this.status.messagesAfter = partial.messagesAfter
100
+ if (partial.tokensAfter !== undefined) this.status.tokensAfter = partial.tokensAfter
101
+ if (partial.retrying !== undefined) this.status.retrying = partial.retrying
102
+
103
+ this.status.elapsedMs = Date.now() - this.startTime
104
+
105
+ // Check for stuck detection
106
+ if (this.status.phase !== 'complete') {
107
+ const timeSinceProgress = Date.now() - this.lastProgressTime
108
+ this.status.stuck = timeSinceProgress > STUCK_TIMEOUT_MS
109
+ }
110
+
111
+ this.emit()
112
+ }
113
+
114
+ /** Mark current retry attempt. */
115
+ onRetry(attempt: number): void {
116
+ this.status.retryCount = attempt
117
+ this.status.retrying = true
118
+ this.emit()
119
+ }
120
+
121
+ /** Mark retry as complete (success or giving up). */
122
+ onRetryComplete(): void {
123
+ this.status.retrying = false
124
+ this.lastProgressTime = Date.now()
125
+ this.status.stuck = false
126
+ this.emit()
127
+ }
128
+
129
+ /** Mark compaction as complete. */
130
+ complete(messagesAfter: number, tokensAfter: number): void {
131
+ this.status.phase = 'complete'
132
+ this.status.percent = 100
133
+ this.status.messagesAfter = messagesAfter
134
+ this.status.tokensAfter = tokensAfter
135
+ this.status.message = `Compaction complete: ${this.status.messagesBefore} → ${messagesAfter} messages`
136
+ this.status.elapsedMs = Date.now() - this.startTime
137
+ this.emit()
138
+ }
139
+
140
+ /** Get current status. */
141
+ getStatus(): CompactionStatus {
142
+ return { ...this.status }
143
+ }
144
+
145
+ /** Generate a compact summary string for CLI display. */
146
+ getSummary(): string {
147
+ const s = this.status
148
+ const elapsed = (s.elapsedMs / 1000).toFixed(1)
149
+ const stuckHint = s.stuck ? ' (stalled — press Esc to cancel)' : ''
150
+ const retryHint = s.retrying ? ` [retry ${s.retryCount}/${MAX_SUMMARIZER_RETRIES}...]` : ''
151
+ const pct = s.percent >= 0 ? ` ${s.percent}%` : ''
152
+ return `🔄 [${s.phase}]${pct} ${s.message}${retryHint}${stuckHint} (${elapsed}s)`
153
+ }
154
+
155
+ private emit(): void {
156
+ if (this.callback) {
157
+ try {
158
+ this.callback(this.getStatus())
159
+ } catch {
160
+ // UI callback failure shouldn't block compaction
161
+ }
162
+ }
163
+ }
164
+
165
+ // ── Static helpers ──
166
+
167
+ /** Retry a summarizer call with backoff and progress tracking. */
168
+ static async retrySummarizer(
169
+ summarizer: () => Promise<string>,
170
+ tracker: CompactionProgressTracker,
171
+ messagesToSummarize: number,
172
+ ): Promise<string> {
173
+ let lastError: Error | undefined
174
+
175
+ for (let attempt = 1; attempt <= MAX_SUMMARIZER_RETRIES; attempt++) {
176
+ try {
177
+ tracker.update({
178
+ message: `Summarizing ${messagesToSummarize} messages${attempt > 1 ? ` (attempt ${attempt}/${MAX_SUMMARIZER_RETRIES})` : ''}...`,
179
+ percent: 30 + attempt * 20,
180
+ retrying: attempt > 1,
181
+ })
182
+
183
+ const result = await summarizer()
184
+ tracker.onRetryComplete()
185
+ return result
186
+ } catch (err) {
187
+ lastError = err instanceof Error ? err : new Error(String(err))
188
+
189
+ if (attempt < MAX_SUMMARIZER_RETRIES) {
190
+ tracker.onRetry(attempt)
191
+ // Wait before retry
192
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS))
193
+ tracker.onRetryComplete()
194
+ }
195
+ }
196
+ }
197
+
198
+ tracker.update({
199
+ message: `Summarization failed after ${MAX_SUMMARIZER_RETRIES} attempts — falling back to truncation`,
200
+ percent: 60,
201
+ })
202
+
203
+ throw lastError ?? new Error('Summarization failed')
204
+ }
205
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ContextManager, Summarizer } from './context'
2
2
  import { snipMessages } from './context-snip'
3
+ import { CompactionProgressTracker } from './compaction-progress'
3
4
 
4
5
  /**
5
6
  * Layer 3: Reactive Compact — API-based summarization.
@@ -20,33 +21,85 @@ function getKeepRecent(contextWindow: number): number {
20
21
  return Math.max(20, Math.floor(contextWindow / 12500))
21
22
  }
22
23
 
24
+ export interface CompactResult {
25
+ /** Messages before compaction */
26
+ beforeCount: number
27
+ /** Messages after compaction */
28
+ afterCount: number
29
+ /** Whether compaction was actually performed */
30
+ compacted: boolean
31
+ /** Progress summary for display */
32
+ summary: string
33
+ }
34
+
23
35
  export async function reactiveCompact(
24
36
  context: ContextManager,
25
37
  summarizer: Summarizer,
26
38
  heading: string,
27
39
  keepRecent?: number,
28
- ): Promise<void> {
40
+ ): Promise<CompactResult> {
29
41
  // Use explicit override, or adaptive default based on context window
30
42
  const effectiveKeepRecent = keepRecent ?? getKeepRecent(context.getMaxTokens())
31
43
  const messages = context.getMessages()
44
+ const tokensBefore = context.getEstimatedTokens()
32
45
 
33
- if (messages.length <= effectiveKeepRecent + 4) return
46
+ if (messages.length <= effectiveKeepRecent + 4) {
47
+ return {
48
+ beforeCount: messages.length,
49
+ afterCount: messages.length,
50
+ compacted: false,
51
+ summary: `Context has ${messages.length} messages — no compaction needed (threshold: ${effectiveKeepRecent + 4}).`,
52
+ }
53
+ }
54
+
55
+ // ── Progress tracker ──
56
+ const tracker = new CompactionProgressTracker(messages.length, tokensBefore)
57
+ tracker.update({ phase: 'snip', message: 'Snipping empty pairs...', percent: 5 })
34
58
 
35
59
  // First, run snip to remove empty pairs
36
60
  const { messages: snipped } = snipMessages(messages)
61
+ tracker.update({
62
+ phase: 'snip',
63
+ message: `Snipped ${messages.length - snipped.length} empty pairs`,
64
+ percent: 15,
65
+ messagesAfter: snipped.length,
66
+ })
37
67
 
38
- if (snipped.length <= effectiveKeepRecent + 4) return
68
+ if (snipped.length <= effectiveKeepRecent + 4) {
69
+ const afterCount = snipped.length
70
+ tracker.complete(afterCount, tokensBefore)
71
+ return {
72
+ beforeCount: messages.length,
73
+ afterCount,
74
+ compacted: true,
75
+ summary: `Snip-only: ${messages.length} → ${afterCount} messages (removed ${messages.length - afterCount} empty pairs).`,
76
+ }
77
+ }
39
78
 
40
79
  // Split: old messages to summarize, recent messages to keep
41
80
  const toSummarize = snipped.slice(0, -effectiveKeepRecent)
42
81
  const toKeep = snipped.slice(-effectiveKeepRecent)
43
82
 
44
- // Generate summary
83
+ tracker.update({
84
+ phase: 'reactive-compact',
85
+ message: `Summarizing ${toSummarize.length} messages...`,
86
+ percent: 20,
87
+ })
88
+
89
+ // Generate summary with retry logic
45
90
  let summary: string
46
91
  try {
47
- summary = await summarizer(toSummarize, heading)
92
+ summary = await CompactionProgressTracker.retrySummarizer(
93
+ () => summarizer(toSummarize, heading),
94
+ tracker,
95
+ toSummarize.length,
96
+ )
48
97
  } catch {
49
98
  // On summarizer failure, do a simple truncation
99
+ tracker.update({
100
+ message: `Summarizer unavailable — truncating to last ${effectiveKeepRecent} messages.`,
101
+ percent: 80,
102
+ })
50
103
  summary = `Earlier conversation (${toSummarize.length} messages) omitted due to context limits.`
51
104
  }
52
105
 
@@ -56,5 +109,16 @@ export async function reactiveCompact(
56
109
  content: `[Earlier conversation summary — ${heading}]: ${summary.slice(0, 8000)}`,
57
110
  }
58
111
 
59
- context.replaceMessages([summaryMsg, ...toKeep])
112
+ const newMessages = [summaryMsg, ...toKeep]
113
+ context.replaceMessages(newMessages)
114
+
115
+ const tokensAfter = context.getEstimatedTokens()
116
+ tracker.complete(newMessages.length, tokensAfter)
117
+
118
+ return {
119
+ beforeCount: messages.length,
120
+ afterCount: newMessages.length,
121
+ compacted: true,
122
+ summary: tracker.getSummary(),
123
+ }
60
124
  }
@@ -1,4 +1,5 @@
1
1
  import type { ContextManager } from './context'
2
+ import type { CompactionProgressTracker } from './compaction-progress'
2
3
 
3
4
  const MINIMAL_KEEP = 5
4
5
  let drainLevel = 0
@@ -12,22 +13,62 @@ let drainLevel = 0
12
13
  * Level 1: Drop earliest 50% of messages
13
14
  * Level 2+: Keep only system prompt + last 5 messages
14
15
  *
15
- * Returns true if recovery was possible, false if context is already minimal.
16
+ * Returns drain result with progress summary.
16
17
  */
17
- export async function emergencyDrain(context: ContextManager): Promise<boolean> {
18
+ export interface DrainResult {
19
+ /** Whether recovery was possible */
20
+ recovered: boolean
21
+ /** Messages before drain */
22
+ beforeCount: number
23
+ /** Messages after drain */
24
+ afterCount: number
25
+ /** Current drain level */
26
+ level: number
27
+ /** Human-readable summary */
28
+ summary: string
29
+ }
30
+
31
+ export async function emergencyDrain(
32
+ context: ContextManager,
33
+ progress?: CompactionProgressTracker,
34
+ ): Promise<DrainResult> {
18
35
  const messages = context.getMessages()
19
36
 
20
37
  if (messages.length <= MINIMAL_KEEP) {
21
- return false // Already minimal, cannot drain further
38
+ return {
39
+ recovered: false,
40
+ beforeCount: messages.length,
41
+ afterCount: messages.length,
42
+ level: drainLevel,
43
+ summary: `Cannot drain further: already at minimal ${messages.length} messages.`,
44
+ }
22
45
  }
23
46
 
47
+ progress?.update({
48
+ phase: 'emergency-drain',
49
+ message: `Emergency drain level ${drainLevel + 1}: recovering from 413 error...`,
50
+ percent: 50,
51
+ })
52
+
24
53
  if (drainLevel === 0) {
25
54
  // First attempt: drop earliest 50%
26
55
  const keepCount = Math.max(MINIMAL_KEEP, Math.floor(messages.length / 2))
27
56
  const kept = messages.slice(-keepCount)
28
57
  context.replaceMessages(kept)
29
58
  drainLevel++
30
- return true
59
+ progress?.update({
60
+ phase: 'emergency-drain',
61
+ message: `Drain level 1: dropped ${messages.length - keepCount} messages (${messages.length} → ${keepCount})`,
62
+ percent: 80,
63
+ messagesAfter: keepCount,
64
+ })
65
+ return {
66
+ recovered: true,
67
+ beforeCount: messages.length,
68
+ afterCount: keepCount,
69
+ level: drainLevel,
70
+ summary: `Emergency drain level 1: ${messages.length} → ${keepCount} messages (dropped earliest 50%).`,
71
+ }
31
72
  }
32
73
 
33
74
  // Subsequent attempts: keep only system + last MINIMAL_KEEP messages
@@ -37,7 +78,7 @@ export async function emergencyDrain(context: ContextManager): Promise<boolean>
37
78
  if (messages.length > MINIMAL_KEEP * 2) {
38
79
  const summaryMsg = {
39
80
  role: 'user' as const,
40
- content: `[Emergency context drain: ${messages.length - MINIMAL_KEEP} earlier messages discarded due to token limit.]`,
81
+ content: `[Emergency context drain level ${drainLevel + 1}: ${messages.length - MINIMAL_KEEP} earlier messages discarded due to token limit.]`,
41
82
  }
42
83
  context.replaceMessages([summaryMsg, ...kept])
43
84
  } else {
@@ -45,10 +86,28 @@ export async function emergencyDrain(context: ContextManager): Promise<boolean>
45
86
  }
46
87
 
47
88
  drainLevel++
48
- return true
89
+ progress?.update({
90
+ phase: 'emergency-drain',
91
+ message: `Drain level ${drainLevel}: ${messages.length} → ${kept.length} messages`,
92
+ percent: 90,
93
+ messagesAfter: kept.length,
94
+ })
95
+
96
+ return {
97
+ recovered: true,
98
+ beforeCount: messages.length,
99
+ afterCount: kept.length,
100
+ level: drainLevel,
101
+ summary: `Emergency drain level ${drainLevel}: ${messages.length} → ${kept.length} messages.`,
102
+ }
49
103
  }
50
104
 
51
105
  /** Reset drain level (call at session start). */
52
106
  export function resetDrainLevel(): void {
53
107
  drainLevel = 0
54
108
  }
109
+
110
+ /** Get current drain level for display. */
111
+ export function getDrainLevel(): number {
112
+ return drainLevel
113
+ }
@@ -124,8 +124,13 @@ export class ContextManager {
124
124
  return this.estimatedTokens > this.config.maxTokens * this.config.compactionThreshold
125
125
  }
126
126
 
127
- async compact(heading: string): Promise<void> {
128
- if (this.messages.length <= 30) return
127
+ async compact(heading: string): Promise<{ before: number; after: number }> {
128
+ const beforeMsgs = this.messages.length
129
+ const beforeTokens = this.estimatedTokens
130
+
131
+ if (this.messages.length <= 30) {
132
+ return { before: beforeTokens, after: beforeTokens }
133
+ }
129
134
 
130
135
  const keep = 20
131
136
  const toDrop = this.messages.slice(0, -keep)
@@ -155,6 +160,8 @@ export class ContextManager {
155
160
  typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
156
161
  )
157
162
  }
163
+
164
+ return { before: beforeTokens, after: this.estimatedTokens }
158
165
  }
159
166
 
160
167
  getEstimatedTokens(): number {
@@ -266,11 +273,11 @@ export class ContextManager {
266
273
  */
267
274
  async on413Error(): Promise<boolean> {
268
275
  const result = await emergencyDrain(this)
269
- if (result) {
276
+ if (result.recovered) {
270
277
  this.compactionStats.drainCount++
271
278
  this.compactionStats.lastCompaction = new Date()
272
279
  }
273
- return result
280
+ return result.recovered
274
281
  }
275
282
 
276
283
  // ── Forced compaction (e.g. /compact command) ──
@@ -160,6 +160,11 @@ export class QueryEngine {
160
160
  this.hookEngine = hooks
161
161
  }
162
162
 
163
+ /** Get the registered hook engine (for health inspection). */
164
+ getHookEngine(): HookEngine | undefined {
165
+ return this.hookEngine
166
+ }
167
+
163
168
  /** Register the artifact server for tool context. */
164
169
  setArtifactServer(server: ArtifactServer): void {
165
170
  this.artifactServer = server
package/src/core/hooks.ts CHANGED
@@ -6,6 +6,29 @@ import type {
6
6
  ToolResult,
7
7
  } from '../shared/index.ts'
8
8
 
9
+ // ── Hook resilience types ──
10
+
11
+ interface HookHealth {
12
+ /** Consecutive failure count */
13
+ failures: number
14
+ /** Timestamp of last failure (ms since epoch) */
15
+ lastFailureTime: number
16
+ /** Whether the hook is currently disabled due to repeated failures */
17
+ disabled: boolean
18
+ /** Timestamp when disabled (ms since epoch) */
19
+ disabledAt: number
20
+ /** Total failure count (lifetime) */
21
+ totalFailures: number
22
+ }
23
+
24
+ /** Max consecutive failures before auto-disabling a hook */
25
+ const MAX_CONSECUTIVE_FAILURES = 5
26
+
27
+ /** Cooldown period in ms before a disabled hook is re-tried (5 minutes) */
28
+ const COOLDOWN_MS = 5 * 60 * 1000
29
+
30
+ // ── Helpers ──
31
+
9
32
  /**
10
33
  * Merge two permission decisions, keeping the more restrictive one.
11
34
  * Priority: deny > defer > ask > allow
@@ -22,6 +45,8 @@ export function mergePermissionDecision(
22
45
 
23
46
  export class HookEngine {
24
47
  private hooks: HookDefinition[] = []
48
+ /** Health tracking per hook key (event[:toolName]) */
49
+ private health = new Map<string, HookHealth>()
25
50
 
26
51
  register(hook: HookDefinition): void {
27
52
  this.hooks.push(hook)
@@ -71,31 +96,26 @@ export class HookEngine {
71
96
 
72
97
  // ── NEW event executors ──
73
98
 
74
- /** Stop hook: fires when AI completes a turn. Can block to force continuation. */
75
99
  async executeStop(sessionId: string): Promise<HookResult> {
76
100
  const ctx: HookContext = { event: 'Stop', sessionId }
77
101
  return this.runHooks('Stop', undefined, ctx)
78
102
  }
79
103
 
80
- /** UserPromptSubmit: fires when user submits input. Can block malicious input. */
81
104
  async executeUserPromptSubmit(prompt: string, sessionId: string): Promise<HookResult> {
82
105
  const ctx: HookContext = { event: 'UserPromptSubmit', sessionId, userPrompt: prompt }
83
106
  return this.runHooks('UserPromptSubmit', undefined, ctx)
84
107
  }
85
108
 
86
- /** PreCompact: fires before context compaction. */
87
109
  async executePreCompact(sessionId: string): Promise<HookResult> {
88
110
  const ctx: HookContext = { event: 'PreCompact', sessionId }
89
111
  return this.runHooks('PreCompact', undefined, ctx)
90
112
  }
91
113
 
92
- /** PostCompact: fires after context compaction. Can inject additional context. */
93
114
  async executePostCompact(sessionId: string): Promise<HookResult> {
94
115
  const ctx: HookContext = { event: 'PostCompact', sessionId }
95
116
  return this.runHooks('PostCompact', undefined, ctx)
96
117
  }
97
118
 
98
- /** ConfigChange: fires when configuration changes. */
99
119
  async executeConfigChange(key: string, value: unknown, sessionId: string): Promise<HookResult> {
100
120
  const ctx: HookContext = {
101
121
  event: 'ConfigChange',
@@ -106,7 +126,6 @@ export class HookEngine {
106
126
  return this.runHooks('ConfigChange', undefined, ctx)
107
127
  }
108
128
 
109
- /** SubagentStart: fires when a sub-agent begins execution. */
110
129
  async executeSubagentStart(
111
130
  agentType: string,
112
131
  description: string,
@@ -120,7 +139,6 @@ export class HookEngine {
120
139
  return this.runHooks('SubagentStart', undefined, ctx)
121
140
  }
122
141
 
123
- /** SubagentStop: fires when a sub-agent completes (success or failure). */
124
142
  async executeSubagentStop(
125
143
  agentType: string,
126
144
  description: string,
@@ -137,7 +155,6 @@ export class HookEngine {
137
155
  return this.runHooks('SubagentStop', undefined, ctx)
138
156
  }
139
157
 
140
- /** PostToolUseFailure: fires when a tool call fails. */
141
158
  async executePostToolUseFailure(
142
159
  toolName: string,
143
160
  toolInput: Record<string, unknown>,
@@ -154,7 +171,6 @@ export class HookEngine {
154
171
  return this.runHooks('PostToolUseFailure', toolName, ctx)
155
172
  }
156
173
 
157
- /** PreInference: fires before every model API call for DLP inspection. */
158
174
  async executePreInference(
159
175
  messages: Array<{ role: string; content: string }>,
160
176
  toolCalls: Array<{
@@ -177,6 +193,79 @@ export class HookEngine {
177
193
  return this.runHooks('PreInference', undefined, ctx)
178
194
  }
179
195
 
196
+ // ── Health & Resilience ──
197
+
198
+ /** Get a hook health key for tracking. */
199
+ private healthKey(hook: HookDefinition): string {
200
+ return hook.toolName ? `${hook.event}:${hook.toolName}` : hook.event
201
+ }
202
+
203
+ /** Check if a hook should be skipped due to repeated failures. */
204
+ private shouldSkip(key: string): boolean {
205
+ const h = this.health.get(key)
206
+ if (!h) return false
207
+
208
+ // If disabled, check cooldown
209
+ if (h.disabled) {
210
+ const elapsed = Date.now() - h.disabledAt
211
+ if (elapsed < COOLDOWN_MS) {
212
+ return true // still in cooldown
213
+ }
214
+ // Cooldown expired — re-enable for a trial
215
+ h.disabled = false
216
+ h.failures = 0
217
+ process.stderr.write(`🔄 Hook "${key}" cooldown expired — re-enabled for trial.\n`)
218
+ return false
219
+ }
220
+
221
+ return false
222
+ }
223
+
224
+ /** Record a hook success — resets failure counter. */
225
+ private recordSuccess(key: string): void {
226
+ const h = this.health.get(key)
227
+ if (h) {
228
+ h.failures = 0
229
+ h.disabled = false
230
+ }
231
+ }
232
+
233
+ /** Record a hook failure — may trigger auto-disable. */
234
+ private recordFailure(key: string): void {
235
+ let h = this.health.get(key)
236
+ if (!h) {
237
+ h = { failures: 0, lastFailureTime: 0, disabled: false, disabledAt: 0, totalFailures: 0 }
238
+ this.health.set(key, h)
239
+ }
240
+
241
+ h.failures++
242
+ h.totalFailures++
243
+ h.lastFailureTime = Date.now()
244
+
245
+ if (h.failures >= MAX_CONSECUTIVE_FAILURES && !h.disabled) {
246
+ h.disabled = true
247
+ h.disabledAt = Date.now()
248
+ process.stderr.write(
249
+ `⚠️ Hook "${key}" failed ${MAX_CONSECUTIVE_FAILURES} consecutive times — auto-disabled for ${COOLDOWN_MS / 60_000} minutes.\n` +
250
+ ` Use /hooks enable "${key}" to re-enable manually.\n`,
251
+ )
252
+ }
253
+ }
254
+
255
+ /** Get the health status of all hooks. */
256
+ getHookHealth(): Array<{ key: string; health: HookHealth }> {
257
+ return Array.from(this.health.entries()).map(([key, health]) => ({ key, health }))
258
+ }
259
+
260
+ /** Manually re-enable a disabled hook. */
261
+ reEnableHook(key: string): boolean {
262
+ const h = this.health.get(key)
263
+ if (!h || !h.disabled) return false
264
+ h.disabled = false
265
+ h.failures = 0
266
+ return true
267
+ }
268
+
180
269
  // ── Core execution ──
181
270
 
182
271
  private async runHooks(
@@ -191,9 +280,23 @@ export class HookEngine {
191
280
  const result: HookResult = { allowed: true }
192
281
 
193
282
  for (const hook of matching) {
283
+ const key = this.healthKey(hook)
284
+
285
+ // ── Resilience: skip disabled hooks ──
286
+ if (this.shouldSkip(key)) {
287
+ result.additionalContext = result.additionalContext
288
+ ? result.additionalContext +
289
+ `\n[Hook "${key}" skipped — temporarily disabled after repeated failures]`
290
+ : `[Hook "${key}" skipped — temporarily disabled after repeated failures]`
291
+ continue
292
+ }
293
+
194
294
  try {
195
295
  const hookResult = await hook.handler(ctx)
196
296
 
297
+ // Record success
298
+ this.recordSuccess(key)
299
+
197
300
  // Block on first deny — stops further hook execution
198
301
  if (!hookResult.allowed) {
199
302
  return { ...hookResult }
@@ -233,6 +336,8 @@ export class HookEngine {
233
336
  result.updatedOutput = hookResult.updatedOutput
234
337
  }
235
338
  } catch {
339
+ // Record failure — may trigger auto-disable
340
+ this.recordFailure(key)
236
341
  // Hook failures do not block execution
237
342
  }
238
343
  }
package/src/ui/app.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
2
2
  import { Box, Text, useInput } from 'ink'
3
3
  import TextInput from 'ink-text-input'
4
+ import { ErrorBoundary } from './error-boundary'
4
5
  import type { QueryEngine } from '../core/engine'
5
6
  import type { RemoteEngine } from '../daemon/remote-engine'
6
7
  import type { MiphamConfig } from '../shared/index.ts'
@@ -410,7 +411,17 @@ export function App({
410
411
  role: msg.role,
411
412
  content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
412
413
  }))
413
- setMessages((prev) => [...prev, ...restored])
414
+ if (result.resumeWarning) {
415
+ // Fix 7: Session isolation — add a visual separator between current session
416
+ // and restored history to prevent accidental leakage/confusion
417
+ const separator: ChatMessage = {
418
+ role: 'assistant' as const,
419
+ content: '── Restored session history below ──',
420
+ }
421
+ setMessages((prev) => [...prev, separator, ...restored])
422
+ } else {
423
+ setMessages((prev) => [...prev, ...restored])
424
+ }
414
425
  }
415
426
  if (result.copyContent) {
416
427
  // Copy to clipboard via pbcopy (macOS) or clip (Windows)
@@ -743,140 +754,142 @@ export function App({
743
754
  }
744
755
 
745
756
  return (
746
- <Box flexDirection="column" padding={1} height="100%">
747
- {/* Workflow progress — auto-detects active workflows, renders nothing when idle */}
748
- <WorkflowProgress />
749
-
750
- {/* Agent View Dashboard — Ctrl+G overlay (replaces chat + input) */}
751
- {agentViewOpen && agentViewManager ? (
752
- <AgentViewDashboard
753
- manager={agentViewManager}
754
- onAttach={() => {}}
755
- onExit={() => setAgentViewOpen(false)}
756
- />
757
- ) : (
758
- <>
759
- {/* Chat panel */}
760
- <ChatPanel messages={messages} focusMode={focusMode} />
761
-
762
- {/* Input with separator lines */}
763
- {pickerOpen ? (
764
- <ModelPicker
765
- config={config}
766
- currentProvider={providerId}
767
- currentModel={modelId}
768
- onSelect={(newProvider, newModel) => {
769
- engine.switchProvider(newProvider, newModel)
770
- setProviderId(newProvider)
771
- setModelId(newModel)
772
- setPickerOpen(false)
773
- setMessages((prev) => [
774
- ...prev,
775
- { role: 'system', content: `✓ Switched to ${newProvider}/${newModel}` },
776
- ])
777
- }}
778
- onNeedsApiKey={(providerId, modelId, providerName) => {
779
- setApiKeyPrompt({ providerId, modelId, providerName })
780
- setApiKeyInput('')
781
- setPickerOpen(false)
782
- setMessages((prev) => [
783
- ...prev,
784
- {
785
- role: 'system',
786
- content: t('ui.picker.needs_api_key', { provider: providerName }),
787
- },
788
- ])
789
- }}
790
- onClose={() => setPickerOpen(false)}
791
- />
792
- ) : (
793
- /* Input bar (hidden when picker is open) */
794
- <Box flexDirection="column">
795
- <Text dimColor>──────────────────────────────</Text>
796
- <InputBar
797
- onSubmit={handleSubmit}
798
- isLoading={isLoading}
799
- onTogglePicker={() => setPickerOpen((prev) => !prev)}
800
- onToggleFocus={() => setFocusMode((prev) => !prev)}
801
- onToggleExpand={() => {
802
- setMessages((prev) => {
803
- const msgs = [...prev]
804
- for (let i = msgs.length - 1; i >= 0; i--) {
805
- if (msgs[i]?.toolMeta) {
806
- const meta = msgs[i]!.toolMeta!
807
- if (meta.collapsed) {
808
- msgs[i] = {
809
- ...msgs[i]!,
810
- content: `🔧 ${meta.name}: ${meta.input}\n📋 Result: ${meta.output || '(pending)'}`,
811
- toolMeta: { ...meta, collapsed: false },
812
- }
813
- } else {
814
- const short =
815
- meta.input.length > 50 ? meta.input.slice(0, 50) + '...' : meta.input
816
- msgs[i] = {
817
- ...msgs[i]!,
818
- content: `⏺ ${meta.name} · ${short} (Ctrl+O to expand)`,
819
- toolMeta: { ...meta, collapsed: true },
757
+ <ErrorBoundary>
758
+ <Box flexDirection="column" padding={1} height="100%">
759
+ {/* Workflow progress — auto-detects active workflows, renders nothing when idle */}
760
+ <WorkflowProgress />
761
+
762
+ {/* Agent View Dashboard — Ctrl+G overlay (replaces chat + input) */}
763
+ {agentViewOpen && agentViewManager ? (
764
+ <AgentViewDashboard
765
+ manager={agentViewManager}
766
+ onAttach={() => {}}
767
+ onExit={() => setAgentViewOpen(false)}
768
+ />
769
+ ) : (
770
+ <>
771
+ {/* Chat panel */}
772
+ <ChatPanel messages={messages} focusMode={focusMode} />
773
+
774
+ {/* Input with separator lines */}
775
+ {pickerOpen ? (
776
+ <ModelPicker
777
+ config={config}
778
+ currentProvider={providerId}
779
+ currentModel={modelId}
780
+ onSelect={(newProvider, newModel) => {
781
+ engine.switchProvider(newProvider, newModel)
782
+ setProviderId(newProvider)
783
+ setModelId(newModel)
784
+ setPickerOpen(false)
785
+ setMessages((prev) => [
786
+ ...prev,
787
+ { role: 'system', content: `✓ Switched to ${newProvider}/${newModel}` },
788
+ ])
789
+ }}
790
+ onNeedsApiKey={(providerId, modelId, providerName) => {
791
+ setApiKeyPrompt({ providerId, modelId, providerName })
792
+ setApiKeyInput('')
793
+ setPickerOpen(false)
794
+ setMessages((prev) => [
795
+ ...prev,
796
+ {
797
+ role: 'system',
798
+ content: t('ui.picker.needs_api_key', { provider: providerName }),
799
+ },
800
+ ])
801
+ }}
802
+ onClose={() => setPickerOpen(false)}
803
+ />
804
+ ) : (
805
+ /* Input bar (hidden when picker is open) */
806
+ <Box flexDirection="column">
807
+ <Text dimColor>──────────────────────────────</Text>
808
+ <InputBar
809
+ onSubmit={handleSubmit}
810
+ isLoading={isLoading}
811
+ onTogglePicker={() => setPickerOpen((prev) => !prev)}
812
+ onToggleFocus={() => setFocusMode((prev) => !prev)}
813
+ onToggleExpand={() => {
814
+ setMessages((prev) => {
815
+ const msgs = [...prev]
816
+ for (let i = msgs.length - 1; i >= 0; i--) {
817
+ if (msgs[i]?.toolMeta) {
818
+ const meta = msgs[i]!.toolMeta!
819
+ if (meta.collapsed) {
820
+ msgs[i] = {
821
+ ...msgs[i]!,
822
+ content: `🔧 ${meta.name}: ${meta.input}\n📋 Result: ${meta.output || '(pending)'}`,
823
+ toolMeta: { ...meta, collapsed: false },
824
+ }
825
+ } else {
826
+ const short =
827
+ meta.input.length > 50 ? meta.input.slice(0, 50) + '...' : meta.input
828
+ msgs[i] = {
829
+ ...msgs[i]!,
830
+ content: `⏺ ${meta.name} · ${short} (Ctrl+O to expand)`,
831
+ toolMeta: { ...meta, collapsed: true },
832
+ }
820
833
  }
834
+ break
821
835
  }
822
- break
823
836
  }
837
+ return msgs
838
+ })
839
+ }}
840
+ onCyclePermission={() => {
841
+ setPermissionMode((prev) => {
842
+ const idx = PERMISSION_MODES.indexOf(prev)
843
+ const next = PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length]!
844
+ engine.getPermission().setMode(next)
845
+ return next
846
+ })
847
+ }}
848
+ onCancel={() => {
849
+ if (abortRef.current) {
850
+ abortRef.current.abort()
824
851
  }
825
- return msgs
826
- })
827
- }}
828
- onCyclePermission={() => {
829
- setPermissionMode((prev) => {
830
- const idx = PERMISSION_MODES.indexOf(prev)
831
- const next = PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length]!
832
- engine.getPermission().setMode(next)
833
- return next
834
- })
835
- }}
836
- onCancel={() => {
837
- if (abortRef.current) {
838
- abortRef.current.abort()
839
- }
840
- }}
841
- onToggleAgentView={() => setAgentViewOpen((prev) => !prev)}
842
- />
843
- <Text dimColor>──────────────────────────────</Text>
844
- </Box>
845
- )}
846
-
847
- {/* Agent status footer — shows active tool indicator + inline agent progress + running background agents */}
848
- <AgentFooter
849
- agents={Object.values(runningAgents)}
850
- tick={agentTick}
851
- activeTool={activeTool}
852
- agentProgress={agentProgress}
853
- />
854
-
855
- {/* Status line — Claude Code style */}
856
- <Box marginTop={1} flexDirection="column">
857
- {goalText && (
858
- <Box>
859
- <Text color="green">🎯 Goal: {goalText}</Text>
852
+ }}
853
+ onToggleAgentView={() => setAgentViewOpen((prev) => !prev)}
854
+ />
855
+ <Text dimColor>──────────────────────────────</Text>
860
856
  </Box>
861
857
  )}
862
- <Box flexDirection="row">
863
- <Text color={PERMISSION_COLORS[permissionMode]}>
864
- ⏵⏵ {PERMISSION_LABELS[permissionMode]}
865
- </Text>
866
- <Text dimColor>
867
- {' '}
868
- ({t('ui.status.shift_tab_cycle')}: {PERMISSION_LABELS.default} ·{' '}
869
- {PERMISSION_LABELS.acceptEdits} · {PERMISSION_LABELS.bypassPermissions} ·{' '}
870
- {PERMISSION_LABELS.plan} · {PERMISSION_LABELS.auto} · {PERMISSION_LABELS.dontAsk})
871
- {' · '}
872
- {t('ui.status.esc_to_interrupt')}
873
- {' · '}
874
- {t('ui.status.left_for_agents')}
875
- </Text>
858
+
859
+ {/* Agent status footer — shows active tool indicator + inline agent progress + running background agents */}
860
+ <AgentFooter
861
+ agents={Object.values(runningAgents)}
862
+ tick={agentTick}
863
+ activeTool={activeTool}
864
+ agentProgress={agentProgress}
865
+ />
866
+
867
+ {/* Status line — Claude Code style */}
868
+ <Box marginTop={1} flexDirection="column">
869
+ {goalText && (
870
+ <Box>
871
+ <Text color="green">🎯 Goal: {goalText}</Text>
872
+ </Box>
873
+ )}
874
+ <Box flexDirection="row">
875
+ <Text color={PERMISSION_COLORS[permissionMode]}>
876
+ ⏵⏵ {PERMISSION_LABELS[permissionMode]}
877
+ </Text>
878
+ <Text dimColor>
879
+ {' '}
880
+ ({t('ui.status.shift_tab_cycle')}: {PERMISSION_LABELS.default} ·{' '}
881
+ {PERMISSION_LABELS.acceptEdits} · {PERMISSION_LABELS.bypassPermissions} ·{' '}
882
+ {PERMISSION_LABELS.plan} · {PERMISSION_LABELS.auto} · {PERMISSION_LABELS.dontAsk})
883
+ {' · '}
884
+ {t('ui.status.esc_to_interrupt')}
885
+ {' · '}
886
+ {t('ui.status.left_for_agents')}
887
+ </Text>
888
+ </Box>
876
889
  </Box>
877
- </Box>
878
- </>
879
- )}
880
- </Box>
890
+ </>
891
+ )}
892
+ </Box>
893
+ </ErrorBoundary>
881
894
  )
882
895
  }
@@ -83,6 +83,8 @@ export interface CommandResult {
83
83
  forwardToAI?: string
84
84
  /** If set, restored messages to load into the session (used by /resume last). */
85
85
  forwardedMessages?: Message[]
86
+ /** If true, resume into current non-empty session — app should warn before loading */
87
+ resumeWarning?: boolean
86
88
  /** API Key 补录请求:切换前需用户输入 Key */
87
89
  needsApiKey?: {
88
90
  providerId: string
@@ -269,11 +271,31 @@ const exitCmd: CommandHandler = () => ({ content: '', exit: true })
269
271
  const compactCmd: CommandHandler = async (ctx) => {
270
272
  const t = resolveT(ctx)
271
273
  const context = ctx.engine.getContext()
272
- const before = context.getEstimatedTokens()
273
- await context.compact('user requested compaction')
274
- const after = context.getEstimatedTokens()
274
+ const msgsBefore = context.getMessageCount()
275
+ const tokensBefore = context.getEstimatedTokens()
276
+
277
+ // Show start progress
278
+ process.stderr.write(
279
+ `🔄 Compacting: ${msgsBefore} messages, ${tokensBefore.toLocaleString()} tokens...\n`,
280
+ )
281
+
282
+ const startTime = Date.now()
283
+ const result = await context.compact('user requested compaction')
284
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
285
+ const msgsAfter = context.getMessageCount()
286
+ const saved = result.before > 0 ? ((1 - result.after / result.before) * 100).toFixed(0) : '0'
287
+
275
288
  return {
276
- content: `${t('commands.compact.confirmed')}\n${t('commands.compact.tokens', { before: before.toLocaleString(), after: after.toLocaleString(), saved: ((1 - after / before) * 100).toFixed(0) })}`,
289
+ content: [
290
+ t('commands.compact.confirmed'),
291
+ `Messages: ${msgsBefore} → ${msgsAfter} | Tokens: ${result.before.toLocaleString()} → ${result.after.toLocaleString()} (${saved}% saved)`,
292
+ `Duration: ${elapsed}s`,
293
+ msgsBefore > msgsAfter
294
+ ? `💡 Tip: Use /compact early to prevent automatic compaction delays.`
295
+ : '',
296
+ ]
297
+ .filter(Boolean)
298
+ .join('\n'),
277
299
  }
278
300
  }
279
301
 
@@ -2256,6 +2278,46 @@ const hooksCmd: CommandHandler = async (ctx) => {
2256
2278
  }
2257
2279
  }
2258
2280
 
2281
+ const hooksHealthCmd: CommandHandler = (ctx) => {
2282
+ const hookEngine = ctx.engine.getHookEngine?.()
2283
+ if (!hookEngine) {
2284
+ return { content: 'Hook engine not available.' }
2285
+ }
2286
+
2287
+ const health = hookEngine.getHookHealth()
2288
+ if (health.length === 0) {
2289
+ return { content: '✅ All hooks are healthy — no failures recorded.' }
2290
+ }
2291
+
2292
+ const lines: string[] = ['## 🪝 Hook Health Status', '']
2293
+ for (const { key, health: h } of health) {
2294
+ const status = h.disabled ? '🔴 DISABLED' : h.failures > 0 ? '🟡 DEGRADED' : '🟢 HEALTHY'
2295
+ lines.push(`### ${status} \`${key}\``)
2296
+ lines.push(`| 连续失败 | ${h.failures} (threshold: 5) |`)
2297
+ lines.push(`| 总失败次数 | ${h.totalFailures} |`)
2298
+ if (h.disabled) {
2299
+ const remaining = Math.max(0, 5 - (Date.now() - h.disabledAt) / 60_000)
2300
+ lines.push(`| 自动恢复 | ${remaining.toFixed(0)} min remaining |`)
2301
+ lines.push('')
2302
+ lines.push(`Use \`/hooks enable "${key}"\` to re-enable manually.`)
2303
+ }
2304
+ lines.push('')
2305
+ }
2306
+ return { content: lines.join('\n') }
2307
+ }
2308
+
2309
+ const hooksEnableCmd: CommandHandler = (ctx, args) => {
2310
+ const hookEngine = ctx.engine.getHookEngine?.()
2311
+ if (!hookEngine) return { content: 'Hook engine not available.' }
2312
+ const keyArg = args[0]?.trim().replace(/"/g, '')
2313
+ if (!keyArg) {
2314
+ return { content: 'Usage: /hooks enable "<hook-key>"\n\nUse /hooks health to see hook keys.' }
2315
+ }
2316
+ return hookEngine.reEnableHook(keyArg)
2317
+ ? { content: `✅ Hook "${keyArg}" re-enabled.` }
2318
+ : { content: `Hook "${keyArg}" not found or not disabled. Use /hooks health to check status.` }
2319
+ }
2320
+
2259
2321
  const batchCmd: CommandHandler = async (ctx) => {
2260
2322
  const t = resolveT(ctx)
2261
2323
  return { content: t('commands.batch.title') }
@@ -2426,6 +2488,7 @@ const resumeLastCmd: CommandHandler = async (ctx) => {
2426
2488
  : t('commands.resume.restored_full_footer', { loaded: String(messages.length) }),
2427
2489
  ].join('\n'),
2428
2490
  forwardedMessages: messages,
2491
+ resumeWarning: true,
2429
2492
  }
2430
2493
  }
2431
2494
 
@@ -3776,6 +3839,8 @@ registry.set('/stats', statsCmd)
3776
3839
  registry.set('/summary', summaryCmd)
3777
3840
  registry.set('/cd', cdCmd)
3778
3841
  registry.set('/hooks', hooksCmd)
3842
+ registry.set('/hooks health', hooksHealthCmd)
3843
+ registry.set('/hooks enable', hooksEnableCmd)
3779
3844
  registry.set('/batch', batchCmd)
3780
3845
 
3781
3846
  // ═══════════════════════════════════════════════════════════════
@@ -3909,6 +3974,8 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
3909
3974
  '/summary': 'Generate session summary',
3910
3975
  '/cd': 'Change session working directory',
3911
3976
  '/hooks': 'Manage lifecycle hook scripts',
3977
+ '/hooks health': 'Check hook health — see failures, disabled hooks, recovery status',
3978
+ '/hooks enable': 'Manually re-enable a hook that was auto-disabled after repeated failures',
3912
3979
  '/batch': 'Apply changes across multiple files',
3913
3980
  }
3914
3981
 
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Fix 8: Error Boundary for UI Rendering Protection
3
+ *
4
+ * Catches rendering errors in the React/Ink component tree,
5
+ * preventing the "layout freeze" bug where the interactive session
6
+ * stops redrawing while the process stays alive.
7
+ *
8
+ * Recovery path: renders a fallback message with a hint that the
9
+ * user can press Enter to attempt recovery (re-render).
10
+ */
11
+
12
+ import React, { Component } from 'react'
13
+ import { Text, Box } from 'ink'
14
+
15
+ interface Props {
16
+ children: React.ReactNode
17
+ /** Called when a render error is caught */
18
+ onError?: (error: Error, errorInfo: React.ErrorInfo) => void
19
+ }
20
+
21
+ interface State {
22
+ hasError: boolean
23
+ error: Error | null
24
+ errorCount: number
25
+ }
26
+
27
+ export class ErrorBoundary extends Component<Props, State> {
28
+ private resetTimer: ReturnType<typeof setTimeout> | null = null
29
+
30
+ constructor(props: Props) {
31
+ super(props)
32
+ this.state = { hasError: false, error: null, errorCount: 0 }
33
+ }
34
+
35
+ static getDerivedStateFromError(error: Error): Partial<State> {
36
+ return { hasError: true, error }
37
+ }
38
+
39
+ override componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
40
+ // Log to stderr for diagnostics
41
+ process.stderr.write(
42
+ `\n⚠️ UI Error Boundary caught: ${error.message}\n` +
43
+ ` Component: ${errorInfo.componentStack?.slice(0, 200) || 'unknown'}\n`,
44
+ )
45
+
46
+ this.props.onError?.(error, errorInfo)
47
+
48
+ // Auto-recovery: reset after 5 seconds to attempt re-render
49
+ this.resetTimer = setTimeout(() => {
50
+ this.setState((prev) => ({
51
+ hasError: false,
52
+ error: null,
53
+ errorCount: prev.errorCount,
54
+ }))
55
+ }, 5_000)
56
+ }
57
+
58
+ override componentWillUnmount(): void {
59
+ if (this.resetTimer) {
60
+ clearTimeout(this.resetTimer)
61
+ }
62
+ }
63
+
64
+ override render(): React.ReactNode {
65
+ if (this.state.hasError) {
66
+ return (
67
+ <Box flexDirection="column" padding={1}>
68
+ <Text color="red" bold>
69
+ ⚠️ UI Render Error
70
+ </Text>
71
+ <Text dimColor>{this.state.error?.message || 'Unknown rendering error'}</Text>
72
+ <Text> </Text>
73
+ <Text dimColor>
74
+ The terminal interface encountered a rendering issue. Auto-recovery in 5 seconds...
75
+ Press Enter to attempt immediate recovery.
76
+ </Text>
77
+ <Text> </Text>
78
+ <Text color="yellow">Your session data and conversation history are preserved.</Text>
79
+ </Box>
80
+ )
81
+ }
82
+
83
+ return this.props.children
84
+ }
85
+ }