@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onmars/lunar-agent-claude",
3
- "version": "0.8.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.8.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
+ }