agent-embassy 2.0.1 → 3.0.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 (144) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/CONTRIBUTING.md +19 -34
  3. package/README.md +110 -228
  4. package/SECURITY.md +55 -89
  5. package/dist/src/errors.d.ts +10 -1
  6. package/dist/src/errors.js +3 -1
  7. package/dist/src/errors.js.map +1 -1
  8. package/dist/src/gateway/claude-helper-protocol.d.ts +8 -11
  9. package/dist/src/gateway/claude-helper-protocol.js +12 -11
  10. package/dist/src/gateway/claude-helper-protocol.js.map +1 -1
  11. package/dist/src/gateway/claude-helper-supervisor.d.ts +1 -5
  12. package/dist/src/gateway/claude-helper-supervisor.js +10 -9
  13. package/dist/src/gateway/claude-helper-supervisor.js.map +1 -1
  14. package/dist/src/gateway/claude-helper.js +6 -6
  15. package/dist/src/gateway/claude-helper.js.map +1 -1
  16. package/dist/src/gateway/claude-peer.d.ts +0 -3
  17. package/dist/src/gateway/claude-peer.js +6 -18
  18. package/dist/src/gateway/claude-peer.js.map +1 -1
  19. package/dist/src/gateway/cli.d.ts +17 -6
  20. package/dist/src/gateway/cli.js +912 -261
  21. package/dist/src/gateway/cli.js.map +1 -1
  22. package/dist/src/gateway/codex-socket-holder.d.ts +26 -0
  23. package/dist/src/gateway/codex-socket-holder.js +76 -0
  24. package/dist/src/gateway/codex-socket-holder.js.map +1 -0
  25. package/dist/src/gateway/codex-stateless-transport.js +1 -1
  26. package/dist/src/gateway/codex-stateless-transport.js.map +1 -1
  27. package/dist/src/gateway/config.d.ts +1 -10
  28. package/dist/src/gateway/config.js +4 -10
  29. package/dist/src/gateway/config.js.map +1 -1
  30. package/dist/src/gateway/control.d.ts +45 -77
  31. package/dist/src/gateway/control.js +56 -141
  32. package/dist/src/gateway/control.js.map +1 -1
  33. package/dist/src/gateway/federation-nodes.d.ts +29 -2
  34. package/dist/src/gateway/federation-nodes.js +177 -7
  35. package/dist/src/gateway/federation-nodes.js.map +1 -1
  36. package/dist/src/gateway/peer-client.d.ts +4 -3
  37. package/dist/src/gateway/peer-client.js +22 -13
  38. package/dist/src/gateway/peer-client.js.map +1 -1
  39. package/dist/src/gateway/peer-protocol.d.ts +8 -11
  40. package/dist/src/gateway/peer-protocol.js +5 -12
  41. package/dist/src/gateway/peer-protocol.js.map +1 -1
  42. package/dist/src/gateway/provenance-envelope.d.ts +0 -1
  43. package/dist/src/gateway/provenance-envelope.js +4 -19
  44. package/dist/src/gateway/provenance-envelope.js.map +1 -1
  45. package/dist/src/gateway/providers.d.ts +7 -4
  46. package/dist/src/gateway/providers.js +16 -20
  47. package/dist/src/gateway/providers.js.map +1 -1
  48. package/dist/src/gateway/server.d.ts +3 -12
  49. package/dist/src/gateway/server.js +31 -50
  50. package/dist/src/gateway/server.js.map +1 -1
  51. package/dist/src/gateway/service-agent.d.ts +187 -0
  52. package/dist/src/gateway/service-agent.js +758 -0
  53. package/dist/src/gateway/service-agent.js.map +1 -0
  54. package/dist/src/gateway/service.d.ts +114 -31
  55. package/dist/src/gateway/service.js +461 -566
  56. package/dist/src/gateway/service.js.map +1 -1
  57. package/dist/src/gateway/status-view.d.ts +167 -0
  58. package/dist/src/gateway/status-view.js +488 -0
  59. package/dist/src/gateway/status-view.js.map +1 -0
  60. package/dist/src/gateway/store.d.ts +103 -21
  61. package/dist/src/gateway/store.js +454 -529
  62. package/dist/src/gateway/store.js.map +1 -1
  63. package/dist/src/gateway/types.d.ts +48 -99
  64. package/dist/src/gateway/types.js +15 -52
  65. package/dist/src/gateway/types.js.map +1 -1
  66. package/docs/CONFIGURATION.md +173 -44
  67. package/docs/DELIVERY.md +11 -11
  68. package/docs/GATEWAY-ARCHITECTURE.md +277 -375
  69. package/package.json +4 -12
  70. package/skills/embassy-peer/SKILL.md +65 -90
  71. package/skills/embassy-peer/agents/openai.yaml +1 -1
  72. package/README.zh-CN.md +0 -275
  73. package/assets/live-dashboard/app.css +0 -1619
  74. package/assets/vendor/react/LICENSE +0 -21
  75. package/assets/vendor/react/react-dom.production.min.js +0 -267
  76. package/assets/vendor/react/react.production.min.js +0 -31
  77. package/dist/src/gateway/acp-client.d.ts +0 -110
  78. package/dist/src/gateway/acp-client.js +0 -407
  79. package/dist/src/gateway/acp-client.js.map +0 -1
  80. package/dist/src/gateway/acp-provider.d.ts +0 -66
  81. package/dist/src/gateway/acp-provider.js +0 -275
  82. package/dist/src/gateway/acp-provider.js.map +0 -1
  83. package/dist/src/gateway/cli-copy.d.ts +0 -8
  84. package/dist/src/gateway/cli-copy.en.d.ts +0 -22
  85. package/dist/src/gateway/cli-copy.en.js +0 -62
  86. package/dist/src/gateway/cli-copy.en.js.map +0 -1
  87. package/dist/src/gateway/cli-copy.js +0 -27
  88. package/dist/src/gateway/cli-copy.js.map +0 -1
  89. package/dist/src/gateway/cli-copy.zh-CN.d.ts +0 -22
  90. package/dist/src/gateway/cli-copy.zh-CN.js +0 -62
  91. package/dist/src/gateway/cli-copy.zh-CN.js.map +0 -1
  92. package/dist/src/gateway/codex-doctor.d.ts +0 -36
  93. package/dist/src/gateway/codex-doctor.js +0 -127
  94. package/dist/src/gateway/codex-doctor.js.map +0 -1
  95. package/dist/src/gateway/dashboard-copy.d.ts +0 -7
  96. package/dist/src/gateway/dashboard-copy.en.d.ts +0 -504
  97. package/dist/src/gateway/dashboard-copy.en.js +0 -505
  98. package/dist/src/gateway/dashboard-copy.en.js.map +0 -1
  99. package/dist/src/gateway/dashboard-copy.js +0 -514
  100. package/dist/src/gateway/dashboard-copy.js.map +0 -1
  101. package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +0 -504
  102. package/dist/src/gateway/dashboard-copy.zh-CN.js +0 -505
  103. package/dist/src/gateway/dashboard-copy.zh-CN.js.map +0 -1
  104. package/dist/src/gateway/dashboard-model.d.ts +0 -343
  105. package/dist/src/gateway/dashboard-model.js +0 -1061
  106. package/dist/src/gateway/dashboard-model.js.map +0 -1
  107. package/dist/src/gateway/dashboard.d.ts +0 -20
  108. package/dist/src/gateway/dashboard.js +0 -874
  109. package/dist/src/gateway/dashboard.js.map +0 -1
  110. package/dist/src/gateway/deepseek-detect.d.ts +0 -14
  111. package/dist/src/gateway/deepseek-detect.js +0 -41
  112. package/dist/src/gateway/deepseek-detect.js.map +0 -1
  113. package/dist/src/gateway/live-dashboard-app/app.js +0 -2385
  114. package/dist/src/gateway/live-dashboard-assets.d.ts +0 -10
  115. package/dist/src/gateway/live-dashboard-assets.js +0 -74
  116. package/dist/src/gateway/live-dashboard-assets.js.map +0 -1
  117. package/dist/src/gateway/live-dashboard-command.d.ts +0 -60
  118. package/dist/src/gateway/live-dashboard-command.js +0 -334
  119. package/dist/src/gateway/live-dashboard-command.js.map +0 -1
  120. package/dist/src/gateway/live-dashboard-http.d.ts +0 -39
  121. package/dist/src/gateway/live-dashboard-http.js +0 -383
  122. package/dist/src/gateway/live-dashboard-http.js.map +0 -1
  123. package/dist/src/gateway/live-dashboard-protocol.d.ts +0 -34
  124. package/dist/src/gateway/live-dashboard-protocol.js +0 -114
  125. package/dist/src/gateway/live-dashboard-protocol.js.map +0 -1
  126. package/dist/src/gateway/live-dashboard-server.d.ts +0 -33
  127. package/dist/src/gateway/live-dashboard-server.js +0 -144
  128. package/dist/src/gateway/live-dashboard-server.js.map +0 -1
  129. package/dist/src/gateway/live-dashboard-stream.d.ts +0 -46
  130. package/dist/src/gateway/live-dashboard-stream.js +0 -234
  131. package/dist/src/gateway/live-dashboard-stream.js.map +0 -1
  132. package/dist/src/gateway/live-dashboard.d.ts +0 -28
  133. package/dist/src/gateway/live-dashboard.js +0 -154
  134. package/dist/src/gateway/live-dashboard.js.map +0 -1
  135. package/dist/src/gateway/locale.d.ts +0 -4
  136. package/dist/src/gateway/locale.js +0 -10
  137. package/dist/src/gateway/locale.js.map +0 -1
  138. package/dist/src/gateway/progress-watch-machine.d.ts +0 -45
  139. package/dist/src/gateway/progress-watch-machine.js +0 -70
  140. package/dist/src/gateway/progress-watch-machine.js.map +0 -1
  141. package/docs/CONFIGURATION.zh-CN.md +0 -97
  142. package/docs/DASHBOARD.md +0 -98
  143. package/docs/DASHBOARD.zh-CN.md +0 -49
  144. package/docs/DELIVERY.zh-CN.md +0 -55
@@ -0,0 +1,167 @@
1
+ import type { GatewayPublicSnapshot, NormalizedMessageEvent, PublicAvailablePeerSnapshot, PublicConnectorSnapshot, PublicGatewayActivityEvent, PublicRouteSnapshot } from "./types.js";
2
+ /**
3
+ * The backstop for a route the broker has not heard about in a long time.
4
+ *
5
+ * It is deliberately far above the broker's own 35-second connector window
6
+ * (`CONNECTOR_OBSERVATION_STALE_AFTER_MS`), because that window is crossed
7
+ * routinely and innocently: the Codex observer polls one route at a time
8
+ * every 15 s, and Claude routes are not polled at all — their evidence is
9
+ * edge-triggered, written when a discovery scan or a delivery observes the
10
+ * session, so a quiet Claude session's `lastSeenAt` simply stops advancing
11
+ * while the session is perfectly alive. A word that meant "gone" at 35 s
12
+ * would tell operators to retire live work; ten minutes plus a code that
13
+ * says the endpoint is gone is what earns that sentence.
14
+ */
15
+ export declare const STATUS_ROUTE_STALE_AFTER_MS: number;
16
+ /** Older than this, the header offers a rescan. `status` never rescans itself. */
17
+ export declare const STATUS_SCAN_STALE_AFTER_MS = 60000;
18
+ /** The one safe code that means the Codex task behind a route is actually gone. */
19
+ export declare const CODEX_ENDPOINT_GONE_CODES: readonly string[];
20
+ export declare const STATUS_RECENT: Readonly<{
21
+ default: 10;
22
+ minimum: 1;
23
+ maximum: 100;
24
+ }>;
25
+ export declare const STATUS_CAPS: Readonly<{
26
+ sessions: 16;
27
+ shellPeers: 8;
28
+ alerts: 6;
29
+ }>;
30
+ export type StatusViewOptions = Readonly<{
31
+ stateDir: string;
32
+ version: string;
33
+ recent: number;
34
+ color: boolean;
35
+ /** Wall clock, so "snapshot 3s ago" is the reader's own now, not the broker's. */
36
+ now: number;
37
+ /** From `<stateDir>/.gateway-controller.lock`; absent when it could not be read. */
38
+ pid?: number;
39
+ }>;
40
+ /**
41
+ * One remedy per safe code, each one line. A connector with an unknown code
42
+ * still gets a bounded generic remedy — it is on the delivery path, so the
43
+ * operator must be told something — while an alert with an unknown code is
44
+ * counted rather than printed. That filter is what makes the alert section
45
+ * "actionable alerts" instead of an event log.
46
+ *
47
+ * `PEER_ALIAS_COLLISION` is deliberately the same sentence the CLI prints for
48
+ * a refused send (`CLI_HINT.aliasCollision`); `status-view.test.ts` pins the
49
+ * two strings together so they cannot drift apart.
50
+ */
51
+ export declare const STATUS_REMEDY: Readonly<Record<string, string>>;
52
+ declare const ANSI: Readonly<{
53
+ reset: "\u001B[0m";
54
+ bold: "\u001B[1m";
55
+ dim: "\u001B[2m";
56
+ red: "\u001B[31m";
57
+ yellow: "\u001B[33m";
58
+ green: "\u001B[32m";
59
+ }>;
60
+ type Tone = keyof typeof ANSI;
61
+ export type Paint = (text: string, tone: Tone) => string;
62
+ /** The one place that emits an escape sequence; `color: false` emits none. */
63
+ export declare const terminalPainter: (color: boolean) => Paint;
64
+ /**
65
+ * Bodies are the one snapshot field with no shape validation beyond "non-empty
66
+ * UTF-8 without NUL", so a preview strips C0/C1 controls and Unicode format
67
+ * characters — an escape sequence would repaint the operator's terminal, and a
68
+ * bidi override or zero-width joiner would let a body render as text it is
69
+ * not — collapses runs of whitespace, and is cut to one short line.
70
+ */
71
+ declare function previewBody(body: string, maximum?: number): string;
72
+ /** Relative wall-clock age: deterministic, and free of the reader's timezone. */
73
+ declare function relativeAge(timestamp: string | undefined, now: number): string;
74
+ /** The words a connector is allowed to say. Never a sentence, never a number. */
75
+ export type StatusWord = "ok" | "stale" | "degraded" | "offline";
76
+ /**
77
+ * Connector words. A connector reporting `CONNECTOR_OBSERVATION_STALE` has
78
+ * nothing wrong with it: no route of that provider was observed inside the
79
+ * broker's window. That is `stale`, not `degraded`, and the difference is the
80
+ * point — an idle Mac with no registered Codex task must not read as broken.
81
+ */
82
+ declare function connectorWord(connector: PublicConnectorSnapshot): StatusWord;
83
+ /**
84
+ * What a route's row says, and why.
85
+ *
86
+ * A Claude route whose session the latest discovery scan still lists is never
87
+ * `stale`: the scan is fresher evidence than the route's own edge-triggered
88
+ * observation, so the discovered state wins and the last-seen cell says where
89
+ * that came from. Everything else keeps the broker's word until the
90
+ * ten-minute backstop, and only then is called stale.
91
+ */
92
+ type RouteView = Readonly<{
93
+ word: string;
94
+ lastSeen: string;
95
+ remedy?: string;
96
+ }>;
97
+ declare function routeView(route: PublicRouteSnapshot, discovered: PublicAvailablePeerSnapshot | undefined, now: number): RouteView;
98
+ /**
99
+ * The overall word is informational and derived from the two connectors a
100
+ * message actually travels through. A shell peer never contributes: its
101
+ * mailbox is pull-only, so "nobody is awaiting it" is a fact about the
102
+ * operator's other terminal, not about the broker.
103
+ */
104
+ declare function overallWord(snapshot: GatewayPublicSnapshot): StatusWord;
105
+ export declare function renderStatus(snapshot: GatewayPublicSnapshot, options: StatusViewOptions): string;
106
+ /**
107
+ * `watch` state.
108
+ *
109
+ * A message row is identified by `messageIdSuffix`, never by `sequence`: the
110
+ * store re-stamps a message's sequence on every state change, so sequence
111
+ * orders events but does not name one. Memory is bounded by the snapshot
112
+ * itself — the map is rebuilt from each poll's own rows, which the control
113
+ * protocol caps — so a broker watched for a month costs what one watched for a
114
+ * minute costs.
115
+ */
116
+ export type WatchState = Readonly<{
117
+ messageStates: ReadonlyMap<string, string>;
118
+ lastSequence: number;
119
+ lastActivitySequence: number;
120
+ truncatedMessages: number;
121
+ }>;
122
+ export declare const emptyWatchState: WatchState;
123
+ export type WatchEvent = Readonly<{
124
+ type: "message";
125
+ event: NormalizedMessageEvent;
126
+ }> | Readonly<{
127
+ type: "transition";
128
+ message: string;
129
+ timestamp: string;
130
+ from: string;
131
+ to: string;
132
+ sourceAlias: string;
133
+ targetAlias: string;
134
+ latencyMs?: number;
135
+ safeErrorCode?: string;
136
+ }> | Readonly<{
137
+ type: "activity";
138
+ event: PublicGatewayActivityEvent;
139
+ }> | Readonly<{
140
+ type: "notice";
141
+ text: string;
142
+ }>;
143
+ /**
144
+ * Pure diff of one poll against the last, in stable emit order.
145
+ *
146
+ * A snapshot can carry the same message twice — once as a journaled activity
147
+ * event and once as the live row — so the last occurrence in sequence order is
148
+ * the current state. What this can promise is "at most once per observed
149
+ * change": a transition the broker passed through entirely between two polls
150
+ * is never seen, and rows evicted from the retained window before this tail
151
+ * reached them are reported as a notice rather than silently skipped.
152
+ */
153
+ export declare function diffWatch(previous: WatchState, snapshot: GatewayPublicSnapshot): Readonly<{
154
+ state: WatchState;
155
+ events: WatchEvent[];
156
+ }>;
157
+ /** One terminal line per watch event; the activity line is deliberately secondary. */
158
+ export declare function renderWatchEvent(event: WatchEvent, color: boolean): string;
159
+ /** Pure helpers the renderer's own tests drive directly; not part of the CLI surface. */
160
+ export declare const __test: Readonly<{
161
+ connectorWord: typeof connectorWord;
162
+ overallWord: typeof overallWord;
163
+ previewBody: typeof previewBody;
164
+ relativeAge: typeof relativeAge;
165
+ routeView: typeof routeView;
166
+ }>;
167
+ export {};
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Human rendering of the public snapshot. Pure and side-effect free: it takes
3
+ * a validated `GatewayPublicSnapshot` and returns text, so every judgement it
4
+ * makes — which connectors matter, what counts as stale, which alerts are
5
+ * actionable — is unit-testable against fixture snapshots without a broker.
6
+ *
7
+ * Nothing here reaches for state the snapshot does not already carry, and
8
+ * nothing here asks the broker for anything: `status` is read-only. The
9
+ * snapshot is the whole input; `StatusViewOptions` adds only what the client
10
+ * knows about itself (its own version, the state directory it resolved, the
11
+ * pid recorded in that directory's controller lock, and the clock).
12
+ *
13
+ * Caps: at most `STATUS_CAPS.sessions` discovered sessions, `.shellPeers`
14
+ * shell-peer lines, and `.alerts` alert rows are printed. Every cap says how
15
+ * many rows it hid, and the shell-peer list is ordered stale-first so a stuck
16
+ * peer is never the row that falls off the end.
17
+ */
18
+ import { gatewayPublicSnapshotLimits } from "./types.js";
19
+ /**
20
+ * The backstop for a route the broker has not heard about in a long time.
21
+ *
22
+ * It is deliberately far above the broker's own 35-second connector window
23
+ * (`CONNECTOR_OBSERVATION_STALE_AFTER_MS`), because that window is crossed
24
+ * routinely and innocently: the Codex observer polls one route at a time
25
+ * every 15 s, and Claude routes are not polled at all — their evidence is
26
+ * edge-triggered, written when a discovery scan or a delivery observes the
27
+ * session, so a quiet Claude session's `lastSeenAt` simply stops advancing
28
+ * while the session is perfectly alive. A word that meant "gone" at 35 s
29
+ * would tell operators to retire live work; ten minutes plus a code that
30
+ * says the endpoint is gone is what earns that sentence.
31
+ */
32
+ export const STATUS_ROUTE_STALE_AFTER_MS = 10 * 60_000;
33
+ /** Older than this, the header offers a rescan. `status` never rescans itself. */
34
+ export const STATUS_SCAN_STALE_AFTER_MS = 60_000;
35
+ /** The one safe code that means the Codex task behind a route is actually gone. */
36
+ export const CODEX_ENDPOINT_GONE_CODES = ["THREAD_NOT_OBSERVED"];
37
+ export const STATUS_RECENT = Object.freeze({ default: 10, minimum: 1, maximum: 100 });
38
+ export const STATUS_CAPS = Object.freeze({ sessions: 16, shellPeers: 8, alerts: 6 });
39
+ const PREVIEW_MAX_CHARS = 60;
40
+ /**
41
+ * One remedy per safe code, each one line. A connector with an unknown code
42
+ * still gets a bounded generic remedy — it is on the delivery path, so the
43
+ * operator must be told something — while an alert with an unknown code is
44
+ * counted rather than printed. That filter is what makes the alert section
45
+ * "actionable alerts" instead of an event log.
46
+ *
47
+ * `PEER_ALIAS_COLLISION` is deliberately the same sentence the CLI prints for
48
+ * a refused send (`CLI_HINT.aliasCollision`); `status-view.test.ts` pins the
49
+ * two strings together so they cannot drift apart.
50
+ */
51
+ export const STATUS_REMEDY = {
52
+ MANAGED_CODEX_UNAVAILABLE: "Either a process outside Embassy holds the managed Codex control socket — quit it — or the managed App Server standalone layout is missing, which starting the daemon alone does not create: follow the Codex prerequisite in the README (the official installer, then the daemon).",
53
+ PEER_ALIAS_COLLISION: "the alias names more than one live session; rename one, or address the session by UUID with --to <session-uuid>.",
54
+ PEER_PROTOCOL_MISMATCH: "That node runs a different federation peer protocol; upgrade the lagging node's Embassy and it re-catalogs on the next refresh.",
55
+ PEER_TUNNEL_UNAVAILABLE: "The SSH tunnel to that node is down; check the node is reachable and its broker is running.",
56
+ PEER_DIAL_FAILED: "Embassy could not open the SSH tunnel to that node; check its entry in nodes.json and your SSH configuration.",
57
+ PEER_CATALOG_INCOMPLETE: "That node answered with an incomplete catalog; its mirrored routes are shown from the last complete one.",
58
+ PEER_ROUTE_STALE: "That mirrored route was missing from the node's latest catalog; it retires when the node stops publishing it.",
59
+ CLAUDE_PEER_NOT_OBSERVED: "That Claude session is no longer in the live registry; run `embassy refresh`, and its route retires on the next send if the session exited.",
60
+ ROUTE_UNOBSERVED: "The provider stopped reporting this route; retire it if the session or task ended.",
61
+ THREAD_NOT_OBSERVED: "That Codex task is gone. Run `embassy register-codex --alias <new-alias> --succeeds <this alias>` from the new task, or `embassy unregister-codex --alias <this alias>` from the old one.",
62
+ CODEX_OBSERVER_UNAVAILABLE: "Embassy cannot observe Codex tasks; start the managed daemon with `codex app-server daemon start`.",
63
+ DISPATCH_RUNNER_FAILED: "A delivery attempt failed inside the broker; the message keeps its own settled state — read it with `embassy delivery-status`, and restart the broker if this repeats.",
64
+ CLAUDE_INGRESS_REJECTED: "A Claude session refused an inbound message; check that session's `crossSessionInbound` setting.",
65
+ CLAUDE_REPLY_REJECTED: "A reply could not be written back to the originating Claude session; it may have exited — run `embassy refresh`.",
66
+ ROUTE_OBSERVATION_FAILED: "The provider's route observation failed once; the state words above may be out of date until the next poll.",
67
+ EPHEMERAL_ROUTE_EXPIRY_FAILED: "A throwaway `embassy check` registration could not be retired on time. The broker retries twice more, five seconds apart; if all three attempts fail the registration stays until the broker restarts, which clears it by construction.",
68
+ GATEWAY_WAKE_FAILED: "The broker's maintenance tick failed once; if it repeats, restart the broker.",
69
+ };
70
+ const ANSI = Object.freeze({
71
+ reset: "\u001b[0m", bold: "\u001b[1m", dim: "\u001b[2m",
72
+ red: "\u001b[31m", yellow: "\u001b[33m", green: "\u001b[32m",
73
+ });
74
+ /** The one place that emits an escape sequence; `color: false` emits none. */
75
+ export const terminalPainter = (color) => color ? (text, tone) => `${ANSI[tone]}${text}${ANSI.reset}` : (text) => text;
76
+ const wordColor = (word) => word === "ok" ? "green" : word === "degraded" || word === "offline" ? "red" : "yellow";
77
+ /**
78
+ * Bodies are the one snapshot field with no shape validation beyond "non-empty
79
+ * UTF-8 without NUL", so a preview strips C0/C1 controls and Unicode format
80
+ * characters — an escape sequence would repaint the operator's terminal, and a
81
+ * bidi override or zero-width joiner would let a body render as text it is
82
+ * not — collapses runs of whitespace, and is cut to one short line.
83
+ */
84
+ function previewBody(body, maximum = PREVIEW_MAX_CHARS) {
85
+ const flattened = body
86
+ .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
87
+ .replace(/\p{Cf}/gu, "")
88
+ .replace(/\s+/gu, " ")
89
+ .trim();
90
+ const characters = [...flattened];
91
+ return characters.length <= maximum ? flattened : `${characters.slice(0, maximum - 1).join("")}…`;
92
+ }
93
+ /** Relative wall-clock age: deterministic, and free of the reader's timezone. */
94
+ function relativeAge(timestamp, now) {
95
+ if (timestamp === undefined)
96
+ return "never";
97
+ const observed = Date.parse(timestamp);
98
+ if (!Number.isFinite(observed))
99
+ return "unknown";
100
+ const seconds = Math.max(0, Math.round((now - observed) / 1_000));
101
+ if (seconds < 1)
102
+ return "just now";
103
+ if (seconds < 60)
104
+ return `${String(seconds)}s ago`;
105
+ const minutes = Math.round(seconds / 60);
106
+ if (minutes < 60)
107
+ return `${String(minutes)}m ago`;
108
+ const hours = Math.round(minutes / 60);
109
+ if (hours < 48)
110
+ return `${String(hours)}h ago`;
111
+ return `${String(Math.round(hours / 24))}d ago`;
112
+ }
113
+ /** The same age without "ago", for a phrase that supplies its own preposition. */
114
+ const spanFor = (timestamp, now) => relativeAge(timestamp, now).replace(/ ago$/, "");
115
+ const ageMs = (timestamp, now) => {
116
+ if (timestamp === undefined)
117
+ return undefined;
118
+ const observed = Date.parse(timestamp);
119
+ return Number.isFinite(observed) ? Math.max(0, now - observed) : undefined;
120
+ };
121
+ /**
122
+ * Connector words. A connector reporting `CONNECTOR_OBSERVATION_STALE` has
123
+ * nothing wrong with it: no route of that provider was observed inside the
124
+ * broker's window. That is `stale`, not `degraded`, and the difference is the
125
+ * point — an idle Mac with no registered Codex task must not read as broken.
126
+ */
127
+ function connectorWord(connector) {
128
+ if (connector.health === "healthy" || connector.health === "connecting")
129
+ return "ok";
130
+ if (connector.health === "offline")
131
+ return "offline";
132
+ return connector.safeErrorCode === "CONNECTOR_OBSERVATION_STALE" ? "stale" : "degraded";
133
+ }
134
+ function routeView(route, discovered, now) {
135
+ if (!route.enabled)
136
+ return { word: "disabled", lastSeen: relativeAge(route.lastSeenAt, now) };
137
+ if (discovered !== undefined) {
138
+ return {
139
+ word: discovered.state === "awaiting_approval" ? "awaiting" : discovered.state,
140
+ lastSeen: `discovered ${spanFor(discovered.lastSeenAt, now)}`,
141
+ ...(discovered.state === "awaiting_approval"
142
+ ? { remedy: "waiting on an approval prompt in that session's own terminal" } : {}),
143
+ };
144
+ }
145
+ const observed = ageMs(route.lastSeenAt, now);
146
+ const beyondBackstop = observed !== undefined && observed > STATUS_ROUTE_STALE_AFTER_MS;
147
+ const word = beyondBackstop ? "stale"
148
+ : route.state === "awaiting_approval" ? "awaiting" : route.state;
149
+ const lastSeen = relativeAge(route.lastSeenAt, now);
150
+ if (word === "awaiting") {
151
+ return { word, lastSeen,
152
+ remedy: `waiting on an approval prompt in that ${route.provider === "codex" ? "Codex task" : "session"}'s own terminal` };
153
+ }
154
+ if (word !== "stale")
155
+ return { word, lastSeen };
156
+ // Stale, and now the only question that matters: gone, or merely quiet?
157
+ if (route.provider === "codex") {
158
+ // A bare silence is not evidence the task is gone, and neither is the
159
+ // broker's own 35-second word: the succession remedy needs BOTH the
160
+ // ten-minute backstop and a code that says the endpoint itself is gone.
161
+ // Anything less tells operators to retire tasks that are merely busy.
162
+ const gone = beyondBackstop && route.safeErrorCode !== undefined &&
163
+ CODEX_ENDPOINT_GONE_CODES.includes(route.safeErrorCode);
164
+ return { word, lastSeen, remedy: gone
165
+ ? STATUS_REMEDY.THREAD_NOT_OBSERVED ?? ""
166
+ : `not observed for ${spanFor(route.lastSeenAt, now)}; the task may be busy or the app-server slow — run \`embassy check --to ${route.alias}\` to settle it` };
167
+ }
168
+ if (route.provider === "claude") {
169
+ return { word, lastSeen,
170
+ remedy: "that session has exited or renamed; run `embassy refresh`" };
171
+ }
172
+ return { word, lastSeen };
173
+ }
174
+ const severityRank = (word) => word === "degraded" || word === "offline" ? 0 : word === "stale" ? 1 : 2;
175
+ /**
176
+ * The overall word is informational and derived from the two connectors a
177
+ * message actually travels through. A shell peer never contributes: its
178
+ * mailbox is pull-only, so "nobody is awaiting it" is a fact about the
179
+ * operator's other terminal, not about the broker.
180
+ */
181
+ function overallWord(snapshot) {
182
+ const routed = snapshot.connectors.filter((connector) => connector.provider === "claude" || connector.provider === "codex");
183
+ if (routed.length === 0)
184
+ return "offline";
185
+ return routed.map(connectorWord).reduce((worst, word) => severityRank(word) < severityRank(worst) ? word : worst, "ok");
186
+ }
187
+ /** The remedy for one connector's condition; never empty when the word is not ok. */
188
+ function connectorRemedy(connector, snapshot) {
189
+ const code = connector.safeErrorCode;
190
+ if (code === "CONNECTOR_OBSERVATION_STALE" && connector.provider === "codex") {
191
+ return snapshot.routes.some((route) => route.provider === "codex")
192
+ ? "A registered Codex task has not been observed recently; run `embassy check` to test the round trip."
193
+ : "No Codex task is registered. Run `embassy register-codex --alias codex-<name>@<host>` from inside the task.";
194
+ }
195
+ if (code === "CONNECTOR_OBSERVATION_STALE" && connector.provider === "claude") {
196
+ return "No Claude session has been observed recently; start one, then run `embassy refresh`.";
197
+ }
198
+ const known = code === undefined ? undefined : STATUS_REMEDY[code];
199
+ if (known !== undefined)
200
+ return known;
201
+ return `The ${connector.provider} connector is not answering. Read \`embassy status --json\` for the full snapshot, and restart the broker if it does not clear.`;
202
+ }
203
+ /**
204
+ * Colliding Claude names are filtered out of `availablePeers` by the broker,
205
+ * and the only public trace left is a rejection count on the Claude
206
+ * connector's registry observation. The count is therefore what can honestly
207
+ * be reported: which name collided is not in the snapshot.
208
+ */
209
+ const claudeConnector = (snapshot) => snapshot.connectors.find((connector) => connector.provider === "claude");
210
+ function collisionCount(snapshot) {
211
+ return claudeConnector(snapshot)?.registry?.rejected.find((row) => row.safeErrorCode === "PEER_ALIAS_COLLISION")?.count ?? 0;
212
+ }
213
+ /**
214
+ * The registry-drift signal: the scan saw records and could not parse a single
215
+ * one into the fields Embassy consumes, and has not since this broker started.
216
+ * That is what a changed Claude Code registry layout looks like from here.
217
+ */
218
+ function registryDrifted(snapshot) {
219
+ const registry = claudeConnector(snapshot)?.registry;
220
+ return registry !== undefined && registry.entriesScanned > 0 &&
221
+ !registry.parseableRecordSeenSinceBoot;
222
+ }
223
+ /**
224
+ * Columns padded to content width; the last column is never padded. Padding is
225
+ * measured and applied on the plain value and colour is put on afterwards —
226
+ * padding a string that already carries escape bytes counts them as width and
227
+ * shears the table apart the moment colour is on.
228
+ */
229
+ function table(headers, rows, paint) {
230
+ const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index]?.value ?? "").length)));
231
+ const render = (cells) => cells.map((cell, index) => {
232
+ const padded = index === headers.length - 1 ? cell.value : cell.value.padEnd(widths[index] ?? 0);
233
+ return cell.tone === undefined ? padded : paint(padded, cell.tone);
234
+ }).join(" ").trimEnd();
235
+ return [render(headers.map((value) => ({ value }))), ...rows.map(render)];
236
+ }
237
+ /** The most recent alert per (code, host, alias), newest first, remedied ones only. */
238
+ function actionableAlerts(alerts) {
239
+ const latest = new Map();
240
+ for (const alert of alerts) {
241
+ latest.set(`${alert.code} ${alert.host ?? ""} ${alert.alias ?? ""}`, alert);
242
+ }
243
+ const rows = [...latest.values()].sort((left, right) => right.timestamp.localeCompare(left.timestamp));
244
+ const explained = rows.filter((alert) => STATUS_REMEDY[alert.code] !== undefined);
245
+ return {
246
+ shown: explained.slice(0, STATUS_CAPS.alerts),
247
+ hidden: Math.max(0, explained.length - STATUS_CAPS.alerts),
248
+ unexplained: rows.length - explained.length,
249
+ };
250
+ }
251
+ const messageArrow = (event) => `${event.sourceAlias} → ${event.targetAlias}`;
252
+ export function renderStatus(snapshot, options) {
253
+ const now = options.now;
254
+ const paint = terminalPainter(options.color);
255
+ const lines = [];
256
+ const overall = overallWord(snapshot);
257
+ const discoveredByAlias = new Map(snapshot.availablePeers.map((peer) => [peer.alias, peer]));
258
+ const brokerFacts = [
259
+ paint(overall, wordColor(overall)),
260
+ ...(options.pid === undefined ? [] : [`pid ${String(options.pid)}`]),
261
+ `snapshot ${relativeAge(snapshot.generatedAt, now)}`,
262
+ ];
263
+ lines.push(`${paint(`embassy ${options.version}`, "bold")} broker ${brokerFacts.join(" · ")}`);
264
+ lines.push(paint(`state dir ${options.stateDir}`, "dim"));
265
+ // `status` never rescans — a rescan journals an activity row and performs a
266
+ // passive discovery scan, both of which need the operator to ask. So the
267
+ // header says how old the scan is and offers the command. The scan's own
268
+ // stamp is the newest `availablePeers[].lastSeenAt`: every discovered
269
+ // session is stamped by the scan that found it, whereas the claude
270
+ // connector's `lastSeenAt` also moves on a delivery. With nothing
271
+ // discovered there is no stamp to read, so the line says that rather than
272
+ // inventing an age.
273
+ const scannedAt = snapshot.availablePeers
274
+ .map((peer) => peer.lastSeenAt)
275
+ .filter((stamp) => stamp !== undefined)
276
+ .sort().at(-1);
277
+ const scanAge = ageMs(scannedAt, now);
278
+ lines.push(paint(scannedAt === undefined ? "sessions: none discovered"
279
+ : `sessions scanned ${relativeAge(scannedAt, now)}`, "dim") +
280
+ (scanAge === undefined || scanAge > STATUS_SCAN_STALE_AFTER_MS
281
+ ? paint(" — run `embassy refresh` to rescan", "yellow") : ""));
282
+ lines.push("", paint("connectors", "bold"));
283
+ const routedConnectors = snapshot.connectors.filter((connector) => connector.provider === "claude" || connector.provider === "codex");
284
+ if (routedConnectors.length === 0)
285
+ lines.push(" none — the broker has no provider connectors");
286
+ for (const connector of routedConnectors) {
287
+ const word = connectorWord(connector);
288
+ const suffix = word === "ok" ? "" : ` ${connector.safeErrorCode ?? "no safe code"}`;
289
+ lines.push(` ${connector.provider.padEnd(7)} ${paint(word, wordColor(word))}${suffix}`);
290
+ if (word !== "ok")
291
+ lines.push(paint(` ${connectorRemedy(connector, snapshot)}`, "dim"));
292
+ if (connector.provider === "claude" && registryDrifted(snapshot)) {
293
+ lines.push(paint(" no parseable session record since broker start — Claude Code's registry layout may have changed; run `embassy check`", "yellow"));
294
+ }
295
+ }
296
+ // A registered shell peer is a connector from the operator's side: the route
297
+ // exists, but whether anything is awaiting it lives in another terminal.
298
+ // Queued mail with no waiter is the one observable proof, and it never
299
+ // touches the overall word. Stale first, so the cap can only hide quiet ones.
300
+ const shellPeers = snapshot.routes
301
+ .filter((route) => route.provider === "peer")
302
+ .sort((left, right) => Number(right.queueDepth > 0) - Number(left.queueDepth > 0));
303
+ for (const peer of shellPeers.slice(0, STATUS_CAPS.shellPeers)) {
304
+ const waiting = peer.queueDepth > 0;
305
+ lines.push(` ${peer.alias} ${waiting
306
+ ? paint("stale (token or await loop gone)", "yellow") : paint("ok", "green")}`);
307
+ if (waiting) {
308
+ lines.push(paint(` ${String(peer.queueDepth)} message(s) waiting: run \`embassy await --alias ${peer.alias} --token-stdin\` in the shell holding its token, or \`embassy unregister-peer --alias ${peer.alias} --token-stdin\`.`, "dim"));
309
+ }
310
+ }
311
+ if (shellPeers.length > STATUS_CAPS.shellPeers) {
312
+ lines.push(paint(` ${String(shellPeers.length - STATUS_CAPS.shellPeers)} more shell peer(s) with empty mailboxes`, "dim"));
313
+ }
314
+ lines.push("", paint("sessions", "bold"));
315
+ if (snapshot.availablePeers.length === 0) {
316
+ lines.push(" none discovered — start a Claude Code session, then run `embassy refresh`");
317
+ }
318
+ else {
319
+ const rows = snapshot.availablePeers.slice(0, STATUS_CAPS.sessions).map((peer) => {
320
+ const word = peer.state === "awaiting_approval" ? "awaiting" : peer.state;
321
+ return [{ value: peer.alias }, { value: word, tone: wordColor(word === "offline" ? "offline" : "ok") },
322
+ { value: peer.routed ? "routed" : "no route yet" },
323
+ { value: relativeAge(peer.lastSeenAt, now) }];
324
+ });
325
+ for (const line of table(["session", "state", "route", "last seen"], rows, paint)) {
326
+ lines.push(` ${line}`);
327
+ }
328
+ if (snapshot.availablePeers.length > STATUS_CAPS.sessions) {
329
+ lines.push(paint(` ${String(snapshot.availablePeers.length - STATUS_CAPS.sessions)} more discovered session(s) — read \`embassy status --json\``, "dim"));
330
+ }
331
+ }
332
+ const collisions = collisionCount(snapshot);
333
+ if (collisions > 0) {
334
+ lines.push(paint(` ${String(collisions)} discovered Claude name(s) are shared by more than one live session and are hidden from this list; ${STATUS_REMEDY.PEER_ALIAS_COLLISION ?? ""}`, "yellow"));
335
+ }
336
+ lines.push("", paint("routes", "bold"));
337
+ if (snapshot.routes.length === 0) {
338
+ lines.push(" none — a Claude session's route installs on its first send; a Codex task is registered explicitly");
339
+ }
340
+ else {
341
+ const views = snapshot.routes.map((route) => ({
342
+ route, view: routeView(route, route.provider === "claude" ? discoveredByAlias.get(route.alias) : undefined, now),
343
+ }));
344
+ const rows = views.map(({ route, view }) => [
345
+ { value: route.alias }, { value: route.provider },
346
+ { value: view.word, tone: wordColor(view.word === "idle" || view.word === "busy" ? "ok" : view.word) },
347
+ { value: String(route.queueDepth) }, { value: view.lastSeen },
348
+ ]);
349
+ for (const line of table(["alias", "provider", "state", "queue", "last seen"], rows, paint)) {
350
+ lines.push(` ${line}`);
351
+ }
352
+ for (const { route, view } of views) {
353
+ if (view.remedy === undefined)
354
+ continue;
355
+ lines.push(paint(` ${route.alias}: ${view.remedy}`, "dim"));
356
+ }
357
+ }
358
+ const recent = snapshot.messages.slice(-options.recent);
359
+ lines.push("", paint(`recent (${String(recent.length)} of ${String(snapshot.messages.length)})`, "bold"));
360
+ if (recent.length === 0)
361
+ lines.push(" no messages retained");
362
+ for (const event of [...recent].reverse()) {
363
+ const latency = event.latencyMs === undefined ? "" : ` ${String(event.latencyMs)} ms`;
364
+ const code = event.safeErrorCode === undefined ? "" : ` ${event.safeErrorCode}`;
365
+ lines.push(` ${relativeAge(event.timestamp, now).padEnd(9)} ${messageArrow(event)} ${event.state}${latency}${code}`);
366
+ if (event.body !== undefined)
367
+ lines.push(paint(` ${previewBody(event.body)}`, "dim"));
368
+ }
369
+ const { shown, hidden, unexplained } = actionableAlerts(snapshot.alerts);
370
+ if (shown.length > 0) {
371
+ lines.push("", paint("alerts", "bold"));
372
+ for (const alert of shown) {
373
+ const subject = alert.alias ?? alert.host ?? alert.provider ?? "";
374
+ lines.push(` ${alert.code}${subject === "" ? "" : ` ${subject}`} ${relativeAge(alert.timestamp, now)}`);
375
+ lines.push(paint(` ${STATUS_REMEDY[alert.code] ?? ""}`, "dim"));
376
+ }
377
+ if (hidden > 0)
378
+ lines.push(paint(` ${String(hidden)} more alert(s) not shown`, "dim"));
379
+ if (unexplained > 0) {
380
+ lines.push(paint(` ${String(unexplained)} alert(s) with no known remedy — read \`embassy status --json\``, "dim"));
381
+ }
382
+ }
383
+ const omitted = Object.entries(snapshot.truncation)
384
+ .filter((entry) => typeof entry[1] === "number" && entry[1] > 0)
385
+ .map(([name, count]) => `${String(count)} ${name}`);
386
+ if (omitted.length > 0) {
387
+ lines.push("", paint(`omitted from this snapshot: ${omitted.join(", ")}`, "dim"));
388
+ }
389
+ return `${lines.join("\n")}\n`;
390
+ }
391
+ export const emptyWatchState = Object.freeze({
392
+ messageStates: new Map(), lastSequence: -1,
393
+ lastActivitySequence: -1, truncatedMessages: 0,
394
+ });
395
+ /**
396
+ * Pure diff of one poll against the last, in stable emit order.
397
+ *
398
+ * A snapshot can carry the same message twice — once as a journaled activity
399
+ * event and once as the live row — so the last occurrence in sequence order is
400
+ * the current state. What this can promise is "at most once per observed
401
+ * change": a transition the broker passed through entirely between two polls
402
+ * is never seen, and rows evicted from the retained window before this tail
403
+ * reached them are reported as a notice rather than silently skipped.
404
+ */
405
+ export function diffWatch(previous, snapshot) {
406
+ const events = [];
407
+ const ordered = [...snapshot.messages].sort((left, right) => left.sequence - right.sequence);
408
+ const messageStates = new Map();
409
+ const sequences = [...ordered.map((event) => event.sequence),
410
+ ...(snapshot.activityEvents ?? []).map((event) => event.sequence)];
411
+ const lastSequence = sequences.length === 0 ? previous.lastSequence
412
+ : Math.max(previous.lastSequence, ...sequences);
413
+ // Message and activity events share one counter, and settling a message
414
+ // re-stamps it, so an ordinary gap in the numbers proves nothing. Only a
415
+ // jump wider than the whole retained window proves rows were evicted before
416
+ // this tail could read them — that, and truncation, are the honest signals.
417
+ const lowest = sequences.length === 0 ? undefined : Math.min(...sequences);
418
+ const missed = lowest === undefined ? 0 : lowest - previous.lastSequence - 1;
419
+ if (previous.lastSequence >= 0 && missed > gatewayPublicSnapshotLimits.messages) {
420
+ events.push({ type: "notice", text: `the retained window advanced past ${String(missed)} event(s) before this tail saw them` });
421
+ }
422
+ if (snapshot.truncation.messages > previous.truncatedMessages) {
423
+ events.push({ type: "notice", text: `${String(snapshot.truncation.messages)} message row(s) omitted from this snapshot by the size budget` });
424
+ }
425
+ for (const event of ordered) {
426
+ const before = previous.messageStates.get(event.messageIdSuffix);
427
+ const already = messageStates.get(event.messageIdSuffix);
428
+ messageStates.set(event.messageIdSuffix, event.state);
429
+ if (already === event.state)
430
+ continue;
431
+ if (before === undefined && already === undefined) {
432
+ events.push({ type: "message", event });
433
+ continue;
434
+ }
435
+ const from = already ?? before;
436
+ if (from === event.state)
437
+ continue;
438
+ events.push({
439
+ type: "transition", message: event.messageIdSuffix, timestamp: event.timestamp,
440
+ from, to: event.state, sourceAlias: event.sourceAlias, targetAlias: event.targetAlias,
441
+ ...(event.latencyMs === undefined ? {} : { latencyMs: event.latencyMs }),
442
+ ...(event.safeErrorCode === undefined ? {} : { safeErrorCode: event.safeErrorCode }),
443
+ });
444
+ }
445
+ let lastActivitySequence = previous.lastActivitySequence;
446
+ for (const event of [...(snapshot.activityEvents ?? [])].sort((left, right) => left.sequence - right.sequence)) {
447
+ if (event.sequence <= previous.lastActivitySequence)
448
+ continue;
449
+ events.push({ type: "activity", event });
450
+ lastActivitySequence = Math.max(lastActivitySequence, event.sequence);
451
+ }
452
+ return {
453
+ state: { messageStates, lastSequence, lastActivitySequence,
454
+ truncatedMessages: snapshot.truncation.messages },
455
+ events,
456
+ };
457
+ }
458
+ const SETTLED_BADLY = ["failed", "ambiguous", "unconfirmed", "expired", "cancelled", "abandoned", "rejected"];
459
+ /** Local wall-clock time, because a tail is read beside other live logs. */
460
+ const clockOf = (timestamp) => {
461
+ const parsed = Date.parse(timestamp);
462
+ return Number.isFinite(parsed) ? new Date(parsed).toTimeString().slice(0, 8) : "--:--:--";
463
+ };
464
+ /** One terminal line per watch event; the activity line is deliberately secondary. */
465
+ export function renderWatchEvent(event, color) {
466
+ const paint = terminalPainter(color);
467
+ if (event.type === "notice")
468
+ return paint(` note ${event.text}`, "yellow");
469
+ if (event.type === "message") {
470
+ const preview = event.event.body === undefined
471
+ ? "" : ` ${paint(previewBody(event.event.body, 40), "dim")}`;
472
+ return `${clockOf(event.event.timestamp)} ${event.event.messageIdSuffix} ${messageArrow(event.event)} ${event.event.state} ${String(event.event.bytes)} B${preview}`;
473
+ }
474
+ if (event.type === "transition") {
475
+ const latency = event.latencyMs === undefined ? "" : ` (${String(event.latencyMs)} ms)`;
476
+ const code = event.safeErrorCode === undefined ? "" : ` ${event.safeErrorCode}`;
477
+ const word = event.to === "delivered" ? paint(event.to, "green")
478
+ : SETTLED_BADLY.includes(event.to) ? paint(event.to, "red") : event.to;
479
+ return `${clockOf(event.timestamp)} ${event.message} ${event.from} → ${word}${latency}${code}`;
480
+ }
481
+ const detail = event.event.safeErrorCode === undefined ? "" : ` ${event.event.safeErrorCode}`;
482
+ return paint(`${clockOf(event.event.timestamp)} ${event.event.action} ${event.event.aliases.join(", ")} ${event.event.outcome}${detail}`, "dim");
483
+ }
484
+ /** Pure helpers the renderer's own tests drive directly; not part of the CLI surface. */
485
+ export const __test = Object.freeze({
486
+ connectorWord, overallWord, previewBody, relativeAge, routeView,
487
+ });
488
+ //# sourceMappingURL=status-view.js.map