@agentdeck/bridge 0.2.3 → 1.0.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.
Files changed (188) hide show
  1. package/assets/fm-helper/agentdeck-fm-helper +0 -0
  2. package/dist/apme/antigravity-transcript.d.ts.map +1 -1
  3. package/dist/apme/antigravity-transcript.js +16 -3
  4. package/dist/apme/antigravity-transcript.js.map +1 -1
  5. package/dist/apme/collector.d.ts +4 -0
  6. package/dist/apme/collector.d.ts.map +1 -1
  7. package/dist/apme/collector.js +1 -0
  8. package/dist/apme/collector.js.map +1 -1
  9. package/dist/apme/dashboard-html.d.ts.map +1 -1
  10. package/dist/apme/dashboard-html.js +30 -0
  11. package/dist/apme/dashboard-html.js.map +1 -1
  12. package/dist/apme/http.d.ts.map +1 -1
  13. package/dist/apme/http.js +9 -0
  14. package/dist/apme/http.js.map +1 -1
  15. package/dist/apme/index.d.ts.map +1 -1
  16. package/dist/apme/index.js +14 -3
  17. package/dist/apme/index.js.map +1 -1
  18. package/dist/apme/judge-detect.d.ts +26 -0
  19. package/dist/apme/judge-detect.d.ts.map +1 -0
  20. package/dist/apme/judge-detect.js +69 -0
  21. package/dist/apme/judge-detect.js.map +1 -0
  22. package/dist/apme/runner.d.ts +7 -0
  23. package/dist/apme/runner.d.ts.map +1 -1
  24. package/dist/apme/runner.js +188 -33
  25. package/dist/apme/runner.js.map +1 -1
  26. package/dist/apme/settings.d.ts +10 -8
  27. package/dist/apme/settings.d.ts.map +1 -1
  28. package/dist/apme/settings.js +11 -16
  29. package/dist/apme/settings.js.map +1 -1
  30. package/dist/awaiting-overlay.d.ts +17 -4
  31. package/dist/awaiting-overlay.d.ts.map +1 -1
  32. package/dist/awaiting-overlay.js +39 -8
  33. package/dist/awaiting-overlay.js.map +1 -1
  34. package/dist/bridge-core.d.ts +10 -0
  35. package/dist/bridge-core.d.ts.map +1 -1
  36. package/dist/bridge-core.js +50 -0
  37. package/dist/bridge-core.js.map +1 -1
  38. package/dist/claude-permission-rules.d.ts +55 -0
  39. package/dist/claude-permission-rules.d.ts.map +1 -0
  40. package/dist/claude-permission-rules.js +197 -0
  41. package/dist/claude-permission-rules.js.map +1 -0
  42. package/dist/cli.d.ts.map +1 -1
  43. package/dist/cli.js +10 -1
  44. package/dist/cli.js.map +1 -1
  45. package/dist/d200h/hid-protocol.d.ts +62 -0
  46. package/dist/d200h/hid-protocol.d.ts.map +1 -0
  47. package/dist/d200h/hid-protocol.js +124 -0
  48. package/dist/d200h/hid-protocol.js.map +1 -0
  49. package/dist/d200h/image-renderer.d.ts +24 -0
  50. package/dist/d200h/image-renderer.d.ts.map +1 -0
  51. package/dist/d200h/image-renderer.js +321 -0
  52. package/dist/d200h/image-renderer.js.map +1 -0
  53. package/dist/daemon-server.d.ts.map +1 -1
  54. package/dist/daemon-server.js +374 -26
  55. package/dist/daemon-server.js.map +1 -1
  56. package/dist/daemon.js +1 -1
  57. package/dist/esp32-serial.d.ts +40 -0
  58. package/dist/esp32-serial.d.ts.map +1 -1
  59. package/dist/esp32-serial.js +203 -11
  60. package/dist/esp32-serial.js.map +1 -1
  61. package/dist/fallback-task-timeline.d.ts.map +1 -1
  62. package/dist/fallback-task-timeline.js +9 -2
  63. package/dist/fallback-task-timeline.js.map +1 -1
  64. package/dist/hook-server.d.ts.map +1 -1
  65. package/dist/hook-server.js +6 -5
  66. package/dist/hook-server.js.map +1 -1
  67. package/dist/idotmatrix/idotmatrix-daemon-sync.js +2 -2
  68. package/dist/idotmatrix/idotmatrix-daemon-sync.js.map +1 -1
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +2 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/logger.d.ts +3 -1
  73. package/dist/logger.d.ts.map +1 -1
  74. package/dist/logger.js +6 -2
  75. package/dist/logger.js.map +1 -1
  76. package/dist/model-catalog.d.ts +1 -1
  77. package/dist/model-catalog.d.ts.map +1 -1
  78. package/dist/model-catalog.js +2 -4
  79. package/dist/model-catalog.js.map +1 -1
  80. package/dist/modules/d200h-module.d.ts +65 -0
  81. package/dist/modules/d200h-module.d.ts.map +1 -0
  82. package/dist/modules/d200h-module.js +495 -0
  83. package/dist/modules/d200h-module.js.map +1 -0
  84. package/dist/modules/d200h-renderer.d.ts +7 -0
  85. package/dist/modules/d200h-renderer.d.ts.map +1 -0
  86. package/dist/modules/d200h-renderer.js +321 -0
  87. package/dist/modules/d200h-renderer.js.map +1 -0
  88. package/dist/modules/trmnl-module.d.ts +38 -0
  89. package/dist/modules/trmnl-module.d.ts.map +1 -0
  90. package/dist/modules/trmnl-module.js +111 -0
  91. package/dist/modules/trmnl-module.js.map +1 -0
  92. package/dist/observed-steering.d.ts +93 -0
  93. package/dist/observed-steering.d.ts.map +1 -0
  94. package/dist/observed-steering.js +247 -0
  95. package/dist/observed-steering.js.map +1 -0
  96. package/dist/opencode-steering.d.ts +36 -0
  97. package/dist/opencode-steering.d.ts.map +1 -0
  98. package/dist/opencode-steering.js +84 -0
  99. package/dist/opencode-steering.js.map +1 -0
  100. package/dist/output-parser.d.ts.map +1 -1
  101. package/dist/output-parser.js +48 -10
  102. package/dist/output-parser.js.map +1 -1
  103. package/dist/passive-observer.d.ts +6 -0
  104. package/dist/passive-observer.d.ts.map +1 -1
  105. package/dist/passive-observer.js +31 -1
  106. package/dist/passive-observer.js.map +1 -1
  107. package/dist/permission-resolver.d.ts +13 -5
  108. package/dist/permission-resolver.d.ts.map +1 -1
  109. package/dist/permission-resolver.js +18 -9
  110. package/dist/permission-resolver.js.map +1 -1
  111. package/dist/pixoo/micro-glyphs.d.ts +10 -41
  112. package/dist/pixoo/micro-glyphs.d.ts.map +1 -1
  113. package/dist/pixoo/micro-glyphs.js +130 -263
  114. package/dist/pixoo/micro-glyphs.js.map +1 -1
  115. package/dist/pixoo/official-dot-glyphs.generated.d.ts +8 -0
  116. package/dist/pixoo/official-dot-glyphs.generated.d.ts.map +1 -0
  117. package/dist/pixoo/official-dot-glyphs.generated.js +247 -0
  118. package/dist/pixoo/official-dot-glyphs.generated.js.map +1 -0
  119. package/dist/pixoo/pixoo-bridge.d.ts +14 -6
  120. package/dist/pixoo/pixoo-bridge.d.ts.map +1 -1
  121. package/dist/pixoo/pixoo-bridge.js +63 -39
  122. package/dist/pixoo/pixoo-bridge.js.map +1 -1
  123. package/dist/pixoo/pixoo-renderer.d.ts +21 -2
  124. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  125. package/dist/pixoo/pixoo-renderer.js +401 -132
  126. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  127. package/dist/pixoo/pixoo-sprites.d.ts +24 -0
  128. package/dist/pixoo/pixoo-sprites.d.ts.map +1 -1
  129. package/dist/pixoo/pixoo-sprites.js +108 -0
  130. package/dist/pixoo/pixoo-sprites.js.map +1 -1
  131. package/dist/review-runner.d.ts +113 -0
  132. package/dist/review-runner.d.ts.map +1 -0
  133. package/dist/review-runner.js +428 -0
  134. package/dist/review-runner.js.map +1 -0
  135. package/dist/session-aggregator.d.ts +8 -0
  136. package/dist/session-aggregator.d.ts.map +1 -1
  137. package/dist/session-aggregator.js.map +1 -1
  138. package/dist/session-focus-relay.d.ts.map +1 -1
  139. package/dist/session-focus-relay.js +7 -1
  140. package/dist/session-focus-relay.js.map +1 -1
  141. package/dist/session-registry.d.ts +3 -0
  142. package/dist/session-registry.d.ts.map +1 -1
  143. package/dist/session-registry.js +3 -0
  144. package/dist/session-registry.js.map +1 -1
  145. package/dist/timeline-store.d.ts +49 -0
  146. package/dist/timeline-store.d.ts.map +1 -1
  147. package/dist/timeline-store.js +175 -7
  148. package/dist/timeline-store.js.map +1 -1
  149. package/dist/trmnl/byos-server.d.ts +39 -0
  150. package/dist/trmnl/byos-server.d.ts.map +1 -0
  151. package/dist/trmnl/byos-server.js +301 -0
  152. package/dist/trmnl/byos-server.js.map +1 -0
  153. package/dist/trmnl/frame-cache.d.ts +37 -0
  154. package/dist/trmnl/frame-cache.d.ts.map +1 -0
  155. package/dist/trmnl/frame-cache.js +157 -0
  156. package/dist/trmnl/frame-cache.js.map +1 -0
  157. package/dist/trmnl/image-renderer.d.ts +27 -0
  158. package/dist/trmnl/image-renderer.d.ts.map +1 -0
  159. package/dist/trmnl/image-renderer.js +235 -0
  160. package/dist/trmnl/image-renderer.js.map +1 -0
  161. package/dist/trmnl/trmnl-settings.d.ts +96 -0
  162. package/dist/trmnl/trmnl-settings.d.ts.map +1 -0
  163. package/dist/trmnl/trmnl-settings.js +177 -0
  164. package/dist/trmnl/trmnl-settings.js.map +1 -0
  165. package/dist/trmnl/trmnl-telemetry.d.ts +47 -0
  166. package/dist/trmnl/trmnl-telemetry.d.ts.map +1 -0
  167. package/dist/trmnl/trmnl-telemetry.js +56 -0
  168. package/dist/trmnl/trmnl-telemetry.js.map +1 -0
  169. package/dist/tui/dashboard.d.ts.map +1 -1
  170. package/dist/tui/dashboard.js +14 -4
  171. package/dist/tui/dashboard.js.map +1 -1
  172. package/dist/tui/renderer.d.ts.map +1 -1
  173. package/dist/tui/renderer.js +63 -13
  174. package/dist/tui/renderer.js.map +1 -1
  175. package/dist/tui/terrarium.d.ts.map +1 -1
  176. package/dist/tui/terrarium.js +7 -2
  177. package/dist/tui/terrarium.js.map +1 -1
  178. package/dist/usage-event.d.ts.map +1 -1
  179. package/dist/usage-event.js +43 -2
  180. package/dist/usage-event.js.map +1 -1
  181. package/dist/version-check.d.ts.map +1 -1
  182. package/dist/version-check.js +1 -17
  183. package/dist/version-check.js.map +1 -1
  184. package/dist/ws-server.d.ts +0 -7
  185. package/dist/ws-server.d.ts.map +1 -1
  186. package/dist/ws-server.js +5 -20
  187. package/dist/ws-server.js.map +1 -1
  188. package/package.json +4 -4
@@ -19,11 +19,13 @@
19
19
  */
20
20
  import { State } from '../types.js';
21
21
  import { PASSIVE_OFFLINE_LABEL } from '@agentdeck/shared';
22
- import { hasOpenClawSession } from '@agentdeck/shared';
22
+ import { hasOpenClawSession, foldCodexSessionsForDisplay } from '@agentdeck/shared';
23
+ import { layoutOctopuses, layoutCloudCreatures, layoutOpenCodeCreatures, layoutAntigravityCreatures, } from '@agentdeck/shared';
23
24
  import { drawTextCentered } from './pixoo-font.js';
24
- import { COLORS, setPixel, blendPixel, glowPixel, lerpColor, drawOctopus, drawJellyfish, drawOpenCode, drawAntigravity, drawCrayfish, drawTetra, drawText, getOctopusPaletteForSession, getJellyfishPaletteForSession, getOpenCodePaletteForSession, getAntigravityPaletteForSession, } from './pixoo-sprites.js';
25
+ import { COLORS, setPixel, blendPixel, glowPixel, lerpColor, drawOfficialDotGlyph, drawTetra, drawText, getOctopusPaletteForSession, getJellyfishPaletteForSession, getOpenCodePaletteForSession, getAntigravityPaletteForSession, } from './pixoo-sprites.js';
25
26
  import { blitWithCamera, quantizeCameraPixels, updateDirector, WORLD_SIZE, ACTIVE_SIZE, } from './pixoo-camera.js';
26
- import { MICRO_SIZE, microStatusBg, paintMicroGlyph, } from './micro-glyphs.js';
27
+ import { MICRO_SIZE, paintTimeboxBeacon, } from './micro-glyphs.js';
28
+ import { OFFICIAL_DOT_GLYPHS, OFFICIAL_DOT_GLYPH_SIZE, } from './official-dot-glyphs.generated.js';
27
29
  const W = WORLD_SIZE;
28
30
  const ACTIVE_OFFSET = (WORLD_SIZE - ACTIVE_SIZE) / 2; // 16
29
31
  // Track last render time for accurate dt calculation
@@ -48,25 +50,58 @@ const JELLYFISH_AGENTS = new Set(['codex-cli', 'codex-app']);
48
50
  const OPENCODE_AGENTS = new Set(['opencode']);
49
51
  /** Agent types drawn as the Antigravity peak/arc mark. */
50
52
  const ANTIGRAVITY_AGENTS = new Set(['antigravity']);
51
- // Y positions by state — idle nearly on sand, active higher up
52
- const IDLE_Y = 0.78; // just above sand line (sleeping on ground)
53
- const WORKING_Y = 0.42; // mid-water (working/starburst)
54
- const ASKING_Y = 0.38; // slightly higher (room for "?" bubble)
55
- function stateY(state) {
56
- if (state === 'processing')
57
- return WORKING_Y;
58
- if (state === 'awaiting')
59
- return ASKING_Y;
60
- return IDLE_Y;
61
- }
62
- function stateYForType(state, creatureType) {
63
- if (creatureType !== 'antigravity')
64
- return stateY(state);
65
- if (state === 'processing')
66
- return 0.30;
67
- if (state === 'awaiting')
68
- return 0.48;
69
- return 0.62;
53
+ function clamp(value, min, max) {
54
+ return Math.min(max, Math.max(min, value));
55
+ }
56
+ /**
57
+ * Vertical stratification per creature type, applied on top of the band's base
58
+ * Y. Each type keeps its own water layer so a mixed Claude+Codex+OpenCode tank
59
+ * stays readable: clouds ride near the surface while working and sink to answer,
60
+ * octopuses hold mid-water, and everything settles to the sand when idle.
61
+ *
62
+ * Mirror of `stateY(_:kind:baseY:)` in apple/AgentDeck/Daemon/Modules/PixooRenderer.swift.
63
+ */
64
+ function stateYForType(state, creatureType, baseY) {
65
+ switch (creatureType) {
66
+ case 'octopus':
67
+ if (state === 'processing')
68
+ return clamp(baseY, 0.40, 0.54);
69
+ if (state === 'awaiting')
70
+ return clamp(baseY - 0.04, 0.35, 0.48);
71
+ return 0.80;
72
+ case 'jellyfish':
73
+ if (state === 'processing')
74
+ return clamp(baseY, 0.16, 0.28);
75
+ if (state === 'awaiting')
76
+ return clamp(baseY + 0.26, 0.52, 0.64);
77
+ return clamp(baseY + 0.40, 0.80, 0.82);
78
+ case 'opencode':
79
+ if (state === 'processing')
80
+ return clamp(baseY - 0.02, 0.20, 0.34);
81
+ if (state === 'awaiting')
82
+ return clamp(baseY + 0.22, 0.50, 0.62);
83
+ return clamp(baseY + 0.36, 0.79, 0.81);
84
+ case 'antigravity':
85
+ if (state === 'processing')
86
+ return clamp(baseY - 0.04, 0.16, 0.30);
87
+ if (state === 'awaiting')
88
+ return clamp(baseY + 0.22, 0.46, 0.54);
89
+ return clamp(baseY + 0.34, 0.56, 0.64);
90
+ }
91
+ }
92
+ function slotsForType(creatureType, count) {
93
+ switch (creatureType) {
94
+ case 'octopus': return layoutOctopuses(count);
95
+ case 'jellyfish': return layoutCloudCreatures(count);
96
+ case 'opencode': return layoutOpenCodeCreatures(count);
97
+ case 'antigravity': return layoutAntigravityCreatures(count);
98
+ }
99
+ }
100
+ const FALLBACK_SLOT = { x: 0.38, y: 0.42, scale: 1.0 };
101
+ function slotAt(slots, index) {
102
+ if (slots.length === 0)
103
+ return FALLBACK_SLOT;
104
+ return slots[Math.min(index, slots.length - 1)];
70
105
  }
71
106
  /** Check if agent type gets a creature. */
72
107
  function isCreatureAgent(agentType) {
@@ -82,11 +117,15 @@ function creatureTypeFor(agentType) {
82
117
  return 'octopus';
83
118
  }
84
119
  function syncCreatures(sessions, stateEvent) {
85
- // Determine which sessions are alive creature agents (octopus or jellyfish)
120
+ // Determine which sessions are alive creature agents (octopus or jellyfish).
121
+ // Codex rows fold by project first: each Claude Code rescue/stop-gate spawns a
122
+ // fresh codex thread, so without folding one workspace lights up 4-5 cloud
123
+ // marks at once. Octopus / opencode are NOT folded — running several of those
124
+ // against one project is a deliberate user pattern.
86
125
  const aliveCoding = [];
87
126
  if (sessions) {
88
- for (const s of sessions) {
89
- if (s.alive && s.agentType && isCreatureAgent(s.agentType)) {
127
+ for (const s of foldCodexSessionsForDisplay(sessions.filter(s => s.alive))) {
128
+ if (s.agentType && isCreatureAgent(s.agentType)) {
90
129
  aliveCoding.push({ id: s.id, agentType: s.agentType, state: s.state ?? 'idle' });
91
130
  }
92
131
  }
@@ -106,22 +145,40 @@ function syncCreatures(sessions, stateEvent) {
106
145
  creatureInstances.delete(id);
107
146
  }
108
147
  }
148
+ // Each agent type owns an X band and wraps to a second/third row as it fills,
149
+ // so a mixed tank clusters by agent instead of interleaving along one line.
150
+ const typeCounts = new Map();
151
+ for (const s of aliveCoding) {
152
+ const t = creatureTypeFor(s.agentType);
153
+ typeCounts.set(t, (typeCounts.get(t) ?? 0) + 1);
154
+ }
155
+ const slotsByType = new Map();
156
+ for (const [type, count] of typeCounts)
157
+ slotsByType.set(type, slotsForType(type, count));
158
+ const typeIndices = new Map();
159
+ // Band base Y per session, kept so the `_primary` refresh below can re-derive
160
+ // its stratified Y from the band instead of compounding onto an already
161
+ // stratified worldY.
162
+ const baseYById = new Map();
109
163
  // Add/update creatures
110
164
  for (let i = 0; i < aliveCoding.length; i++) {
111
165
  const s = aliveCoding[i];
112
166
  const existing = creatureInstances.get(s.id);
113
167
  const sessionState = mapSessionState(s.state);
114
168
  const creatureType = creatureTypeFor(s.agentType);
115
- // Uniformly distribute X positions to maximize spacing and prevent overlap
116
- const x = aliveCoding.length === 1
117
- ? (creatureType === 'antigravity' ? 0.68 : 0.38) // single session: type-native band
118
- : 0.15 + (i / (aliveCoding.length - 1)) * 0.70;
169
+ const slotIndex = typeIndices.get(creatureType) ?? 0;
170
+ typeIndices.set(creatureType, slotIndex + 1);
171
+ const slot = slotAt(slotsByType.get(creatureType) ?? [], slotIndex);
172
+ const x = slot.x;
173
+ baseYById.set(s.id, slot.y);
174
+ const worldY = stateYForType(sessionState, creatureType, slot.y);
119
175
  if (existing) {
120
176
  existing.state = sessionState;
121
177
  existing.agentType = s.agentType;
122
178
  existing.creatureType = creatureType;
123
179
  existing.worldX = x; // Update X dynamically to maintain even spacing
124
- existing.worldY = stateYForType(sessionState, creatureType);
180
+ existing.worldY = worldY;
181
+ existing.sizeScale = slot.scale;
125
182
  }
126
183
  else {
127
184
  creatureInstances.set(s.id, {
@@ -130,22 +187,28 @@ function syncCreatures(sessions, stateEvent) {
130
187
  creatureType,
131
188
  state: sessionState,
132
189
  worldX: x,
133
- worldY: stateYForType(sessionState, creatureType),
190
+ worldY,
134
191
  phaseOffset: i * 5,
192
+ sizeScale: slot.scale,
135
193
  });
136
194
  }
137
195
  }
138
- // Override primary session state from stateEvent (more precise than polling)
139
- // Only when stateEvent is from a creature agent daemon/openclaw report stale IDLE
196
+ // Refresh the synthetic `_primary` creature from stateEvent it *is* the
197
+ // stateEvent, so this keeps it precise before the first sessions_list lands.
198
+ //
199
+ // Real sessions are deliberately left alone. stateEvent.sessionId can name a
200
+ // row that folding has already collapsed into a representative, and stamping
201
+ // that member's state onto the representative would downgrade a group the fold
202
+ // resolved to `processing` down to the one idle sibling the event came from.
203
+ // The sessions list is daemon-pushed and already carries per-session state.
140
204
  const aType = stateEvent?.agentType;
141
205
  const isCreature = isCreatureAgent(aType ?? '');
142
- if (stateEvent && isCreature && aliveCoding.length > 0) {
143
- const primaryId = aliveCoding[0].id;
144
- const primary = creatureInstances.get(primaryId);
206
+ if (stateEvent && isCreature) {
207
+ const primary = creatureInstances.get('_primary');
145
208
  if (primary) {
146
209
  const st = simplifiedState(stateEvent.state ?? State.IDLE);
147
210
  primary.state = st;
148
- primary.worldY = stateYForType(st, primary.creatureType);
211
+ primary.worldY = stateYForType(st, primary.creatureType, baseYById.get('_primary') ?? primary.worldY);
149
212
  }
150
213
  }
151
214
  }
@@ -474,7 +537,7 @@ function simplifiedState(state) {
474
537
  }
475
538
  // ===== Usage HUD Helpers =====
476
539
  /** Gauge bar color based on usage percentage. */
477
- function gaugeColor(pct, animFrame) {
540
+ function gaugeColor(pct, animFrame, brand) {
478
541
  if (pct >= 90) {
479
542
  // Red with pulse
480
543
  const pulse = (Math.sin(animFrame * 0.2) + 1) * 0.3;
@@ -482,9 +545,7 @@ function gaugeColor(pct, animFrame) {
482
545
  }
483
546
  if (pct >= 70)
484
547
  return COLORS.stateAwaiting; // amber
485
- if (pct >= 50)
486
- return [0x00, 0xC8, 0xB4]; // teal
487
- return COLORS.stateProcessing; // blue
548
+ return brand;
488
549
  }
489
550
  /** Pixoo HUD reset time: "1h23", "4d6", "59m". */
490
551
  export function formatResetDetailed(resetsAt) {
@@ -508,78 +569,155 @@ export function formatResetDetailed(resetsAt) {
508
569
  return `${hours}h`;
509
570
  return `${mins}m`;
510
571
  }
511
- /** Draw Usage HUD in screen space (bottom right, zoom-independent).
512
- * Single row at rows 57-63:
513
- * - 7d absent: full-width, right-aligned (original behavior)
514
- * - 7d present: left half [0..30]=5h | right half [32..63]=7d
572
+ /** Draw Usage HUD in screen space (bottom, zoom-independent).
573
+ * Each provider uses the same seven-pixel band with its official creature
574
+ * silhouette, full-height usage fill, percentage and reset countdown. When both are present Claude occupies
575
+ * rows 50-56 and Codex rows 57-63; a lone provider stays on rows 57-63.
576
+ * Primary/5h is the left zone and secondary/7d is the right zone.
577
+ * When Codex only reports 7d, its subscription date occupies the otherwise
578
+ * empty primary zone.
515
579
  */
516
580
  function drawUsageHUD(buf, usageEvent, animFrame) {
517
- // Hide the HUD entirely when upstream flags the data as stale — otherwise
518
- // an old "12%" frozen on-screen looks authoritative to the user even though
519
- // the CLI daemon isn't live to produce a fresh fetch. Matches the collapse
520
- // behavior on every other surface (macOS dashboard, plugin, Android, D200H).
521
- if (!usageEvent || usageEvent.fiveHourPercent == null)
522
- return;
523
- if (usageEvent.usageStale === true)
581
+ if (!usageEvent)
524
582
  return;
525
- const w = Math.sqrt(buf.length / 3);
526
- const textY = w === 32 ? 26 : 58;
527
- const bgTop = textY - 1;
528
- const bgBot = textY + 5;
529
- // Full-width dark base hides sand/terrain at HUD rows regardless of camera zoom
530
- for (let y = bgTop; y <= bgBot; y++) {
531
- for (let x = 0; x < w; x++) {
532
- blendPixel(buf, x, y, COLORS.black, 0.55);
533
- }
583
+ const providers = [];
584
+ if (usageEvent.usageStale !== true && usageEvent.fiveHourPercent != null) {
585
+ providers.push({
586
+ glyph: 'claudeCode', brand: [255, 112, 76],
587
+ primary: { percent: usageEvent.fiveHourPercent, resetsAt: usageEvent.fiveHourResetsAt },
588
+ secondary: usageEvent.sevenDayPercent == null ? undefined : {
589
+ percent: usageEvent.sevenDayPercent, resetsAt: usageEvent.sevenDayResetsAt,
590
+ },
591
+ });
592
+ }
593
+ // A window counts as renderable only when it is both fresh AND carries a
594
+ // number. "Not stale but no percent" is a malformed frame, not a zero — the
595
+ // gauge must stay absent rather than draw an authoritative-looking 0%.
596
+ // Mirror of `freshCodexWindow` in apple/AgentDeck/Daemon/Modules/PixooRenderer.swift.
597
+ const freshCodexWindow = (w) => w && w.stale !== true && w.usedPercent != null
598
+ ? { percent: w.usedPercent, resetsAt: w.resetsAt }
599
+ : undefined;
600
+ const codexPrimaryWindow = freshCodexWindow(usageEvent.codexRateLimits?.primary);
601
+ const codexSecondaryWindow = freshCodexWindow(usageEvent.codexRateLimits?.secondary);
602
+ if (codexPrimaryWindow || codexSecondaryWindow) {
603
+ providers.push({
604
+ glyph: 'codex', brand: [126, 116, 255],
605
+ primary: codexPrimaryWindow,
606
+ secondary: codexSecondaryWindow,
607
+ subscriptionUntil: usageEvent.codexSubscriptionActiveUntil,
608
+ });
534
609
  }
610
+ if (providers.length === 0)
611
+ return;
535
612
  const timeColor = [0x60, 0x70, 0x80];
536
- /** Render a zone: usage background fill + two-color text (pct + time). */
537
- function renderZone(pctText, timeText, pct, leftX, rightX) {
538
- const color = gaugeColor(pct, animFrame);
539
- const zoneW = rightX - leftX + 1;
540
- const fillW = Math.round(zoneW * Math.max(0, Math.min(100, pct)) / 100);
541
- // Background fill proportional to usage
542
- for (let y = bgTop; y <= bgBot; y++) {
543
- for (let x = leftX; x < leftX + fillW; x++) {
544
- blendPixel(buf, x, y, color, 0.35);
613
+ const firstY = providers.length > 1 ? 50 : 57;
614
+ function drawCreatureMarker(provider, rowY) {
615
+ const mask = OFFICIAL_DOT_GLYPHS[provider.glyph];
616
+ const sourceSize = OFFICIAL_DOT_GLYPH_SIZE;
617
+ // Sample the canonical square canvas instead of cropping its occupied
618
+ // bounds. Claude's source silhouette is intentionally wider than it is
619
+ // tall; cropping and stretching those bounds to 7x7 turns the robot into
620
+ // a square. The 9px slot leaves one physical LED on both sides.
621
+ const markerSize = 7;
622
+ const markerX = 1;
623
+ for (let dy = 0; dy < 7; dy++) {
624
+ const sy = Math.min(sourceSize - 1, Math.floor((dy + 0.5) * sourceSize / markerSize));
625
+ for (let dx = 0; dx < markerSize; dx++) {
626
+ const sx = Math.min(sourceSize - 1, Math.floor((dx + 0.5) * sourceSize / markerSize));
627
+ const alpha = mask[sy * sourceSize + sx];
628
+ if (alpha <= 16)
629
+ continue;
630
+ const intensity = alpha >= 96 ? 1 : 0.56;
631
+ setPixel(buf, markerX + dx, rowY + dy, lerpColor(COLORS.black, provider.brand, intensity));
545
632
  }
546
633
  }
547
- // Two-color text: time (dimmed) right-aligned, then pct (gauge color) to its left
548
- if (timeText) {
549
- drawText(buf, timeText, rightX, textY, timeColor);
550
- const timeW = timeText.length * 4; // 3px glyph + 1px gap per char
551
- drawText(buf, pctText, rightX - timeW, textY, color);
634
+ }
635
+ function fittedReset(resetsAt, pctText, zoneWidth) {
636
+ const detailed = formatResetDetailed(resetsAt);
637
+ if (!detailed)
638
+ return '';
639
+ const maxChars = Math.floor(zoneWidth / 4);
640
+ if (pctText.length + detailed.length <= maxChars)
641
+ return detailed;
642
+ const unit = detailed.match(/^(\d+)[dhm]/);
643
+ return unit ? `${unit[1]}${unit[0].at(-1)}` : detailed.slice(0, Math.max(0, maxChars - pctText.length));
644
+ }
645
+ function subscriptionDate(until) {
646
+ const value = until?.trim();
647
+ if (!value)
648
+ return '';
649
+ const iso = value.match(/^\d{4}-(\d{1,2})-(\d{1,2})/);
650
+ if (iso)
651
+ return `${Number(iso[1])}/${Number(iso[2])}`;
652
+ const compact = value.match(/^~?(\d{1,2})\/(\d{1,2})$/);
653
+ return compact ? `${Number(compact[1])}/${Number(compact[2])}` : '';
654
+ }
655
+ function drawCenteredLabel(text, leftX, rightX, rowY) {
656
+ if (!text)
657
+ return;
658
+ const textWidth = text.length * 4 - 1;
659
+ const startX = leftX + Math.max(0, Math.floor((rightX - leftX - textWidth) / 2));
660
+ drawText(buf, text, startX + textWidth, rowY + 1, timeColor);
661
+ }
662
+ function renderWindow(window, leftX, rightX, rowY, brand) {
663
+ const pct = Math.max(0, Math.min(100, window.percent));
664
+ const color = gaugeColor(pct, animFrame, brand);
665
+ const zoneWidth = rightX - leftX;
666
+ const fillWidth = Math.round(zoneWidth * pct / 100);
667
+ // TC001-style fill: the used portion is a dim full-height color field, so
668
+ // the gauge reads at a glance while bright text remains crisp above it.
669
+ for (let y = rowY; y < rowY + 7; y++) {
670
+ for (let x = leftX; x < leftX + fillWidth; x++)
671
+ blendPixel(buf, x, y, color, 0.24);
672
+ }
673
+ const pctText = `${Math.round(pct)}%`;
674
+ const resetText = fittedReset(window.resetsAt, pctText, zoneWidth);
675
+ if (resetText) {
676
+ drawText(buf, resetText, rightX, rowY + 1, timeColor);
677
+ drawText(buf, pctText, rightX - resetText.length * 4, rowY + 1, color);
552
678
  }
553
679
  else {
554
- drawText(buf, pctText, rightX, textY, color);
680
+ drawText(buf, pctText, rightX, rowY + 1, color);
555
681
  }
556
682
  }
557
- const pct5 = usageEvent.fiveHourPercent;
558
- if (w === 32) {
559
- // Single compact zone for 32x32 screens
560
- const r5 = formatResetDetailed(usageEvent.fiveHourResetsAt);
561
- renderZone(`${Math.round(pct5)}%`, r5, pct5, 0, 31);
562
- return;
563
- }
564
- if (usageEvent.sevenDayPercent == null) {
565
- // Single full-width zone
566
- const r5 = formatResetDetailed(usageEvent.fiveHourResetsAt);
567
- renderZone(`${Math.round(pct5)}%`, r5, pct5, 0, 63);
568
- return;
569
- }
570
- // Two-column layout: [5h | 7d]
571
- const pct7 = usageEvent.sevenDayPercent;
572
- const r5 = formatResetDetailed(usageEvent.fiveHourResetsAt);
573
- const r7 = formatResetDetailed(usageEvent.sevenDayResetsAt);
574
- renderZone(`${Math.round(pct5)}%`, r5, pct5, 0, 30);
575
- renderZone(`${Math.round(pct7)}%`, r7, pct7, 32, 63);
683
+ providers.forEach((provider, index) => {
684
+ const rowY = firstY + index * 7;
685
+ for (let y = rowY; y < rowY + 7; y++) {
686
+ for (let x = 0; x < 64; x++) {
687
+ blendPixel(buf, x, y, COLORS.black, 0.70);
688
+ blendPixel(buf, x, y, provider.brand, 0.08);
689
+ }
690
+ }
691
+ // A slightly denser identity dock lets the official mark join the same
692
+ // color band without placing quota fill behind the silhouette itself.
693
+ for (let y = rowY; y < rowY + 7; y++) {
694
+ for (let x = 0; x < 9; x++)
695
+ blendPixel(buf, x, y, provider.brand, 0.12);
696
+ }
697
+ drawCreatureMarker(provider, rowY);
698
+ if (provider.primary && provider.secondary) {
699
+ for (let y = rowY + 1; y < rowY + 6; y++)
700
+ blendPixel(buf, 36, y, provider.brand, 0.28);
701
+ renderWindow(provider.primary, 9, 36, rowY, provider.brand);
702
+ renderWindow(provider.secondary, 37, 64, rowY, provider.brand);
703
+ }
704
+ else if (!provider.primary && provider.secondary && subscriptionDate(provider.subscriptionUntil)) {
705
+ for (let y = rowY + 1; y < rowY + 6; y++)
706
+ blendPixel(buf, 36, y, provider.brand, 0.28);
707
+ drawCenteredLabel(subscriptionDate(provider.subscriptionUntil), 9, 36, rowY);
708
+ renderWindow(provider.secondary, 37, 64, rowY, provider.brand);
709
+ }
710
+ else {
711
+ const only = provider.primary ?? provider.secondary;
712
+ if (only)
713
+ renderWindow(only, 9, 64, rowY, provider.brand);
714
+ }
715
+ });
576
716
  }
577
717
  /**
578
- * Render the micro layout natively at 11×11 using hand-authored creature glyphs,
579
- * then nearest-scale into the `size`×`size` output. The Timebox Mini has 121 LEDs;
580
- * downscaling the 32×32 terrarium bottoms out at a fuzzy silhouette, so micro draws
581
- * a bold per-pixel glyph (octopus/jellyfish/opencode/crayfish) on a dark
582
- * status-color field. The device fetches `size=11` for a pixel-perfect 1:1 frame.
718
+ * Render the Timebox Mini's native 11×11 Agent Beacon, then nearest-scale into
719
+ * the requested output. A stable 9×9 official mark carries identity while the
720
+ * one-pixel perimeter rail alone carries state and motion.
583
721
  */
584
722
  function renderMicroFrame(outputBuf, size, animFrame, stateEvent, sessions, usagePct) {
585
723
  // Presence-driven SSOT: the crayfish renders iff the daemon emitted an
@@ -597,28 +735,19 @@ function renderMicroFrame(outputBuf, size, animFrame, stateEvent, sessions, usag
597
735
  : dominant?.state === 'awaiting' ? 'awaiting'
598
736
  : (dominant?.state === 'processing' || (!dominant && routing)) ? 'processing'
599
737
  : 'idle';
600
- // Build the native 11×11 frame: dark status field + bold creature glyph.
601
738
  const base = new Uint8Array(MICRO_SIZE * MICRO_SIZE * 3);
602
- const bg = microStatusBg(aggregate, animFrame);
603
- for (let i = 0; i < MICRO_SIZE * MICRO_SIZE; i++) {
604
- base[i * 3] = bg[0];
605
- base[i * 3 + 1] = bg[1];
606
- base[i * 3 + 2] = bg[2];
607
- }
608
- const glyphState = dominant?.state === 'processing' ? 'working'
609
- : dominant?.state === 'awaiting' ? 'asking'
610
- : 'idle';
739
+ let creature = null;
611
740
  if (dominant) {
612
- const creature = dominant.agentType === 'antigravity' ? 'antigravity'
613
- : dominant.creatureType === 'jellyfish' ? 'jellyfish'
614
- : dominant.creatureType === 'opencode' ? 'opencode'
615
- : 'octopus';
616
- paintMicroGlyph(base, creature, glyphState, animFrame);
741
+ creature =
742
+ dominant.agentType === 'antigravity' ? 'antigravity'
743
+ : dominant.creatureType === 'jellyfish' ? 'jellyfish'
744
+ : dominant.creatureType === 'opencode' ? 'opencode'
745
+ : 'octopus';
617
746
  }
618
747
  else if (hasGateway) {
619
- paintMicroGlyph(base, 'crayfish', routing ? 'working' : 'idle', animFrame);
748
+ creature = 'crayfish';
620
749
  }
621
- // else: no creatures at all → the solid status field is the whole signal.
750
+ paintTimeboxBeacon(base, creature, aggregate, animFrame);
622
751
  // Scale the 11×11 base into the size×size output (1:1 when size === 11).
623
752
  for (let y = 0; y < size; y++) {
624
753
  const sy = Math.min(MICRO_SIZE - 1, Math.floor((y * MICRO_SIZE) / size));
@@ -632,12 +761,133 @@ function renderMicroFrame(outputBuf, size, animFrame, stateEvent, sessions, usag
632
761
  }
633
762
  }
634
763
  }
764
+ /** Native 32×32 iDotMatrix identity stage. The panel gets saturated official
765
+ * marks on a quiet field plus four fixed telemetry rails; no 64px scene is
766
+ * downsampled, so cutouts remain physical pixels. */
767
+ function renderCompact32Frame(outputBuf, animFrame, stateEvent, sessions, usageEvent) {
768
+ const set = (x, y, color) => setPixel(outputBuf, x, y, color);
769
+ const state = String(stateEvent?.state ?? State.IDLE);
770
+ for (let y = 0; y < 28; y++) {
771
+ const t = y / 27;
772
+ const color = [2 + Math.round(5 * t), 7 + Math.round(10 * t), 18 + Math.round(18 * t)];
773
+ for (let x = 0; x < 32; x++)
774
+ set(x, y, color);
775
+ }
776
+ const surface = state.startsWith('awaiting') ? [255, 190, 45]
777
+ : state === 'processing' ? [50, 225, 255] : [38, 210, 145];
778
+ for (let x = 0; x < 32; x++) {
779
+ if ((x + Math.floor(animFrame / 4)) % (state === 'processing' ? 3 : 6) === 0)
780
+ set(x, 2, surface);
781
+ }
782
+ const glyphFor = (kind) => kind === 'jellyfish' ? 'codex' : kind === 'opencode' ? 'openCode'
783
+ : kind === 'antigravity' ? 'antigravity' : 'claudeCode';
784
+ const priority = (s) => s === 'awaiting' ? 0 : s === 'processing' ? 1 : 2;
785
+ const marks = [...creatureInstances.values()].map((c) => ({ glyph: glyphFor(c.creatureType), state: c.state }));
786
+ if (hasOpenClawSession(sessions ?? [])) {
787
+ const routing = sessions?.some((s) => s.agentType === 'openclaw' && s.state === 'processing') ?? false;
788
+ marks.push({ glyph: 'openClaw', state: routing ? 'processing' : 'idle' });
789
+ }
790
+ marks.sort((a, b) => priority(a.state) - priority(b.state));
791
+ marks.splice(3);
792
+ const slots = marks.length === 1 ? [{ x: 16, y: 14, size: 18 }]
793
+ : marks.length === 2 ? [{ x: 9, y: 14, size: 13 }, { x: 23, y: 14, size: 13 }]
794
+ : [{ x: 6, y: 14, size: 10 }, { x: 16, y: 14, size: 10 }, { x: 26, y: 14, size: 10 }];
795
+ const antigravityBands = [
796
+ [92, 214, 77], [245, 203, 36], [255, 132, 16], [255, 82, 65],
797
+ [183, 92, 182], [102, 111, 225], [36, 126, 255],
798
+ ];
799
+ const baseColor = (glyph, sx) => {
800
+ if (glyph === 'claudeCode')
801
+ return [255, 112, 76];
802
+ if (glyph === 'codex')
803
+ return [126, 116, 255];
804
+ if (glyph === 'openCode')
805
+ return [255, 246, 248];
806
+ if (glyph === 'openClaw')
807
+ return [255, 67, 84];
808
+ return antigravityBands[Math.min(antigravityBands.length - 1, Math.floor(sx * antigravityBands.length / OFFICIAL_DOT_GLYPH_SIZE))];
809
+ };
810
+ marks.forEach((mark, index) => {
811
+ const slot = slots[index];
812
+ const mask = OFFICIAL_DOT_GLYPHS[mark.glyph];
813
+ const bob = mark.state === 'processing' ? Math.round(Math.sin((animFrame + index * 5) * 0.28)) : 0;
814
+ const x0 = slot.x - Math.floor(slot.size / 2);
815
+ const y0 = slot.y - Math.floor(slot.size / 2) + bob;
816
+ for (let dy = 0; dy < slot.size; dy++) {
817
+ const sy = Math.min(OFFICIAL_DOT_GLYPH_SIZE - 1, Math.floor(dy * OFFICIAL_DOT_GLYPH_SIZE / slot.size));
818
+ for (let dx = 0; dx < slot.size; dx++) {
819
+ const sx = Math.min(OFFICIAL_DOT_GLYPH_SIZE - 1, Math.floor(dx * OFFICIAL_DOT_GLYPH_SIZE / slot.size));
820
+ const alpha = mask[sy * OFFICIAL_DOT_GLYPH_SIZE + sx] / 255;
821
+ if (alpha <= 0.04)
822
+ continue;
823
+ const color = baseColor(mark.glyph, sx);
824
+ blendPixel(outputBuf, x0 + dx + 1, y0 + dy + 1, [0, 0, 0], alpha * 0.55);
825
+ if (alpha > 0.42) {
826
+ blendPixel(outputBuf, x0 + dx - 1, y0 + dy, color, 0.055);
827
+ blendPixel(outputBuf, x0 + dx + 1, y0 + dy, color, 0.055);
828
+ }
829
+ const coverage = Math.min(1, Math.pow(alpha, 0.72) * 1.12);
830
+ const light = 1.08 - dy / Math.max(1, slot.size - 1) * 0.12;
831
+ const lit = [
832
+ Math.min(255, Math.round(color[0] * light)),
833
+ Math.min(255, Math.round(color[1] * light)),
834
+ Math.min(255, Math.round(color[2] * light)),
835
+ ];
836
+ blendPixel(outputBuf, x0 + dx, y0 + dy, lit, coverage);
837
+ }
838
+ }
839
+ if (mark.glyph === 'openClaw') {
840
+ set(x0 + Math.round(9.05 / 24 * slot.size), y0 + Math.round(7.63 / 24 * slot.size), [0, 229, 204]);
841
+ set(x0 + Math.round(15.38 / 24 * slot.size), y0 + Math.round(7.63 / 24 * slot.size), [0, 229, 204]);
842
+ }
843
+ if (mark.state === 'processing') {
844
+ for (let spark = 0; spark < 3; spark++) {
845
+ const angle = animFrame * 0.24 + spark * Math.PI * 2 / 3;
846
+ const radius = slot.size / 2 + 1;
847
+ set(Math.round(slot.x + Math.cos(angle) * radius), Math.round(slot.y + bob + Math.sin(angle) * radius), [110, 235, 255]);
848
+ }
849
+ }
850
+ else if (mark.state === 'awaiting') {
851
+ set(Math.min(31, x0 + slot.size), Math.max(2, y0), [255, 190, 45]);
852
+ set(Math.min(31, x0 + slot.size), Math.max(2, y0 + 1), [255, 190, 45]);
853
+ }
854
+ });
855
+ if (marks.length === 0) {
856
+ for (const [x, y] of [[14, 12], [15, 11], [16, 12], [17, 11], [18, 12], [15, 14], [16, 15], [17, 14]]) {
857
+ set(x, y, [76, 206, 220]);
858
+ }
859
+ }
860
+ const primary = usageEvent?.codexRateLimits?.primary?.stale === true
861
+ ? undefined : usageEvent?.codexRateLimits?.primary?.usedPercent;
862
+ const secondary = usageEvent?.codexRateLimits?.secondary?.stale === true
863
+ ? undefined : usageEvent?.codexRateLimits?.secondary?.usedPercent;
864
+ const telemetry = [
865
+ [usageEvent?.fiveHourPercent, [42, 220, 154]],
866
+ [usageEvent?.sevenDayPercent, [54, 154, 255]],
867
+ [primary, [185, 86, 255]],
868
+ [secondary, [104, 116, 255]],
869
+ ];
870
+ telemetry.forEach(([raw, brand], row) => {
871
+ const y = 28 + row;
872
+ for (let x = 0; x < 32; x++)
873
+ set(x, y, [5, 8, 14]);
874
+ if (raw == null)
875
+ return;
876
+ const pct = Math.max(0, Math.min(100, raw));
877
+ const color = pct >= 90 ? [255, 58, 72] : pct >= 70 ? [255, 183, 38] : brand;
878
+ set(0, y, brand);
879
+ set(1, y, brand);
880
+ const width = Math.round(pct / 100 * 29);
881
+ for (let x = 3; x < 3 + width; x++)
882
+ set(x, y, color);
883
+ });
884
+ }
635
885
  /**
636
886
  * Render a complete frame with camera system.
637
887
  * Returns RGB buffer.
638
888
  *
639
- * `layout='micro'` renders a single dominant creature on a status field for
640
- * tiny screens (Timebox Mini 11×11); `'standard'` is the full terrarium.
889
+ * `layout='micro'` renders the Timebox Mini Agent Beacon;
890
+ * `'standard'` is the full terrarium.
641
891
  */
642
892
  export function renderFrame(stateEvent, usageEvent, sessions, timeOverrideMs, size = 64, layout = 'standard') {
643
893
  const worldBuf = new Uint8Array(W * W * 3);
@@ -649,6 +899,11 @@ export function renderFrame(stateEvent, usageEvent, sessions, timeOverrideMs, si
649
899
  renderMicroFrame(outputBuf, size, animFrame, stateEvent, sessions, usageEvent?.fiveHourPercent ?? 0);
650
900
  return outputBuf;
651
901
  }
902
+ if (size === 32) {
903
+ syncCreatures(sessions, stateEvent);
904
+ renderCompact32Frame(outputBuf, animFrame, stateEvent, sessions, usageEvent);
905
+ return outputBuf;
906
+ }
652
907
  const state = stateEvent?.state ?? State.IDLE;
653
908
  const usagePct = usageEvent?.fiveHourPercent ?? 0;
654
909
  const surfaceY = SURFACE_Y;
@@ -757,23 +1012,16 @@ export function renderFrame(stateEvent, usageEvent, sessions, timeOverrideMs, si
757
1012
  const spriteState = c.state === 'processing' ? 'working'
758
1013
  : c.state === 'awaiting' ? 'asking'
759
1014
  : 'idle'; // IDLE → idle (limbs move, body color preserved)
760
- if (c.creatureType === 'jellyfish') {
761
- drawJellyfish(outputBuf, c.worldX, c.worldY, spriteState, animFrame + c.phaseOffset, camera, getJellyfishPaletteForSession(sessionToneIndex));
762
- }
763
- else if (c.creatureType === 'opencode') {
764
- drawOpenCode(outputBuf, c.worldX, c.worldY, spriteState, animFrame + c.phaseOffset, camera, getOpenCodePaletteForSession(sessionToneIndex));
765
- }
766
- else if (c.creatureType === 'antigravity') {
767
- drawAntigravity(outputBuf, c.worldX, c.worldY, spriteState, animFrame + c.phaseOffset, camera, getAntigravityPaletteForSession(sessionToneIndex));
768
- }
769
- else {
770
- drawOctopus(outputBuf, c.worldX, c.worldY, spriteState, animFrame + c.phaseOffset, camera, getOctopusPaletteForSession(sessionToneIndex));
771
- }
1015
+ const glyph = c.creatureType === 'jellyfish' ? 'codex'
1016
+ : c.creatureType === 'opencode' ? 'openCode'
1017
+ : c.creatureType === 'antigravity' ? 'antigravity'
1018
+ : 'claudeCode';
1019
+ drawOfficialDotGlyph(outputBuf, glyph, c.worldX, c.worldY, spriteState, animFrame + c.phaseOffset, camera, sessionToneIndex, c.sizeScale);
772
1020
  }
773
1021
  // Crayfish — always drawn when gateway available; IDLE = sitting (subtle breathing only)
774
1022
  if (hasGateway) {
775
1023
  const gatewayHasError = stateEvent?.gatewayHasError ?? false;
776
- drawCrayfish(outputBuf, cfX, cfY, crayfishRouting, animFrame, camera, gatewayHasError);
1024
+ drawOfficialDotGlyph(outputBuf, 'openClaw', cfX, cfY, crayfishRouting ? 'working' : 'idle', animFrame, camera, 0, 1.0, gatewayHasError);
777
1025
  }
778
1026
  // ========================================
779
1027
  // Phase 4: Screen-space overlays
@@ -820,6 +1068,27 @@ export function renderDisconnectedFrame() {
820
1068
  return buf;
821
1069
  }
822
1070
  // ===== Preview API (re-export camera controls) =====
1071
+ /**
1072
+ * Introspection seam: the creature set `renderFrame` would draw for this input,
1073
+ * without rasterizing anything. Counting marks back out of a 64×64 buffer is
1074
+ * far too fragile to assert on, so tests and the preview CLI read the resolved
1075
+ * world placement from here instead.
1076
+ *
1077
+ * This runs the same `syncCreatures` the renderer runs, so it mutates the module's
1078
+ * creature state exactly as a real frame would.
1079
+ */
1080
+ export function getCreatureLayoutSnapshot(sessions, stateEvent) {
1081
+ syncCreatures(sessions, stateEvent);
1082
+ return [...creatureInstances.values()].map(c => ({
1083
+ sessionId: c.sessionId,
1084
+ agentType: c.agentType,
1085
+ creatureType: c.creatureType,
1086
+ state: c.state,
1087
+ worldX: c.worldX,
1088
+ worldY: c.worldY,
1089
+ sizeScale: c.sizeScale,
1090
+ }));
1091
+ }
823
1092
  export { setZone, setOverride, resetDirector } from './pixoo-camera.js';
824
1093
  export { ZONES } from './pixoo-camera.js';
825
1094
  //# sourceMappingURL=pixoo-renderer.js.map