Skip to content

Commit 150e77d

Browse files
Ubuntuclaude
andcommitted
fix(claude-code,m4): unstick cancel button + light context chip + synth session.init (v1.0.667-alpha)
ADR-027 W11 fix-up wedge #11. Three residual M4 polish gaps surfaced on v1.0.666 (the load-bearing text-rendering fix) on-host smoke. - Cancel button stuck on after end-of-turn. Mobile's _isAgentBusy walks events tail-first and treats every agent-produced kind as proof of in-flight turn. M4's wire order is turn.result → text → usage (Stop hook posts turn.result first, JSONL tail posts text+usage), so the LATEST event is `usage` and the walker fell through to "busy = true". Added usage + rate_limit to the skip list (both telemetry, similar shape). - Context chip blank. The mapper's per-message usage event carried input_tokens/cache_read/cache_create but no context_window, so mobile couldn't compute pct (chip suppresses itself when cw=0). Mapper now derives capacity from model name via a small lookup (claude-opus-* / claude-sonnet-* / claude-haiku-* / claude-3-* → 200000; otherwise omit). Mobile's per-message usage handler picks up the new field. - Session.init chip empty. M4's on-disk JSONL has no equivalent of M2 stream-json's `init` frame. Adapter now synthesises a session.init from the FIRST usage frame carrying a model. Idempotent per Adapter lifetime — subsequent usage frames don't re-emit. Payload: engine=claude-code, model, cwd. 3 new tests: usage carries context_window for known models (sweep), omits it for unknown, adapter synthesises one + only one session.init that lands BEFORE the first usage so mobile picks both up in the same build pass. Co-Authored-By: Claude Opus 4.7 <[email protected]>
1 parent b079c74 commit 150e77d

8 files changed

Lines changed: 279 additions & 3 deletions

File tree

docs/changelog.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
> **Type:** reference
44
> **Status:** Current (2026-05-24)
55
> **Audience:** contributors, operators
6-
> **Last verified vs code:** v1.0.666
6+
> **Last verified vs code:** v1.0.667
77
88
**TL;DR.** Append-only record of what shipped in each tagged release.
99
One section per version, newest first. Format follows
@@ -23,6 +23,55 @@ binding). Seed entries prior to that are in
2323

2424
---
2525

26+
## v1.0.667-alpha — 2026-05-24
27+
28+
ADR-027 W11 fix-up wedge #11 — three residual M4 polish gaps surfaced
29+
after v1.0.666 (the text-rendering fix) on-host smoke.
30+
31+
**Fixed.**
32+
33+
- *Cancel button stuck on after end-of-turn.* Mobile's `_isAgentBusy`
34+
walks events tail-first and treats any "agent-produced" kind as
35+
proof a turn is still in motion. The M4 wire order at end-of-turn
36+
is `turn.result → text → usage` (turn.result is posted from the
37+
Stop hook handler, text+usage from the JSONL tail), so the LATEST
38+
event is `usage`. Walker fell through to the default "busy = true"
39+
branch. Added `usage` (and `rate_limit`, similar shape) to the
40+
skip list — both are pure telemetry kinds that don't move the
41+
turn-progress signal.
42+
43+
- *Context-utilisation chip stayed blank.* The mapper's per-message
44+
usage event carried `input_tokens`/`cache_read`/`cache_create` but
45+
no `context_window`, so mobile couldn't compute the percentage
46+
(chip suppresses itself when capacity is zero). Now the mapper
47+
derives the capacity from the model name via a small lookup
48+
(`claude-opus-*` / `claude-sonnet-*` / `claude-haiku-*` /
49+
`claude-3-*` → 200000; everything else → omit so the chip
50+
stays suppressed rather than render a wrong %). Mobile's
51+
per-message usage branch picks up the new field.
52+
53+
- *Session.init header chip blank.* M4's on-disk JSONL has no
54+
equivalent of M2 stream-json's `init` frame, so mobile's AppBar
55+
chip (engine + model + cwd row) stayed empty. The adapter now
56+
synthesises a `session.init` event from the FIRST usage frame it
57+
sees (the one that carries the model name). Idempotent per
58+
Adapter lifetime — subsequent usage events don't re-emit.
59+
60+
**Test coverage.**
61+
62+
- `TestMapLine_UsageCarriesContextWindowFromModel` — sweeps all 4
63+
known model prefixes, asserts 200K each.
64+
- `TestMapLine_UsageOmitsContextWindowForUnknownModel` — better blank
65+
than wrong.
66+
- `TestAdapter_SynthesisesSessionInitFromFirstUsage` — exactly one
67+
init landed; payload carries engine/model/cwd; lands BEFORE the
68+
first usage event so mobile's build pass picks it up in the same
69+
pass.
70+
71+
Mobile changes covered by CI `flutter analyze`.
72+
73+
---
74+
2675
## v1.0.666-alpha — 2026-05-24
2776

2877
ADR-027 W11 fix-up wedge #10 — the load-bearing one. Closes the

hub/internal/buildinfo/buildinfo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
// mobile and hub use the same x.y.z-alpha numbering. Use
1414
// `make bump VERSION=...` from the repo root to update both files
1515
// atomically.
16-
const Version = "1.0.666-alpha"
16+
const Version = "1.0.667-alpha"
1717

1818
var (
1919
Commit string

hub/internal/drivers/local_log_tail/claude_code/adapter.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ type Adapter struct {
133133
// Plan §5.B.1.
134134
pickerMu sync.Mutex
135135
pickerDone chan struct{}
136+
137+
// sessionInitMu guards sessionInitSent. The flag is checked + set
138+
// in runLoop so a second usage event on the same session doesn't
139+
// re-emit the synthetic session.init (mobile would still merge it
140+
// correctly, but the duplicate event is noise).
141+
sessionInitMu sync.Mutex
142+
sessionInitSent bool
136143
}
137144

138145
// NewAdapter constructs a claude-code Adapter. Returns an error early
@@ -270,6 +277,40 @@ func (a *Adapter) resolveAndRun(ctx context.Context) {
270277
a.runLoop(ctx, lines)
271278
}
272279

280+
// maybeEmitSessionInit posts a synthetic session.init the first time
281+
// we see a usage event carrying a model field. M4 (on-disk JSONL)
282+
// has no equivalent of M2 stream-json's `init` frame, so mobile's
283+
// AppBar chip stays empty for every M4 spawn without this. Idempotent
284+
// per Adapter lifetime — the flag prevents duplicate emits when many
285+
// usage events flow in the same session.
286+
func (a *Adapter) maybeEmitSessionInit(ctx context.Context, ev MappedEvent) {
287+
if ev.Kind != "usage" {
288+
return
289+
}
290+
model, _ := ev.Payload["model"].(string)
291+
if model == "" {
292+
return
293+
}
294+
a.sessionInitMu.Lock()
295+
if a.sessionInitSent {
296+
a.sessionInitMu.Unlock()
297+
return
298+
}
299+
a.sessionInitSent = true
300+
a.sessionInitMu.Unlock()
301+
302+
payload := map[string]any{
303+
"engine": "claude-code",
304+
"model": model,
305+
"cwd": a.Workdir,
306+
"version": "claude-code", // mobile's chip shows engine alone if version absent
307+
}
308+
if err := a.Poster.PostAgentEvent(ctx, a.AgentID, "session.init", "agent", payload); err != nil {
309+
a.Log.Warn("claude-code adapter: session.init post failed",
310+
"agent_id", a.AgentID, "err", err)
311+
}
312+
}
313+
273314
// noteFailure logs and surfaces a soft failure: the pane is still
274315
// live, but JSONL-derived events won't flow for this session.
275316
// Best-effort post — a hub blip shouldn't escalate this further.
@@ -342,6 +383,17 @@ func (a *Adapter) runLoop(ctx context.Context, lines <-chan Line) {
342383
}
343384
for _, ev := range events {
344385
payload := ev.Payload
386+
// v1.0.667: synthesise a session.init event from the
387+
// first per-message usage frame we see. The usage
388+
// payload carries `model` (set by usageFromMessage),
389+
// and the adapter's Workdir gives `cwd`; together
390+
// that's enough for mobile's AppBar header chip to
391+
// render the engine + model + cwd row that M1/M2
392+
// drivers get for free from their engine-emitted
393+
// init frame. claude-code's on-disk JSONL has no
394+
// equivalent of M2 stream-json's `init` frame, so
395+
// the chip was empty for every M4 spawn.
396+
a.maybeEmitSessionInit(ctx, ev)
345397
// v1.0.666: no replay tagging — see runLoop header note.
346398
if err := a.Poster.PostAgentEvent(ctx, a.AgentID, ev.Kind, ev.Producer, payload); err != nil {
347399
// v1.0.664 escalated from Debug to Warn. Silent

hub/internal/drivers/local_log_tail/claude_code/adapter_integration_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,3 +326,72 @@ func TestAdapter_StopDrainsRunLoop(t *testing.T) {
326326
// Idempotent.
327327
a.Stop()
328328
}
329+
330+
// v1.0.667 — the M4 adapter synthesises a session.init event on the
331+
// first usage frame carrying a model, since on-disk JSONL has no
332+
// equivalent of M2 stream-json's `init` frame. Without this mobile's
333+
// AppBar chip stays blank for every M4 spawn. Asserts:
334+
// - session.init lands BEFORE the corresponding usage event so
335+
// the chip can render in the same build pass
336+
// - payload carries engine="claude-code", the model from the
337+
// assistant frame, and the workdir
338+
// - subsequent usage frames in the same session DO NOT re-emit
339+
// session.init (would be benign but adds noise)
340+
func TestAdapter_SynthesisesSessionInitFromFirstUsage(t *testing.T) {
341+
cwd := "/home/test/proj-sinit"
342+
homeDir, projectDir := makeFakeHome(t, cwd)
343+
jsonl := filepath.Join(projectDir, "sess.jsonl")
344+
writeJSONL(t, jsonl,
345+
`{"type":"assistant","message":{"model":"claude-opus-4-7","content":[{"type":"text","text":"hi"}],"usage":{"input_tokens":1,"cache_read_input_tokens":100}}}`,
346+
`{"type":"assistant","message":{"model":"claude-opus-4-7","content":[{"type":"text","text":"more"}],"usage":{"input_tokens":2,"cache_read_input_tokens":200}}}`,
347+
)
348+
349+
p := &capturingPoster{}
350+
a, _ := NewAdapter(Config{AgentID: "ag", Workdir: cwd, Poster: p})
351+
a.HomeDir = homeDir
352+
a.SessionCutoff = time.Time{}
353+
354+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
355+
defer cancel()
356+
if err := a.Start(ctx); err != nil {
357+
t.Fatalf("Start: %v", err)
358+
}
359+
defer a.Stop()
360+
361+
// Both assistant frames produce text + usage. With one
362+
// synthetic session.init, that's 5 events total.
363+
got := waitForN(t, p, 5, 2*time.Second)
364+
365+
// Find the session.init.
366+
var initIdx = -1
367+
initCount := 0
368+
for i, ev := range got {
369+
if ev.kind == "session.init" {
370+
initCount++
371+
if initIdx < 0 {
372+
initIdx = i
373+
}
374+
}
375+
}
376+
if initCount != 1 {
377+
t.Fatalf("want exactly 1 session.init, got %d in %+v", initCount, got)
378+
}
379+
init := got[initIdx]
380+
if init.payload["engine"] != "claude-code" {
381+
t.Errorf("engine = %v, want claude-code", init.payload["engine"])
382+
}
383+
if init.payload["model"] != "claude-opus-4-7" {
384+
t.Errorf("model = %v, want claude-opus-4-7", init.payload["model"])
385+
}
386+
if init.payload["cwd"] != cwd {
387+
t.Errorf("cwd = %v, want %s", init.payload["cwd"], cwd)
388+
}
389+
// session.init must precede the FIRST usage event so mobile's
390+
// build-pass picks it up alongside the chip-driving values.
391+
for i := 0; i < initIdx; i++ {
392+
if got[i].kind == "usage" {
393+
t.Errorf("usage at index %d landed BEFORE session.init at %d: %+v",
394+
i, initIdx, got)
395+
}
396+
}
397+
}

hub/internal/drivers/local_log_tail/claude_code/mapper.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,16 @@ func usageFromMessage(model string, raw json.RawMessage) *MappedEvent {
160160
}
161161
if model != "" {
162162
payload["model"] = model
163+
// v1.0.667: include context_window so mobile's telemetry
164+
// strip can render the context-utilisation chip. Mobile's
165+
// chip suppresses itself when contextWindow is zero (it
166+
// can't compute the pct), so without this the chip stayed
167+
// blank on M4 even though usage events were flowing. All
168+
// current claude-* models are 200K; future models would
169+
// extend the switch in claudeModelContextWindow.
170+
if cw := claudeModelContextWindow(model); cw > 0 {
171+
payload["context_window"] = cw
172+
}
163173
}
164174
return &MappedEvent{
165175
Kind: "usage",
@@ -168,6 +178,31 @@ func usageFromMessage(model string, raw json.RawMessage) *MappedEvent {
168178
}
169179
}
170180

181+
// claudeModelContextWindow returns the context window of a claude
182+
// model identifier in tokens, or 0 if the identifier is unrecognised
183+
// (mobile then suppresses the chip rather than rendering a wrong %).
184+
//
185+
// Source: Anthropic public model docs (2026-05). claude-opus-4-*,
186+
// claude-sonnet-4-*, and claude-haiku-4-* all ship with a 200K
187+
// context window. claude-3-* are kept on the legacy 200K too. When a
188+
// new model size ships with a different capacity, add the prefix
189+
// here — keeping the map small + explicit so we don't fall back to
190+
// a wrong default.
191+
func claudeModelContextWindow(model string) int {
192+
const k200 = 200_000
193+
for _, prefix := range []string{
194+
"claude-opus-",
195+
"claude-sonnet-",
196+
"claude-haiku-",
197+
"claude-3-",
198+
} {
199+
if strings.HasPrefix(model, prefix) {
200+
return k200
201+
}
202+
}
203+
return 0
204+
}
205+
171206
func mapAssistantBlock(raw json.RawMessage) *MappedEvent {
172207
var b struct {
173208
Type string `json:"type"`

hub/internal/drivers/local_log_tail/claude_code/mapper_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,57 @@ func TestMapLine_AssistantWithoutUsageEmitsOnlyContent(t *testing.T) {
277277
}
278278
}
279279

280+
// v1.0.667 — usage events MUST carry context_window when the model
281+
// name resolves to a known capacity. Without it mobile's
282+
// context-utilisation chip suppresses itself entirely (cw==0 → no
283+
// tile). All current claude-* models are 200K.
284+
func TestMapLine_UsageCarriesContextWindowFromModel(t *testing.T) {
285+
for _, model := range []string{
286+
"claude-opus-4-7",
287+
"claude-sonnet-4-6",
288+
"claude-haiku-4-5-20251001",
289+
"claude-3-5-sonnet-20240620",
290+
} {
291+
raw := `{"type":"assistant","message":{
292+
"model":"` + model + `",
293+
"content":[{"type":"text","text":"x"}],
294+
"usage":{"input_tokens":1,"output_tokens":1}
295+
}}`
296+
got := mustMap(t, raw)
297+
var usage *MappedEvent
298+
for i := range got {
299+
if got[i].Kind == "usage" {
300+
usage = &got[i]
301+
}
302+
}
303+
if usage == nil {
304+
t.Fatalf("%s: usage not emitted: %+v", model, got)
305+
}
306+
if cw, _ := usage.Payload["context_window"].(int); cw != 200_000 {
307+
t.Errorf("%s: context_window = %v, want 200000", model, usage.Payload["context_window"])
308+
}
309+
}
310+
}
311+
312+
// An unrecognised model name must NOT have a context_window field —
313+
// better blank than wrong. Mobile then suppresses the chip.
314+
func TestMapLine_UsageOmitsContextWindowForUnknownModel(t *testing.T) {
315+
raw := `{"type":"assistant","message":{
316+
"model":"gpt-99-future",
317+
"content":[{"type":"text","text":"x"}],
318+
"usage":{"input_tokens":1,"output_tokens":1}
319+
}}`
320+
got := mustMap(t, raw)
321+
for _, ev := range got {
322+
if ev.Kind != "usage" {
323+
continue
324+
}
325+
if _, has := ev.Payload["context_window"]; has {
326+
t.Errorf("unknown model emitted context_window: %v", ev.Payload)
327+
}
328+
}
329+
}
330+
280331
// An assistant message with all-zero usage fields must NOT emit a
281332
// usage event — the chip would be no better off seeing a {0,0,0}
282333
// snapshot than no event at all, and the zero would replace a real

lib/widgets/agent_feed.dart

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,6 +1117,15 @@ class _AgentFeedState extends ConsumerState<AgentFeed> {
11171117
if (i != null) perMessageInput = i;
11181118
if (cr != null) perMessageCacheRead = cr;
11191119
if (cc != null) perMessageCacheCreate = cc;
1120+
// v1.0.667: pick up `context_window` if present. The M4
1121+
// mapper attaches it (derived from model name) so mobile can
1122+
// render the context-utilisation chip. Without it the chip
1123+
// suppresses itself (cw <= 0 → no tile). The cumulative
1124+
// branch above already handled this; the per-message branch
1125+
// didn't, leaving the chip blank for claude-code spawns
1126+
// even though usage was flowing.
1127+
final cw = (p['context_window'] as num?)?.toInt() ?? 0;
1128+
if (cw > 0) latestContextWindow = cw;
11201129
}
11211130
}
11221131
// If no by_model rows arrived (codex's turn/completed doesn't
@@ -1395,6 +1404,17 @@ class _AgentFeedState extends ConsumerState<AgentFeed> {
13951404
// mean a turn is in progress. Skip and keep scanning so a
13961405
// real text/tool_call signal can win.
13971406
if (kind == 'system') continue;
1407+
// v1.0.667: 'usage' is per-message telemetry too — the
1408+
// claude-code M4 adapter posts a usage frame AFTER each
1409+
// assistant message AND after each turn's Stop hook
1410+
// (turn.result). Concretely the wire order for an end-of-turn
1411+
// is: turn.result → text → usage. Reading from the tail back,
1412+
// the LATEST agent event is `usage`, which fell through to
1413+
// the default "return true" branch — keeping the busy pill
1414+
// stuck on forever even after turn.result fired. Treat usage
1415+
// as pure telemetry (same as 'rate_limit', also skipped via
1416+
// _isHiddenInFeed but not enumerated here pre-v1.0.667).
1417+
if (kind == 'usage' || kind == 'rate_limit') continue;
13981418
// Any other agent-produced kind — text streaming, thought,
13991419
// tool_call mid-flight, plan, raw, etc. — means the turn is
14001420
// still in motion.

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
1616
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
1717
# In Windows, build-name is used as the major, minor, and patch parts
1818
# of the product and file versions while build-number is used as the build suffix.
19-
version: 1.0.666-alpha+10666
19+
version: 1.0.667-alpha+10667
2020

2121
environment:
2222
sdk: ^3.10.7

0 commit comments

Comments
 (0)