borgmcp 2.13.0 → 2.14.0

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 (38) hide show
  1. package/dist/codex-app-wake.d.ts +3 -3
  2. package/dist/codex-app-wake.d.ts.map +1 -1
  3. package/dist/codex-app-wake.js +10 -8
  4. package/dist/codex-app-wake.js.map +1 -1
  5. package/dist/cube-activity-wake-copy.d.ts +3 -0
  6. package/dist/cube-activity-wake-copy.d.ts.map +1 -0
  7. package/dist/cube-activity-wake-copy.js +5 -0
  8. package/dist/cube-activity-wake-copy.js.map +1 -0
  9. package/dist/inbox-monitor.d.ts +1 -1
  10. package/dist/inbox-monitor.d.ts.map +1 -1
  11. package/dist/inbox-monitor.js +6 -3
  12. package/dist/inbox-monitor.js.map +1 -1
  13. package/dist/log-stream.d.ts +2 -1
  14. package/dist/log-stream.d.ts.map +1 -1
  15. package/dist/log-stream.js +44 -12
  16. package/dist/log-stream.js.map +1 -1
  17. package/dist/opencode-drone.d.ts +4 -3
  18. package/dist/opencode-drone.d.ts.map +1 -1
  19. package/dist/opencode-drone.js +14 -5
  20. package/dist/opencode-drone.js.map +1 -1
  21. package/dist/opencode-wake-copy.d.ts.map +1 -1
  22. package/dist/opencode-wake-copy.js +3 -1
  23. package/dist/opencode-wake-copy.js.map +1 -1
  24. package/dist/roster-render.d.ts +2 -0
  25. package/dist/roster-render.d.ts.map +1 -1
  26. package/dist/roster-render.js +4 -4
  27. package/dist/roster-render.js.map +1 -1
  28. package/docs/EXTRACTION_PROVENANCE.md +8 -7
  29. package/docs/LOCAL_SERVER.md +3 -7
  30. package/docs/RELEASING.md +10 -6
  31. package/package.json +2 -2
  32. package/src/codex-app-wake.ts +15 -8
  33. package/src/cube-activity-wake-copy.ts +6 -0
  34. package/src/inbox-monitor.ts +7 -3
  35. package/src/log-stream.ts +49 -16
  36. package/src/opencode-drone.ts +16 -5
  37. package/src/opencode-wake-copy.ts +4 -1
  38. package/src/roster-render.ts +6 -4
@@ -21,12 +21,16 @@ import {
21
21
  shouldFireHeartbeat,
22
22
  type CodexThreadInfo,
23
23
  } from './codex-wake-resolve.js';
24
+ import {
25
+ CUBE_ACTIVITY_RESUME_WAKE_MESSAGE,
26
+ formatCubeActivityWakeMessage,
27
+ } from './cube-activity-wake-copy.js';
24
28
 
25
29
  export const CODEX_WAKE_PROMPT =
26
- 'New Borg cube-log activity arrived.';
30
+ CUBE_ACTIVITY_RESUME_WAKE_MESSAGE;
27
31
 
28
32
  export function formatCodexWakePrompt(inboxLine: string): string {
29
- return `New Borg cube-log activity arrived:\n${inboxLine}`;
33
+ return formatCubeActivityWakeMessage(inboxLine);
30
34
  }
31
35
 
32
36
  // gh#708: STATIC catch-up/drain prompt (zero interpolation — no token/secret/PII/
@@ -36,7 +40,8 @@ export function formatCodexWakePrompt(inboxLine: string): string {
36
40
  // fire the already-shipped drain so no entry is skipped. gh#857 WI-2 reuses it as
37
41
  // the periodic heartbeat prompt.
38
42
  export const CODEX_CATCHUP_PROMPT =
39
- 'Borg cube activity arrived while you were busy. Wake triage: run `borg_read-log unread_only=true` and DRAIN — repeat until the returned page is under the limit and behind_by is 0 so no entries are skipped. Then handle actionable entries; if none, resume the prior interrupted work.';
43
+ `${formatCubeActivityWakeMessage('Wake triage: repeat the drain until the returned page is under the limit and behind_by is 0 so no entries are skipped.')} ` +
44
+ 'If there are no actionable entries, resume the prior interrupted work.';
40
45
 
41
46
  export function isCodexRemoteWakeEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
42
47
  return env[BORG_CODEX_REMOTE_WAKE_ENV] === '1';
@@ -92,6 +97,7 @@ export async function probeCodexBridgeArmed(
92
97
  let wakeInFlight = false;
93
98
  const pendingWakeRequests: Array<{
94
99
  reason: string;
100
+ deliveryIdentity?: string;
95
101
  deps: CodexWakeDeps;
96
102
  }> = [];
97
103
  const deliveredWakeKeys = new Set<string>();
@@ -272,11 +278,12 @@ async function maybePersistWakeTarget(
272
278
  export function wakeCodexViaAppServer(
273
279
  reason: string = CODEX_WAKE_PROMPT,
274
280
  env: NodeJS.ProcessEnv = process.env,
275
- deps: CodexWakeDeps = {}
281
+ deps: CodexWakeDeps = {},
282
+ deliveryIdentity?: string,
276
283
  ): void {
277
284
  const target = resolveCodexWakeTarget(env);
278
285
  if (!target.enabled) return;
279
- pendingWakeRequests.push({ reason, deps });
286
+ pendingWakeRequests.push({ reason, deliveryIdentity, deps });
280
287
  if (wakeInFlight) return;
281
288
 
282
289
  wakeInFlight = true;
@@ -288,11 +295,11 @@ export function wakeCodexViaAppServer(
288
295
  async function drainCodexWakeQueue(): Promise<void> {
289
296
  while (pendingWakeRequests.length > 0) {
290
297
  const request = pendingWakeRequests.shift()!;
291
- await wakeCodexTargeted(request.reason, request.deps);
298
+ await wakeCodexTargeted(request.reason, request.deliveryIdentity, request.deps);
292
299
  }
293
300
  }
294
301
 
295
- async function wakeCodexTargeted(reason: string, deps: CodexWakeDeps): Promise<void> {
302
+ async function wakeCodexTargeted(reason: string, deliveryIdentity: string | undefined, deps: CodexWakeDeps): Promise<void> {
296
303
  // gh#861 finding 1: another path (heartbeat/retry-drain) is mid-inject into the
297
304
  // same thread — defer to the retry-drain so this entry isn't double-injected nor
298
305
  // lost (the drain re-syncs the whole burst via the server read-cursor).
@@ -308,7 +315,7 @@ async function wakeCodexTargeted(reason: string, deps: CodexWakeDeps): Promise<v
308
315
  const resolved = await resolveFreshCodexWakeTarget(active, deps);
309
316
  if (!resolved) return;
310
317
  const { socketPath, threadId } = resolved;
311
- const wakeKey = `${threadId}\0${reason}`;
318
+ const wakeKey = `${threadId}\0${deliveryIdentity ?? reason}`;
312
319
  if (deliveredWakeKeys.has(wakeKey)) return; // dedup before opening the wake socket
313
320
  const client = makeCodexClient(socketPath, deps);
314
321
  await client.connect();
@@ -0,0 +1,6 @@
1
+ export const CUBE_ACTIVITY_RESUME_WAKE_MESSAGE =
2
+ 'Borg cube activity arrived while you were busy. Reading cube messages does not end your current task. Drain `borg_read-log unread_only=true` until caught up, handle actionable entries, then RESUME the interrupted work.';
3
+
4
+ export function formatCubeActivityWakeMessage(detail: string): string {
5
+ return `${CUBE_ACTIVITY_RESUME_WAKE_MESSAGE}\n${detail}`;
6
+ }
@@ -36,6 +36,7 @@ import { chmodSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync,
36
36
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
37
37
  import { createInterface } from 'node:readline';
38
38
  import { fileURLToPath } from 'node:url';
39
+ import { formatCubeActivityWakeMessage } from './cube-activity-wake-copy.js';
39
40
 
40
41
  const ENTRY_LINE_RE =
41
42
  /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\S*)\s+(\S+)\s+\(([^)]+)\):\s*(.*)$/;
@@ -87,11 +88,14 @@ export function formatEventLine(inboxLine: string): string | null {
87
88
 
88
89
  export function formatFreshEventLine(
89
90
  inboxLine: string,
90
- deduper: RecentLineDeduper
91
+ deduper: RecentLineDeduper,
92
+ includeWakeMessage = false,
91
93
  ): string | null {
92
94
  const pretty = formatEventLine(inboxLine);
93
95
  if (pretty === null) return null;
94
- return deduper.remember(inboxLine) ? pretty : null;
96
+ return deduper.remember(inboxLine)
97
+ ? includeWakeMessage ? formatCubeActivityWakeMessage(pretty) : pretty
98
+ : null;
95
99
  }
96
100
 
97
101
  export function seedDeduperFromInboxTail(
@@ -914,7 +918,7 @@ function main(): void {
914
918
 
915
919
  const rl = createInterface({ input: tail.stdout, crlfDelay: Infinity });
916
920
  rl.on('line', (line) => {
917
- const pretty = formatFreshEventLine(line, deduper);
921
+ const pretty = formatFreshEventLine(line, deduper, true);
918
922
  if (pretty !== null) {
919
923
  console.log(pretty);
920
924
  // Delivered → the tail is current; re-anchor the offset to the live
package/src/log-stream.ts CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  startCodexHeartbeat,
56
56
  wakeCodexViaAppServer,
57
57
  } from './codex-app-wake.js';
58
+ import { formatCubeActivityWakeMessage } from './cube-activity-wake-copy.js';
58
59
  import { readBoundedResponseBody } from './server-response.js';
59
60
  import { BorgServerError } from './server-errors.js';
60
61
  import { markSeatRejected } from './seats.js';
@@ -140,6 +141,14 @@ export function setModuleInjectOpenCode(
140
141
  * ring buffer.
141
142
  */
142
143
  const RECENT_IDS_CAP = 50;
144
+
145
+ function formatOpenCodeWakePrompt(line: string, wakeNonce?: string): string {
146
+ const prompt = formatCubeActivityWakeMessage(line);
147
+ return wakeNonce === undefined
148
+ ? prompt
149
+ : `${prompt}\n\n<!-- borg-wake-nonce:${wakeNonce} -->`;
150
+ }
151
+
143
152
  export const INBOX_TAIL_LINES_CAP = 512;
144
153
  export const INBOX_TAIL_TRIM_THRESHOLD_LINES = INBOX_TAIL_LINES_CAP * 2;
145
154
 
@@ -382,7 +391,7 @@ export interface StreamDeps {
382
391
  renderedLine: string
383
392
  ) => Promise<boolean>;
384
393
  /** Optional Codex app-server wake sink; tests inject a spy. */
385
- wakeCodex?: (reason: string) => void;
394
+ wakeCodex?: (reason: string, deliveryIdentity?: string) => void;
386
395
  /** Override the heartbeat watchdog timeout. */
387
396
  heartbeatTimeoutMs?: number;
388
397
  /** Override HWM divergence grace for focused tests. */
@@ -407,7 +416,8 @@ const defaultDeps: Required<StreamDeps> = {
407
416
  getCursor: getLocalServerCursor,
408
417
  appendLine: defaultAppendLine,
409
418
  hasInboxEntryId: defaultHasInboxEntryId,
410
- wakeCodex: wakeCodexViaAppServer,
419
+ wakeCodex: (reason, deliveryIdentity) =>
420
+ wakeCodexViaAppServer(reason, process.env, {}, deliveryIdentity),
411
421
  heartbeatTimeoutMs: HEARTBEAT_TIMEOUT_MS,
412
422
  hwmDivergenceGraceMs: HWM_DIVERGENCE_GRACE_MS,
413
423
  abortSignal: new AbortController().signal,
@@ -830,23 +840,27 @@ export async function streamOnce(
830
840
  ev: Extract<ParsedEvent, { type: 'log' }>
831
841
  ): Promise<'persisted-skip' | 'written'> => {
832
842
  const line = formatInboxLine(withSseEventId(ev.data, ev.id));
833
- const alreadyPersisted = isCatchingUp && (
843
+ const deliveryId = ev.wake_nonce ?? ev.id;
844
+ const isReping = ev.wake_nonce !== undefined;
845
+ const alreadyPersisted = (ev.wake_nonce !== undefined || isCatchingUp) && (
834
846
  // gh#441: pass the rendered line so the dedup can also recognize LEGACY
835
847
  // (no-entry_id-prefix) on-disk lines, not just the [entry_id:] marker.
836
848
  await hasInboxEntryId(active.cubeId, active.droneId, ev.id, line)
837
849
  );
838
850
  if (alreadyPersisted) {
839
- // Replay still re-enters the OpenCode delivery queue with the same entry
840
- // ID. The queue confirms/deduplicates it by the unique persisted entry text.
841
- await injectOpenCode(line, ev.id, false);
851
+ // Replay still re-enters the OpenCode delivery queue. Ordinary entries
852
+ // correlate by canonical inbox text; re-pings add a stable nonce marker
853
+ // to the injected text so each distinct nonce submits exactly once.
854
+ await injectOpenCode(formatOpenCodeWakePrompt(line, ev.wake_nonce), deliveryId, isReping);
842
855
  markEventPersisted(ev.id, ev.data?.created_at ?? '');
843
856
  return 'persisted-skip';
844
857
  }
845
858
  // The inbox append is the durable record. OpenCode injection is only the
846
859
  // wake attempt and may return before the agent finishes processing.
847
860
  await appendLine(active.cubeId, active.droneId, line);
848
- if (!(await injectOpenCode(line, ev.id, true))) {
849
- wakeCodex(formatCodexWakePrompt(line));
861
+ if (!(await injectOpenCode(formatOpenCodeWakePrompt(line, ev.wake_nonce), deliveryId, true))) {
862
+ if (ev.wake_nonce === undefined) wakeCodex(formatCodexWakePrompt(line));
863
+ else wakeCodex(formatCodexWakePrompt(line), ev.wake_nonce);
850
864
  }
851
865
  return 'written';
852
866
  };
@@ -1007,7 +1021,7 @@ export async function streamOnce(
1007
1021
  const entryId =
1008
1022
  event.id ??
1009
1023
  `control:eviction:${active.cubeId}:${active.droneId}:${event.reason ?? ''}`;
1010
- await injectOpenCode(line, entryId, true);
1024
+ await injectOpenCode(formatCubeActivityWakeMessage(line), entryId, true);
1011
1025
  } catch {
1012
1026
  // Inbox write failed — the Path-B 410 backstop still tears the drone
1013
1027
  // down on its next authed call. Best-effort wake only.
@@ -1064,6 +1078,10 @@ export async function streamOnce(
1064
1078
  continue;
1065
1079
  }
1066
1080
  if (event.type === 'log') {
1081
+ const isHeartbeatPing =
1082
+ typeof event.data?.message === 'string' &&
1083
+ event.data.message.startsWith('[HEARTBEAT-PING]');
1084
+
1067
1085
  // DEDUP per §(3) recent-ids contract: an out-of-order DO
1068
1086
  // broadcast followed by reconnect+catchup can replay an entry
1069
1087
  // we already persisted. The entry IS on disk from an earlier
@@ -1073,10 +1091,14 @@ export async function streamOnce(
1073
1091
  // Last-Event-ID on the next reconnect reflects the highest
1074
1092
  // id we've actually got persisted). UUIDs are not ordinally
1075
1093
  // comparable with created_at, so set membership is the check.
1094
+ const isRecentWakeReplay = recentIds.has(event.id) && event.wake_nonce !== undefined;
1076
1095
  if (recentIds.has(event.id)) {
1096
+ // Nonce replays continue through every ordinary routing filter below;
1097
+ // only their durable append is skipped. Other duplicates retain the
1098
+ // historical fast path.
1077
1099
  markEventPersisted(event.id, event.data?.created_at ?? '');
1078
1100
  markBroadcastPersisted(broadcastHwmFromLogEvent(event));
1079
- continue;
1101
+ if (!isRecentWakeReplay) continue;
1080
1102
  }
1081
1103
 
1082
1104
  // OWN-DRONE FILTER: restore the silent-self property — parity
@@ -1103,10 +1125,6 @@ export async function streamOnce(
1103
1125
  // drone through the disk-write path; the existing rate-limit
1104
1126
  // in the server heartbeat contract (max 1 ping per drone per ~1h)
1105
1127
  // bounds the silent-self property's relaxation.
1106
- const isHeartbeatPing =
1107
- typeof event.data?.message === 'string' &&
1108
- event.data.message.startsWith('[HEARTBEAT-PING]');
1109
-
1110
1128
  // Sprint 26 ack-fan-out: ack events have `kind: 'ack'` plus an
1111
1129
  // `author_drone_id` field naming the recipient (the author of
1112
1130
  // the entry that got acked). Only the author writes the ack
@@ -1117,6 +1135,10 @@ export async function streamOnce(
1117
1135
  // here BEFORE the legacy own-drone filter so the ack-specific
1118
1136
  // semantic takes precedence.
1119
1137
  if (event.data?.kind === 'ack') {
1138
+ if (isRecentWakeReplay) {
1139
+ await recordSeen(event);
1140
+ continue;
1141
+ }
1120
1142
  if (event.data?.author_drone_id === active.droneId) {
1121
1143
  if ((await writeInboxLine(event)) === 'persisted-skip') continue;
1122
1144
  }
@@ -1151,6 +1173,15 @@ export async function streamOnce(
1151
1173
  continue;
1152
1174
  }
1153
1175
 
1176
+ if (isRecentWakeReplay) {
1177
+ const line = formatInboxLine(withSseEventId(event.data, event.id));
1178
+ const wakeNonce = event.wake_nonce;
1179
+ if (wakeNonce !== undefined && !(await injectOpenCode(formatOpenCodeWakePrompt(line, wakeNonce), wakeNonce, true))) {
1180
+ wakeCodex(formatCodexWakePrompt(line), wakeNonce);
1181
+ }
1182
+ continue;
1183
+ }
1184
+
1154
1185
  // Regular inbound entry: write the inbox line (catchup-dedup aware),
1155
1186
  // then advance both cursors.
1156
1187
  if ((await writeInboxLine(event)) === 'persisted-skip') continue;
@@ -1206,7 +1237,7 @@ export async function streamOnceIfOwner(
1206
1237
  // ------------------------------------------------------------------
1207
1238
 
1208
1239
  export type ParsedEvent =
1209
- | { type: 'log'; id: string; data: any; cursor?: LocalServerCursor }
1240
+ | { type: 'log'; id: string; data: any; wake_nonce?: string; cursor?: LocalServerCursor }
1210
1241
  | { type: 'heartbeat'; ts: string | null; hwm: BroadcastHwm | null }
1211
1242
  | { type: 'bookmark'; as_of: string | null }
1212
1243
  // gh#877 Path-A: terminal eviction control frame (wake hint, zero authority).
@@ -1327,10 +1358,12 @@ function parseEventBlock(block: string): ParsedEvent | null {
1327
1358
  const validCursor = cursor &&
1328
1359
  typeof cursor.id === 'string' &&
1329
1360
  typeof cursor.created_at === 'string';
1361
+ const entry = parsed?.entry ?? parsed;
1330
1362
  return {
1331
1363
  type: 'log',
1332
1364
  id,
1333
- data: parsed?.entry ?? parsed,
1365
+ data: entry,
1366
+ ...(typeof entry?.wake_nonce === 'string' ? { wake_nonce: entry.wake_nonce } : {}),
1334
1367
  ...(validCursor ? { cursor } : {}),
1335
1368
  };
1336
1369
  }
@@ -485,11 +485,15 @@ async function deliverOpenCodeEntry(
485
485
  log(`entry ${delivery.entryId} target unavailable: ${err}`);
486
486
  continue;
487
487
  }
488
- if (!target) return 'failed';
488
+ if (!target) {
489
+ log(`entry ${delivery.entryId} target unavailable: no bound session`);
490
+ return 'failed';
491
+ }
489
492
  }
490
493
 
491
494
  try {
492
495
  if (await findInjectedMessage(target.id, delivery.text)) {
496
+ log(`entry ${delivery.entryId} already present in session ${target.id}`);
493
497
  return 'delivered';
494
498
  }
495
499
  } catch (err) {
@@ -631,9 +635,10 @@ export async function injectInitialKickoff(launch: OpenCodeLaunchKickoff): Promi
631
635
 
632
636
  /**
633
637
  * Queue one durable inbox entry for delivery into the bound OpenCode session.
634
- * The durable inbox text identifies the OpenCode-generated user message, so
635
- * retries and replay can confirm an earlier ambiguous submission without
636
- * supplying an ordering-breaking caller message ID or running it twice.
638
+ * The injected text identifies the OpenCode-generated user message, so retries
639
+ * and replay can confirm an earlier ambiguous submission without supplying an
640
+ * ordering-breaking caller message ID or running it twice. Normal delivery uses
641
+ * canonical inbox text; wake re-pings include their stable nonce marker.
637
642
  */
638
643
  export function injectOpenCodeEntry(
639
644
  text: string,
@@ -641,7 +646,10 @@ export function injectOpenCodeEntry(
641
646
  allowSubmit: boolean = true,
642
647
  ): Promise<boolean> {
643
648
  const owner = state;
644
- if (!owner?.connected) return Promise.resolve(false);
649
+ if (!owner?.connected) {
650
+ log(`entry ${entryId} rejected: OpenCode is not connected`);
651
+ return Promise.resolve(false);
652
+ }
645
653
 
646
654
  const deliveredText = owner.deliveredEntries.get(entryId);
647
655
  if (deliveredText !== undefined) {
@@ -650,6 +658,7 @@ export function injectOpenCodeEntry(
650
658
  rememberBounded(owner.failedEntries, entryId, text);
651
659
  return Promise.resolve(false);
652
660
  }
661
+ log(`entry ${entryId} replay already delivered`);
653
662
  return Promise.resolve(true);
654
663
  }
655
664
 
@@ -660,6 +669,7 @@ export function injectOpenCodeEntry(
660
669
  rememberBounded(owner.failedEntries, entryId, text);
661
670
  return Promise.resolve(false);
662
671
  }
672
+ log(`entry ${entryId} replay remains unconfirmed`);
663
673
  return Promise.resolve(false);
664
674
  }
665
675
 
@@ -670,6 +680,7 @@ export function injectOpenCodeEntry(
670
680
  rememberBounded(owner.failedEntries, entryId, text);
671
681
  return Promise.resolve(false);
672
682
  }
683
+ log(`entry ${entryId} replay joined active delivery`);
673
684
  return active.promise;
674
685
  }
675
686
 
@@ -1,3 +1,5 @@
1
+ import { CUBE_ACTIVITY_RESUME_WAKE_MESSAGE } from './cube-activity-wake-copy.js';
2
+
1
3
  export const OPENCODE_WAKE_PATH_GUIDANCE =
2
4
  'OpenCode wakes through HTTP entry injection into its session through the local HTTP API. ' +
3
5
  'Borg writes each entry to the durable inbox before one injection attempt and ' +
@@ -5,4 +7,5 @@ export const OPENCODE_WAKE_PATH_GUIDANCE =
5
7
  'If injection is rejected or cannot be ' +
6
8
  'confirmed, the durable entry remains available and `borg_stream-status` reports ' +
7
9
  'it as failed or delivered-unconfirmed; run `borg_read-log unread_only=true` and ' +
8
- 'drain the unread log to recover.';
10
+ 'drain the unread log to recover. ' +
11
+ CUBE_ACTIVITY_RESUME_WAKE_MESSAGE;
@@ -28,6 +28,8 @@ export interface RosterDrone {
28
28
  last_seen: string | Date;
29
29
  /** Only present when the request carried `since`. */
30
30
  seen_since?: boolean;
31
+ /** Additive server-side wake state; absent on pre-C3 servers. */
32
+ wake_state?: 'idle' | 'pending' | 'awake' | 'stale';
31
33
  /**
32
34
  * gh#370 — which AI agent is running this drone. Null for drones that
33
35
  * joined before the column existed or via a launcher path that didn't
@@ -215,7 +217,8 @@ export function renderRoster(inputs: RenderRosterInputs): string {
215
217
  const regenCountMarker =
216
218
  typeof d.regen_count === 'number' ? ` · \`regen-count:${d.regen_count}\`` : '';
217
219
  if (resolvedSince) {
218
- // T2.1 awake/stale column. `seen_since === true` drone called a
220
+ // T2.1 awake/stale column. When available, wake_state is the server's
221
+ // richer state; older servers fall back to seen_since. `seen_since === true` → drone called a
219
222
  // tool after the resolved timestamp; treat as awake. False or
220
223
  // missing → stale. Missing should not happen when the server
221
224
  // echoed a since, but defending against a shape mismatch is
@@ -229,10 +232,9 @@ export function renderRoster(inputs: RenderRosterInputs): string {
229
232
  // probe call — pure redundancy. The per-row `last seen X ago`
230
233
  // field carries the diagnostic detail for "how stale is this
231
234
  // particular drone."
232
- const isAwake = d.seen_since === true;
233
- const marker = isAwake ? '`awake`' : '`stale`';
235
+ const marker = d.wake_state ?? (d.seen_since === true ? 'awake' : 'stale');
234
236
  lines.push(
235
- `- **${d.label}**${addr} (Role: ${roleName}) — last seen ${lastSeen} · ${marker}${regenCountMarker}${wakePathMarker}${wakePathClassMarker}`
237
+ `- **${d.label}**${addr} (Role: ${roleName}) — last seen ${lastSeen} · \`${marker}\`${regenCountMarker}${wakePathMarker}${wakePathClassMarker}`
236
238
  );
237
239
  } else {
238
240
  lines.push(