@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 +1 -1
- package/src/agent/cross-session/file-inbox.ts +30 -12
- package/src/core/compaction-progress.ts +205 -0
- package/src/core/context-compact.ts +70 -6
- package/src/core/context-drain.ts +65 -6
- package/src/core/context.ts +11 -4
- package/src/core/engine.ts +5 -0
- package/src/core/hooks.ts +114 -9
- package/src/ui/app.tsx +142 -129
- package/src/ui/commands.ts +71 -4
- package/src/ui/error-boundary.tsx +85 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
85
|
+
senderName?: string
|
|
86
|
+
senderMachine?: string
|
|
87
|
+
senderSession?: string
|
|
70
88
|
}
|
|
71
89
|
|
|
72
|
-
//
|
|
73
|
-
const
|
|
74
|
-
|
|
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: `${
|
|
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<
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
16
|
+
* Returns drain result with progress summary.
|
|
16
17
|
*/
|
|
17
|
-
export
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/core/context.ts
CHANGED
|
@@ -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<
|
|
128
|
-
|
|
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) ──
|
package/src/core/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
<
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
<
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
</
|
|
890
|
+
</>
|
|
891
|
+
)}
|
|
892
|
+
</Box>
|
|
893
|
+
</ErrorBoundary>
|
|
881
894
|
)
|
|
882
895
|
}
|
package/src/ui/commands.ts
CHANGED
|
@@ -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
|
|
273
|
-
|
|
274
|
-
|
|
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:
|
|
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
|
+
}
|