@onmars/lunar-agent-claude 0.8.0 → 0.9.1
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 +2 -2
- package/src/__tests__/compaction-bridge.test.ts +291 -0
- package/src/adapter.ts +39 -0
- package/src/index.ts +5 -0
- package/src/lib/compaction-bridge.ts +182 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onmars/lunar-agent-claude",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"LICENSE"
|
|
13
13
|
],
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@onmars/lunar-core": "^0.
|
|
15
|
+
"@onmars/lunar-core": "^0.9.1"
|
|
16
16
|
},
|
|
17
17
|
"description": "Claude CLI agent adapter for Lunar",
|
|
18
18
|
"author": "onMars Tech",
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* # ClaudeCompactionBridge — Functional Specification
|
|
3
|
+
*
|
|
4
|
+
* Phase 1.5 bridge between Anthropic's Compaction API (and Claude Code
|
|
5
|
+
* PreCompact hook) and the agnostic FlushHook that lives in
|
|
6
|
+
* @onmars/lunar-core.
|
|
7
|
+
*
|
|
8
|
+
* ## onStreamEvent(event)
|
|
9
|
+
* Pattern-matches Anthropic streaming events. When a
|
|
10
|
+
* `content_block_start` event with `content_block.type === 'compaction'`
|
|
11
|
+
* is seen, emits `{ kind: 'compaction-incoming', source:
|
|
12
|
+
* 'anthropic-stream', model? }` on the wired FlushHook. Other event
|
|
13
|
+
* types (including `message_start`, `content_block_delta`, unrelated
|
|
14
|
+
* blocks) are ignored. `message_start` events are sniffed for the
|
|
15
|
+
* active model so subsequent compaction signals can be annotated.
|
|
16
|
+
*
|
|
17
|
+
* ## handlePreCompactHook(payload)
|
|
18
|
+
* Public entry point for Claude Code's PreCompact hook. Emits
|
|
19
|
+
* `{ kind: 'compaction-incoming', source: 'claude-code-hook', model? }`
|
|
20
|
+
* and returns exit code 0 — the native compaction must never be
|
|
21
|
+
* blocked. Errors from the FlushHook are swallowed (logged) and still
|
|
22
|
+
* resolve to 0.
|
|
23
|
+
*
|
|
24
|
+
* ## shutdown()
|
|
25
|
+
* Makes the bridge inert. Subsequent onStreamEvent/handlePreCompactHook
|
|
26
|
+
* calls are no-ops.
|
|
27
|
+
*
|
|
28
|
+
* ## Defensive: missing FlushHook
|
|
29
|
+
* When constructed with `undefined`, every method is a silent no-op.
|
|
30
|
+
*/
|
|
31
|
+
import { describe, expect, test } from 'bun:test'
|
|
32
|
+
import type { CurationEngine, CurationResult, PressureSignal } from '@onmars/lunar-core'
|
|
33
|
+
import { FlushHook } from '@onmars/lunar-core'
|
|
34
|
+
import {
|
|
35
|
+
type AnthropicStreamEvent,
|
|
36
|
+
ClaudeCompactionBridge,
|
|
37
|
+
type PreCompactPayload,
|
|
38
|
+
} from '../lib/compaction-bridge'
|
|
39
|
+
|
|
40
|
+
// ─── helpers ────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** Minimal CurationEngine stub that records signals it receives. */
|
|
43
|
+
function makeEngineStub(result?: CurationResult) {
|
|
44
|
+
const signals: PressureSignal[] = []
|
|
45
|
+
const stub = {
|
|
46
|
+
curate: async (ctx: { signal: PressureSignal }) => {
|
|
47
|
+
signals.push(ctx.signal)
|
|
48
|
+
return (
|
|
49
|
+
result ?? {
|
|
50
|
+
strategy: 'delegate' as const,
|
|
51
|
+
savedFacts: [],
|
|
52
|
+
skipped: [],
|
|
53
|
+
instructionToInject: '[SYSTEM FLUSH] test',
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
},
|
|
57
|
+
signals,
|
|
58
|
+
} as unknown as CurationEngine & { signals: PressureSignal[] }
|
|
59
|
+
return stub
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeFlushHook(): {
|
|
63
|
+
hook: FlushHook
|
|
64
|
+
signals: PressureSignal[]
|
|
65
|
+
engine: CurationEngine & { signals: PressureSignal[] }
|
|
66
|
+
} {
|
|
67
|
+
const engine = makeEngineStub()
|
|
68
|
+
const hook = new FlushHook({ curationEngine: engine, cooldownMs: 0, threshold: 0.7 })
|
|
69
|
+
// Buffer a message so the signal does not get skipped as 'empty-buffer'.
|
|
70
|
+
hook.pushMessage({ role: 'user', content: 'hello' })
|
|
71
|
+
return { hook, signals: engine.signals, engine }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Wait a tick for fire-and-forget onSignal to flush through. */
|
|
75
|
+
async function flushMicrotasks(ms = 10): Promise<void> {
|
|
76
|
+
await new Promise<void>((r) => setTimeout(r, ms))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── tests ──────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe('ClaudeCompactionBridge — onStreamEvent', () => {
|
|
82
|
+
test('emits compaction-incoming when content_block_start.type is compaction', async () => {
|
|
83
|
+
const { hook, signals } = makeFlushHook()
|
|
84
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
85
|
+
|
|
86
|
+
// Observe the model from a prior message_start so the signal can
|
|
87
|
+
// annotate it.
|
|
88
|
+
bridge.onStreamEvent({
|
|
89
|
+
type: 'message_start',
|
|
90
|
+
message: { model: 'claude-opus-4-7' },
|
|
91
|
+
} satisfies AnthropicStreamEvent)
|
|
92
|
+
|
|
93
|
+
bridge.onStreamEvent({
|
|
94
|
+
type: 'content_block_start',
|
|
95
|
+
content_block: { type: 'compaction' },
|
|
96
|
+
} satisfies AnthropicStreamEvent)
|
|
97
|
+
|
|
98
|
+
await flushMicrotasks()
|
|
99
|
+
|
|
100
|
+
expect(signals).toHaveLength(1)
|
|
101
|
+
const [signal] = signals
|
|
102
|
+
expect(signal.kind).toBe('compaction-incoming')
|
|
103
|
+
if (signal.kind === 'compaction-incoming') {
|
|
104
|
+
expect(signal.source).toBe('anthropic-stream')
|
|
105
|
+
expect(signal.model).toBe('claude-opus-4-7')
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('emits signal with undefined model when no message_start was seen', async () => {
|
|
110
|
+
const { hook, signals } = makeFlushHook()
|
|
111
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
112
|
+
|
|
113
|
+
bridge.onStreamEvent({
|
|
114
|
+
type: 'content_block_start',
|
|
115
|
+
content_block: { type: 'compaction' },
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
await flushMicrotasks()
|
|
119
|
+
|
|
120
|
+
expect(signals).toHaveLength(1)
|
|
121
|
+
const [signal] = signals
|
|
122
|
+
if (signal.kind === 'compaction-incoming') {
|
|
123
|
+
expect(signal.model).toBeUndefined()
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('ignores unrelated content_block_start types', async () => {
|
|
128
|
+
const { hook, signals } = makeFlushHook()
|
|
129
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
130
|
+
|
|
131
|
+
bridge.onStreamEvent({
|
|
132
|
+
type: 'content_block_start',
|
|
133
|
+
content_block: { type: 'text' },
|
|
134
|
+
})
|
|
135
|
+
bridge.onStreamEvent({
|
|
136
|
+
type: 'content_block_start',
|
|
137
|
+
content_block: { type: 'tool_use' },
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
await flushMicrotasks()
|
|
141
|
+
|
|
142
|
+
expect(signals).toHaveLength(0)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('ignores unrelated event types', async () => {
|
|
146
|
+
const { hook, signals } = makeFlushHook()
|
|
147
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
148
|
+
|
|
149
|
+
bridge.onStreamEvent({ type: 'message_delta' })
|
|
150
|
+
bridge.onStreamEvent({ type: 'message_stop' })
|
|
151
|
+
bridge.onStreamEvent({ type: 'content_block_delta', content_block: { type: 'compaction' } })
|
|
152
|
+
bridge.onStreamEvent({ type: 'ping' })
|
|
153
|
+
|
|
154
|
+
await flushMicrotasks()
|
|
155
|
+
|
|
156
|
+
expect(signals).toHaveLength(0)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('ignores malformed events gracefully', async () => {
|
|
160
|
+
const { hook, signals } = makeFlushHook()
|
|
161
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
162
|
+
|
|
163
|
+
// None of these should throw; none should emit.
|
|
164
|
+
bridge.onStreamEvent({ type: 'content_block_start' })
|
|
165
|
+
bridge.onStreamEvent({ type: 'content_block_start', content_block: { type: '' } })
|
|
166
|
+
bridge.onStreamEvent({ type: '' })
|
|
167
|
+
|
|
168
|
+
await flushMicrotasks()
|
|
169
|
+
|
|
170
|
+
expect(signals).toHaveLength(0)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('tracks model across multiple message_start events', async () => {
|
|
174
|
+
const { hook, signals } = makeFlushHook()
|
|
175
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
176
|
+
|
|
177
|
+
bridge.onStreamEvent({ type: 'message_start', message: { model: 'claude-sonnet-4-6' } })
|
|
178
|
+
bridge.onStreamEvent({ type: 'message_start', message: { model: 'claude-opus-4-7' } })
|
|
179
|
+
|
|
180
|
+
bridge.onStreamEvent({
|
|
181
|
+
type: 'content_block_start',
|
|
182
|
+
content_block: { type: 'compaction' },
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
await flushMicrotasks()
|
|
186
|
+
|
|
187
|
+
expect(signals).toHaveLength(1)
|
|
188
|
+
const [signal] = signals
|
|
189
|
+
if (signal.kind === 'compaction-incoming') {
|
|
190
|
+
expect(signal.model).toBe('claude-opus-4-7')
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('ClaudeCompactionBridge — handlePreCompactHook', () => {
|
|
196
|
+
test('emits compaction-incoming with claude-code-hook source and returns 0', async () => {
|
|
197
|
+
const { hook, signals } = makeFlushHook()
|
|
198
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
199
|
+
|
|
200
|
+
const code = await bridge.handlePreCompactHook({
|
|
201
|
+
model: 'claude-opus-4-7',
|
|
202
|
+
session_id: 'sess-123',
|
|
203
|
+
} satisfies PreCompactPayload)
|
|
204
|
+
|
|
205
|
+
expect(code).toBe(0)
|
|
206
|
+
expect(signals).toHaveLength(1)
|
|
207
|
+
const [signal] = signals
|
|
208
|
+
expect(signal.kind).toBe('compaction-incoming')
|
|
209
|
+
if (signal.kind === 'compaction-incoming') {
|
|
210
|
+
expect(signal.source).toBe('claude-code-hook')
|
|
211
|
+
expect(signal.model).toBe('claude-opus-4-7')
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('accepts payload without model', async () => {
|
|
216
|
+
const { hook, signals } = makeFlushHook()
|
|
217
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
218
|
+
|
|
219
|
+
const code = await bridge.handlePreCompactHook({})
|
|
220
|
+
expect(code).toBe(0)
|
|
221
|
+
expect(signals).toHaveLength(1)
|
|
222
|
+
const [signal] = signals
|
|
223
|
+
if (signal.kind === 'compaction-incoming') {
|
|
224
|
+
expect(signal.model).toBeUndefined()
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('returns 0 even when the underlying flush throws', async () => {
|
|
229
|
+
const { hook } = makeFlushHook()
|
|
230
|
+
// Monkey-patch onSignal to throw — handlePreCompactHook must still
|
|
231
|
+
// return 0 so it never blocks the native compaction.
|
|
232
|
+
const original = hook.onSignal.bind(hook)
|
|
233
|
+
hook.onSignal = async () => {
|
|
234
|
+
throw new Error('boom')
|
|
235
|
+
}
|
|
236
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
237
|
+
|
|
238
|
+
const code = await bridge.handlePreCompactHook({ model: 'claude-opus-4-7' })
|
|
239
|
+
expect(code).toBe(0)
|
|
240
|
+
|
|
241
|
+
// Restore to avoid cross-test contamination.
|
|
242
|
+
hook.onSignal = original
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
describe('ClaudeCompactionBridge — shutdown', () => {
|
|
247
|
+
test('onStreamEvent is a no-op after shutdown', async () => {
|
|
248
|
+
const { hook, signals } = makeFlushHook()
|
|
249
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
250
|
+
|
|
251
|
+
bridge.shutdown()
|
|
252
|
+
expect(bridge.isShutdown).toBe(true)
|
|
253
|
+
|
|
254
|
+
bridge.onStreamEvent({
|
|
255
|
+
type: 'content_block_start',
|
|
256
|
+
content_block: { type: 'compaction' },
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
await flushMicrotasks()
|
|
260
|
+
expect(signals).toHaveLength(0)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('handlePreCompactHook returns 0 and emits nothing after shutdown', async () => {
|
|
264
|
+
const { hook, signals } = makeFlushHook()
|
|
265
|
+
const bridge = new ClaudeCompactionBridge(hook)
|
|
266
|
+
bridge.shutdown()
|
|
267
|
+
|
|
268
|
+
const code = await bridge.handlePreCompactHook({ model: 'x' })
|
|
269
|
+
expect(code).toBe(0)
|
|
270
|
+
expect(signals).toHaveLength(0)
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
describe('ClaudeCompactionBridge — defensive (no FlushHook)', () => {
|
|
275
|
+
test('onStreamEvent is a no-op when constructed without a FlushHook', () => {
|
|
276
|
+
const bridge = new ClaudeCompactionBridge(undefined)
|
|
277
|
+
|
|
278
|
+
expect(() =>
|
|
279
|
+
bridge.onStreamEvent({
|
|
280
|
+
type: 'content_block_start',
|
|
281
|
+
content_block: { type: 'compaction' },
|
|
282
|
+
}),
|
|
283
|
+
).not.toThrow()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('handlePreCompactHook returns 0 when constructed without a FlushHook', async () => {
|
|
287
|
+
const bridge = new ClaudeCompactionBridge(undefined)
|
|
288
|
+
const code = await bridge.handlePreCompactHook({ model: 'claude-opus-4-7' })
|
|
289
|
+
expect(code).toBe(0)
|
|
290
|
+
})
|
|
291
|
+
})
|
package/src/adapter.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Agent, AgentEvent, AgentInput, AgentUsage } from '@onmars/lunar-co
|
|
|
2
2
|
import { log } from '@onmars/lunar-core'
|
|
3
3
|
import type { SecurityConfig } from '@onmars/lunar-core/lib/config-loader'
|
|
4
4
|
import { buildSafeEnv } from '@onmars/lunar-core/lib/security'
|
|
5
|
+
import type { ClaudeCompactionBridge } from './lib/compaction-bridge'
|
|
5
6
|
|
|
6
7
|
export interface ClaudeAgentOptions {
|
|
7
8
|
/** Agent ID for routing — defaults to 'claude' */
|
|
@@ -100,11 +101,29 @@ export class ClaudeAgent implements Agent {
|
|
|
100
101
|
|
|
101
102
|
private activeProcess: ReturnType<typeof Bun.spawn> | null = null
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Optional Claude-specific compaction bridge. When set, every parsed
|
|
106
|
+
* CLI stream event is forwarded so the bridge can emit
|
|
107
|
+
* `compaction-incoming` pressure signals. The bridge remains fully
|
|
108
|
+
* opt-in — if unset (or the feature is disabled upstream), the adapter
|
|
109
|
+
* behaves exactly as before.
|
|
110
|
+
*/
|
|
111
|
+
private compactionBridge: ClaudeCompactionBridge | undefined
|
|
112
|
+
|
|
103
113
|
constructor(private options: ClaudeAgentOptions) {
|
|
104
114
|
this.id = options.id ?? 'claude'
|
|
105
115
|
this.name = options.name ?? 'Claude Code (CLI)'
|
|
106
116
|
}
|
|
107
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Inject a compaction bridge after construction. The bridge is
|
|
120
|
+
* optional and installed post-hoc from `start.ts` because the
|
|
121
|
+
* FlushHook it wraps is built later in the boot sequence.
|
|
122
|
+
*/
|
|
123
|
+
setCompactionBridge(bridge: ClaudeCompactionBridge | undefined): void {
|
|
124
|
+
this.compactionBridge = bridge
|
|
125
|
+
}
|
|
126
|
+
|
|
108
127
|
async init(): Promise<void> {
|
|
109
128
|
const binary = this.options.binaryPath ?? 'claude'
|
|
110
129
|
|
|
@@ -389,6 +408,26 @@ export class ClaudeAgent implements Agent {
|
|
|
389
408
|
|
|
390
409
|
try {
|
|
391
410
|
const msg: ClaudeJsonMessage = JSON.parse(trimmed)
|
|
411
|
+
|
|
412
|
+
// Forward raw stream event to the compaction bridge (if any)
|
|
413
|
+
// BEFORE we transform into AgentEvents. The bridge does its
|
|
414
|
+
// own structural matching and ignores unrelated messages.
|
|
415
|
+
if (this.compactionBridge) {
|
|
416
|
+
try {
|
|
417
|
+
this.compactionBridge.onStreamEvent(
|
|
418
|
+
msg as unknown as import('./lib/compaction-bridge').AnthropicStreamEvent,
|
|
419
|
+
)
|
|
420
|
+
} catch (bridgeErr) {
|
|
421
|
+
// Bridge failures must never break the stream loop.
|
|
422
|
+
log.debug(
|
|
423
|
+
{
|
|
424
|
+
err: bridgeErr instanceof Error ? bridgeErr.message : String(bridgeErr),
|
|
425
|
+
},
|
|
426
|
+
'Compaction bridge onStreamEvent threw (ignored)',
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
392
431
|
for (const evt of this.processMessage(msg)) {
|
|
393
432
|
if (evt.type === 'text') hasAssistantText = true
|
|
394
433
|
yield evt
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export type { ClaudeAgentOptions } from './adapter'
|
|
2
2
|
export { CLAUDE_MODEL_ALIASES, ClaudeAgent, resolveModelAlias } from './adapter'
|
|
3
|
+
export type {
|
|
4
|
+
AnthropicStreamEvent,
|
|
5
|
+
PreCompactPayload,
|
|
6
|
+
} from './lib/compaction-bridge'
|
|
7
|
+
export { ClaudeCompactionBridge } from './lib/compaction-bridge'
|
|
3
8
|
export type { ClaudeTokenCounterOptions } from './lib/token-counter-claude'
|
|
4
9
|
export { ClaudeTokenCounter } from './lib/token-counter-claude'
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClaudeCompactionBridge — Claude-specific translator between Anthropic's
|
|
3
|
+
* Compaction API (and Claude Code's PreCompact hook) and the agnostic
|
|
4
|
+
* `FlushHook` that lives in @onmars/lunar-core.
|
|
5
|
+
*
|
|
6
|
+
* Phase 1.5 bridge. Responsibilities:
|
|
7
|
+
*
|
|
8
|
+
* • onStreamEvent(event) — inspect a Claude stream event. If we
|
|
9
|
+
* detect a `content_block_start` of
|
|
10
|
+
* type `compaction` (Anthropic's
|
|
11
|
+
* Compaction API, beta header
|
|
12
|
+
* `compact-2026-01-12` with
|
|
13
|
+
* `pause_after_compaction: true`), emit
|
|
14
|
+
* a `compaction-incoming` PressureSignal
|
|
15
|
+
* with source = 'anthropic-stream'.
|
|
16
|
+
*
|
|
17
|
+
* • handlePreCompactHook(p) — public entry point for Claude Code's
|
|
18
|
+
* PreCompact hook. Claude Code can be
|
|
19
|
+
* configured (via settings.json's
|
|
20
|
+
* hooks.PreCompact) to shell out to a
|
|
21
|
+
* local endpoint that ultimately calls
|
|
22
|
+
* this method. Emits the same signal
|
|
23
|
+
* with source = 'claude-code-hook'.
|
|
24
|
+
* Returns exit code 0 so the native
|
|
25
|
+
* compact proceeds unblocked.
|
|
26
|
+
*
|
|
27
|
+
* • shutdown() — mark the bridge inert. After shutdown,
|
|
28
|
+
* further events/hooks are ignored.
|
|
29
|
+
*
|
|
30
|
+
* Design constraints:
|
|
31
|
+
*
|
|
32
|
+
* • Zero runtime dependency on @anthropic-ai/sdk. We accept a minimal
|
|
33
|
+
* structural `AnthropicStreamEvent` shape so integrators are free to
|
|
34
|
+
* feed events from any source (SDK, CLI stream-json, mock tests).
|
|
35
|
+
* • No @onmars/lunar-core types leak Anthropic-specific fields upward.
|
|
36
|
+
* This file is the only place that knows about Anthropic's
|
|
37
|
+
* compaction block shape.
|
|
38
|
+
* • Opt-in. If no FlushHook is provided, every method is a no-op.
|
|
39
|
+
* Callers should simply skip constructing the bridge when the
|
|
40
|
+
* feature is disabled.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type { FlushHook } from '@onmars/lunar-core'
|
|
44
|
+
import { log } from '@onmars/lunar-core'
|
|
45
|
+
|
|
46
|
+
// ════════════════════════════════════════════════════════════
|
|
47
|
+
// Public types — structural, no SDK import required
|
|
48
|
+
// ════════════════════════════════════════════════════════════
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Minimal structural shape of an Anthropic streaming event we need to
|
|
52
|
+
* recognise compaction. Matches the public Messages API streaming
|
|
53
|
+
* contract (see Anthropic docs). Only the fields we actually inspect
|
|
54
|
+
* are declared; integrators may feed richer events and we'll ignore
|
|
55
|
+
* the rest.
|
|
56
|
+
*
|
|
57
|
+
* Compaction detection uses the `content_block_start` event whose
|
|
58
|
+
* `content_block.type === 'compaction'` (Anthropic Compaction API, beta
|
|
59
|
+
* header `compact-2026-01-12`).
|
|
60
|
+
*/
|
|
61
|
+
export interface AnthropicStreamEvent {
|
|
62
|
+
type: string
|
|
63
|
+
/** Present on content_block_start / content_block_delta events. */
|
|
64
|
+
content_block?: {
|
|
65
|
+
type: string
|
|
66
|
+
[key: string]: unknown
|
|
67
|
+
}
|
|
68
|
+
/** Present on message_start — we use it to annotate the signal. */
|
|
69
|
+
message?: {
|
|
70
|
+
model?: string
|
|
71
|
+
[key: string]: unknown
|
|
72
|
+
}
|
|
73
|
+
[key: string]: unknown
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Payload shape for Claude Code's PreCompact hook. The upstream CLI
|
|
78
|
+
* currently writes a JSON object on stdin to hook commands; we accept
|
|
79
|
+
* whatever it provides and only read what we need.
|
|
80
|
+
*/
|
|
81
|
+
export interface PreCompactPayload {
|
|
82
|
+
/** The model about to compact, if reported. */
|
|
83
|
+
model?: string
|
|
84
|
+
/** Optional session identifier for log context. */
|
|
85
|
+
session_id?: string
|
|
86
|
+
/** Allow arbitrary additional fields without type loss. */
|
|
87
|
+
[key: string]: unknown
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ════════════════════════════════════════════════════════════
|
|
91
|
+
// Implementation
|
|
92
|
+
// ════════════════════════════════════════════════════════════
|
|
93
|
+
|
|
94
|
+
export class ClaudeCompactionBridge {
|
|
95
|
+
private shutdownRequested = false
|
|
96
|
+
|
|
97
|
+
/** Last model seen on a message_start — lets us tag stream signals. */
|
|
98
|
+
private lastKnownModel: string | undefined
|
|
99
|
+
|
|
100
|
+
constructor(private readonly flushHook: FlushHook | undefined) {}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Feed a single Anthropic stream event to the bridge. The method is
|
|
104
|
+
* intentionally synchronous and fire-and-forget on the FlushHook side:
|
|
105
|
+
* we trigger the signal but do not await the flush (callers must not
|
|
106
|
+
* block the streaming loop).
|
|
107
|
+
*/
|
|
108
|
+
onStreamEvent(event: AnthropicStreamEvent): void {
|
|
109
|
+
if (this.shutdownRequested) return
|
|
110
|
+
if (!this.flushHook) return
|
|
111
|
+
if (!event || typeof event !== 'object') return
|
|
112
|
+
|
|
113
|
+
// Track model from message_start so we can annotate downstream
|
|
114
|
+
// signals with the active model id.
|
|
115
|
+
if (event.type === 'message_start' && event.message?.model) {
|
|
116
|
+
this.lastKnownModel = event.message.model
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (event.type !== 'content_block_start') return
|
|
121
|
+
if (!event.content_block || event.content_block.type !== 'compaction') return
|
|
122
|
+
|
|
123
|
+
const model = this.lastKnownModel
|
|
124
|
+
log.info(
|
|
125
|
+
{ source: 'anthropic-stream', model },
|
|
126
|
+
'Claude Compaction bridge: compaction block detected in stream',
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
// Fire and forget — the FlushHook is already async-safe internally
|
|
130
|
+
// and will coalesce concurrent signals.
|
|
131
|
+
void this.flushHook.onSignal({
|
|
132
|
+
kind: 'compaction-incoming',
|
|
133
|
+
source: 'anthropic-stream',
|
|
134
|
+
model,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Public entry point for Claude Code's PreCompact hook. Intended to
|
|
140
|
+
* be invoked by whatever process bridges the CLI hook payload into
|
|
141
|
+
* the Lunar runtime (future: a lightweight HTTP endpoint). Always
|
|
142
|
+
* returns 0 so the native compaction proceeds unblocked — we are a
|
|
143
|
+
* last-chance persister, not a gatekeeper.
|
|
144
|
+
*/
|
|
145
|
+
async handlePreCompactHook(payload: PreCompactPayload): Promise<number> {
|
|
146
|
+
if (this.shutdownRequested) return 0
|
|
147
|
+
if (!this.flushHook) return 0
|
|
148
|
+
|
|
149
|
+
const model = typeof payload?.model === 'string' ? payload.model : undefined
|
|
150
|
+
log.info(
|
|
151
|
+
{ source: 'claude-code-hook', model, sessionId: payload?.session_id },
|
|
152
|
+
'Claude Compaction bridge: PreCompact hook received',
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
await this.flushHook.onSignal({
|
|
157
|
+
kind: 'compaction-incoming',
|
|
158
|
+
source: 'claude-code-hook',
|
|
159
|
+
model,
|
|
160
|
+
})
|
|
161
|
+
} catch (err) {
|
|
162
|
+
// Never block the native compaction on our side — log and swallow.
|
|
163
|
+
log.warn(
|
|
164
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
165
|
+
'Claude Compaction bridge: PreCompact flush failed (continuing)',
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return 0
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Mark the bridge inert. Subsequent events/hooks are no-ops. */
|
|
173
|
+
shutdown(): void {
|
|
174
|
+
this.shutdownRequested = true
|
|
175
|
+
this.lastKnownModel = undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Observability helper — tests and logs only. */
|
|
179
|
+
get isShutdown(): boolean {
|
|
180
|
+
return this.shutdownRequested
|
|
181
|
+
}
|
|
182
|
+
}
|