@openscout/protocol 0.2.98 → 0.2.104

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 (63) hide show
  1. package/dist/actors.d.ts +2 -2
  2. package/dist/actors.d.ts.map +1 -1
  3. package/dist/actors.js +1 -0
  4. package/dist/broker-routes.d.ts +8 -0
  5. package/dist/broker-routes.d.ts.map +1 -1
  6. package/dist/broker-routes.js +15 -0
  7. package/dist/channel-identity.d.ts +77 -0
  8. package/dist/channel-identity.d.ts.map +1 -1
  9. package/dist/channel-identity.js +134 -0
  10. package/dist/channel-invites.d.ts +179 -0
  11. package/dist/channel-invites.d.ts.map +1 -0
  12. package/dist/channel-invites.js +166 -0
  13. package/dist/channel-reception.d.ts +78 -0
  14. package/dist/channel-reception.d.ts.map +1 -0
  15. package/dist/channel-reception.js +219 -0
  16. package/dist/common.d.ts +1 -1
  17. package/dist/common.d.ts.map +1 -1
  18. package/dist/conversation-projection.d.ts +1 -1
  19. package/dist/conversation-projection.js +1 -1
  20. package/dist/herdr-workspace-digest.d.ts +151 -0
  21. package/dist/herdr-workspace-digest.d.ts.map +1 -0
  22. package/dist/herdr-workspace-digest.js +343 -0
  23. package/dist/index.d.ts +4 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +4 -0
  26. package/dist/machines.d.ts +233 -0
  27. package/dist/machines.d.ts.map +1 -0
  28. package/dist/machines.js +550 -0
  29. package/dist/mesh.d.ts +8 -0
  30. package/dist/mesh.d.ts.map +1 -1
  31. package/dist/runtime-catalog-contract.d.ts +1 -1
  32. package/dist/runtime-catalog-contract.d.ts.map +1 -1
  33. package/dist/runtime-catalog-contract.js +1 -1
  34. package/dist/runtime-catalog.generated.d.ts +34 -15
  35. package/dist/runtime-catalog.generated.d.ts.map +1 -1
  36. package/dist/runtime-catalog.generated.js +46 -19
  37. package/dist/runtime-execution.d.ts +1 -1
  38. package/dist/runtime-execution.d.ts.map +1 -1
  39. package/dist/scout-delivery.d.ts +1 -0
  40. package/dist/scout-delivery.d.ts.map +1 -1
  41. package/dist/terminal-host-topology.d.ts +20 -1
  42. package/dist/terminal-host-topology.d.ts.map +1 -1
  43. package/dist/terminal-host-topology.js +8 -2
  44. package/dist/transports.d.ts +39 -1
  45. package/dist/transports.d.ts.map +1 -1
  46. package/package.json +2 -2
  47. package/src/actors.ts +2 -0
  48. package/src/broker-routes.ts +17 -0
  49. package/src/channel-identity.ts +151 -0
  50. package/src/channel-invites.ts +343 -0
  51. package/src/channel-reception.ts +308 -0
  52. package/src/common.ts +1 -0
  53. package/src/conversation-projection.ts +1 -1
  54. package/src/herdr-workspace-digest.ts +458 -0
  55. package/src/index.ts +4 -0
  56. package/src/machines.ts +743 -0
  57. package/src/mesh.ts +8 -0
  58. package/src/runtime-catalog-contract.ts +1 -1
  59. package/src/runtime-catalog.generated.ts +46 -19
  60. package/src/runtime-catalog.v1.json +18 -8
  61. package/src/scout-delivery.ts +1 -0
  62. package/src/terminal-host-topology.ts +32 -2
  63. package/src/transports.ts +48 -0
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Herdr workspace digest — the presentation layer over {@link HerdrSessionTopology}.
3
+ *
4
+ * The raw projection is faithful but shapeless: a complex session is several
5
+ * workspaces × tabs × panes, and handing that whole tree to an operator (or to
6
+ * an assistant that has to answer for it) buries the one thing they came for.
7
+ * A digest ranks, groups, bounds, and describes it.
8
+ *
9
+ * Four commitments, in order of how much they matter:
10
+ *
11
+ * 1. **Attention leads.** A `blocked` pane is the only thing in a herdr session
12
+ * that is actually waiting on a person. It comes first, always, before any
13
+ * inventory. Everything else is context for it.
14
+ * 2. **Directory is the grouping axis, not the tab.** Operators think "which
15
+ * repo", herdr thinks "which tab". Grouping is by the pane's literal working
16
+ * directory — no repo-root inference, because a guessed grouping presented as
17
+ * a fact is worse than an honest fragmented one.
18
+ * 3. **Shape is described, not dumped.** A tab's layout becomes one readable
19
+ * phrase ("2×2 grid", "3 columns (2 · 1 · 1)") derived from the pane rects.
20
+ * 4. **Bounded and honest.** Panes are capped and truncation is declared;
21
+ * `unknown` never collapses into `done`; a persisted projection is labeled
22
+ * last-known rather than passed off as live.
23
+ *
24
+ * There are no mutation verbs here, for the same reason there are none in the
25
+ * topology projection: herdr owns workspaces, tabs and panes, and Scout's layer
26
+ * is coordination over whatever host is present. A digest pane carries the
27
+ * herdr `target` string so a caller can construct a handoff; performing it is
28
+ * the herdr client's job.
29
+ */
30
+
31
+ import type {
32
+ HerdrAgentStatus,
33
+ HerdrPaneProjection,
34
+ HerdrSessionTopology,
35
+ HerdrTabLayout,
36
+ HerdrTopologyUnavailableReason,
37
+ } from "./terminal-host-topology.js";
38
+
39
+ /** Total emitted panes across every group before the digest declares truncation. */
40
+ export const HERDR_DIGEST_PANE_CAP = 40;
41
+ /** Ceiling on each of the two lead lists; attention is a shortlist, not an inventory. */
42
+ export const HERDR_DIGEST_LEAD_CAP = 12;
43
+
44
+ export type HerdrStatusCounts = Record<HerdrAgentStatus, number>;
45
+
46
+ export type HerdrDigestPane = {
47
+ /**
48
+ * What `herdr agent <verb>` accepts as a target for this pane: the stable
49
+ * terminal id when herdr reported one, else the pane id. Null on a persisted
50
+ * projection, where neither is live — a pane you cannot address yet.
51
+ */
52
+ target: string | null;
53
+ paneId: string;
54
+ workspaceId: string;
55
+ tabId: string;
56
+ label: string | null;
57
+ /** Detected agent label ("claude", "codex"), or null for a plain shell. */
58
+ agent: string | null;
59
+ status: HerdrAgentStatus;
60
+ /** Working directory: the foreground process's when known, else the pane's. */
61
+ directory: string | null;
62
+ focused: boolean;
63
+ /**
64
+ * Scrollback backlog depth in lines. The cheapest activity signal a
65
+ * projection carries — a deep backlog on an idle pane is work that already
66
+ * happened, not work happening. Null when herdr did not report scroll state.
67
+ */
68
+ backlog: number | null;
69
+ };
70
+
71
+ export type HerdrDigestGroup = {
72
+ /** The panes' literal shared working directory; null when they report none. */
73
+ directory: string | null;
74
+ /** Last path segment — how an operator names the place. */
75
+ name: string;
76
+ counts: HerdrStatusCounts;
77
+ panes: HerdrDigestPane[];
78
+ /** Panes in this group dropped by the pane cap. */
79
+ omitted: number;
80
+ /**
81
+ * What was dropped, by status. A collapsed label has to carry its own scent:
82
+ * a reader decides whether the hidden rows matter from the label alone, so
83
+ * "2 idle not shown" is a usable label and "2 not shown" is not.
84
+ */
85
+ omittedCounts: HerdrStatusCounts;
86
+ };
87
+
88
+ export type HerdrDigestShape = {
89
+ workspaceId: string;
90
+ workspaceLabel: string;
91
+ tabId: string;
92
+ tabLabel: string;
93
+ paneCount: number;
94
+ /** One readable phrase for the arrangement, e.g. "2×2 grid". */
95
+ arrangement: string;
96
+ zoomed: boolean;
97
+ focused: boolean;
98
+ };
99
+
100
+ export type HerdrWorkspaceDigest = {
101
+ session: string;
102
+ /** True only for a projection read from a running herdr server. */
103
+ live: boolean;
104
+ observedAt: number;
105
+ /** Set when the projection is the persisted last-known state, not a live read. */
106
+ savedAt: number | null;
107
+ /**
108
+ * Why a non-live projection is not live. `unreadable` means a server for the
109
+ * session IS running and did not answer — the digest must not present that as
110
+ * a stopped session. Null on a live read.
111
+ */
112
+ unavailable: HerdrTopologyUnavailableReason | null;
113
+ totals: {
114
+ workspaces: number;
115
+ tabs: number;
116
+ panes: number;
117
+ /** Panes herdr recognized an agent in. The rest are plain shells. */
118
+ agents: number;
119
+ };
120
+ counts: HerdrStatusCounts;
121
+ /** Blocked panes — the only entries that represent something waiting on a person. */
122
+ needsYou: HerdrDigestPane[];
123
+ /** Panes herdr observed as working, bounded. */
124
+ working: HerdrDigestPane[];
125
+ groups: HerdrDigestGroup[];
126
+ shapes: HerdrDigestShape[];
127
+ truncated: boolean;
128
+ /** Caveats the reader must carry with the numbers. Never decorative. */
129
+ notes: string[];
130
+ };
131
+
132
+ const EMPTY_COUNTS: HerdrStatusCounts = { blocked: 0, working: 0, idle: 0, done: 0, unknown: 0 };
133
+
134
+ /** Attention order. Blocked outranks working because it is the only state that waits on a person. */
135
+ const STATUS_RANK: Record<HerdrAgentStatus, number> = {
136
+ blocked: 5,
137
+ working: 4,
138
+ idle: 3,
139
+ done: 2,
140
+ unknown: 1,
141
+ };
142
+
143
+ export function herdrStatusRank(status: HerdrAgentStatus): number {
144
+ return STATUS_RANK[status];
145
+ }
146
+
147
+ function emptyCounts(): HerdrStatusCounts {
148
+ return { ...EMPTY_COUNTS };
149
+ }
150
+
151
+ function totalCount(counts: HerdrStatusCounts): number {
152
+ return counts.blocked + counts.working + counts.idle + counts.done + counts.unknown;
153
+ }
154
+
155
+ function paneDirectory(pane: HerdrPaneProjection): string | null {
156
+ return pane.foregroundCwd ?? pane.cwd ?? null;
157
+ }
158
+
159
+ function directoryName(directory: string | null): string {
160
+ if (!directory) return "no directory";
161
+ const segments = directory.replace(/\/+$/, "").split("/").filter(Boolean);
162
+ return segments[segments.length - 1] ?? directory;
163
+ }
164
+
165
+ function toDigestPane(pane: HerdrPaneProjection): HerdrDigestPane {
166
+ return {
167
+ target: pane.terminalId ?? pane.paneId ?? null,
168
+ paneId: pane.paneId,
169
+ workspaceId: pane.workspaceId,
170
+ tabId: pane.tabId,
171
+ label: pane.label,
172
+ agent: pane.agent,
173
+ status: pane.agentStatus,
174
+ directory: paneDirectory(pane),
175
+ focused: pane.focused,
176
+ backlog: pane.scroll?.maxOffsetFromBottom ?? null,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Rank panes for a lead list: attention state, then the focused pane (where the
182
+ * operator's eyes already are), then the deepest backlog, then pane id so the
183
+ * order never wobbles between polls.
184
+ */
185
+ function compareLeadPanes(left: HerdrDigestPane, right: HerdrDigestPane): number {
186
+ const byStatus = STATUS_RANK[right.status] - STATUS_RANK[left.status];
187
+ if (byStatus !== 0) return byStatus;
188
+ if (left.focused !== right.focused) return left.focused ? -1 : 1;
189
+ const byBacklog = (right.backlog ?? -1) - (left.backlog ?? -1);
190
+ if (byBacklog !== 0) return byBacklog;
191
+ return left.paneId.localeCompare(right.paneId, undefined, { numeric: true });
192
+ }
193
+
194
+ /**
195
+ * Describe a tab's arrangement from its pane rects.
196
+ *
197
+ * Panes sharing an x start a column; panes sharing a y start a row. A layout
198
+ * whose columns all hold the same number of panes is a grid and is named as
199
+ * one; anything else is named by its columns and their depths, which is both
200
+ * shorter and more truthful than inventing a name for an irregular split.
201
+ */
202
+ export function describeHerdrArrangement(
203
+ layout: HerdrTabLayout | null,
204
+ paneCount: number,
205
+ ): string {
206
+ if (paneCount <= 0) return "no panes";
207
+ if (paneCount === 1) return "single pane";
208
+ const panes = layout?.panes ?? [];
209
+ if (panes.length !== paneCount) return `${paneCount} panes (layout unavailable)`;
210
+
211
+ const columns = new Map<number, number>();
212
+ const rows = new Map<number, number>();
213
+ for (const pane of panes) {
214
+ columns.set(pane.rect.x, (columns.get(pane.rect.x) ?? 0) + 1);
215
+ rows.set(pane.rect.y, (rows.get(pane.rect.y) ?? 0) + 1);
216
+ }
217
+ if (columns.size === 1) return `${paneCount} stacked rows`;
218
+ if (rows.size === 1) return `${paneCount} side-by-side columns`;
219
+
220
+ const depths = [...columns.entries()]
221
+ .sort((left, right) => left[0] - right[0])
222
+ .map(([, count]) => count);
223
+ const first = depths[0] ?? 0;
224
+ if (depths.every((depth) => depth === first) && first * columns.size === paneCount) {
225
+ return `${first}×${columns.size} grid`;
226
+ }
227
+ return `${columns.size} columns (${depths.join(" · ")})`;
228
+ }
229
+
230
+ /**
231
+ * Build the digest for one herdr session topology.
232
+ *
233
+ * Pure: the same topology always digests to the same value, so the whole
234
+ * presentation is testable without a herdr server.
235
+ */
236
+ export function digestHerdrTopology(
237
+ topology: HerdrSessionTopology,
238
+ options: { paneCap?: number; leadCap?: number } = {},
239
+ ): HerdrWorkspaceDigest {
240
+ const paneCap = options.paneCap ?? HERDR_DIGEST_PANE_CAP;
241
+ const leadCap = options.leadCap ?? HERDR_DIGEST_LEAD_CAP;
242
+
243
+ const counts = emptyCounts();
244
+ const shapes: HerdrDigestShape[] = [];
245
+ const all: HerdrDigestPane[] = [];
246
+ let tabTotal = 0;
247
+ let agentTotal = 0;
248
+
249
+ for (const workspace of topology.workspaces) {
250
+ const workspaceLabel = workspace.label ?? (workspace.number != null ? `workspace ${workspace.number}` : workspace.workspaceId);
251
+ for (const tab of workspace.tabs) {
252
+ tabTotal += 1;
253
+ shapes.push({
254
+ workspaceId: workspace.workspaceId,
255
+ workspaceLabel,
256
+ tabId: tab.tabId,
257
+ tabLabel: tab.label ?? (tab.number != null ? `tab ${tab.number}` : tab.tabId),
258
+ paneCount: tab.panes.length,
259
+ arrangement: describeHerdrArrangement(tab.layout, tab.panes.length),
260
+ zoomed: tab.layout?.zoomed ?? false,
261
+ focused: workspace.focused && tab.focused,
262
+ });
263
+ for (const pane of tab.panes) {
264
+ const digestPane = toDigestPane(pane);
265
+ counts[digestPane.status] += 1;
266
+ if (digestPane.agent) agentTotal += 1;
267
+ all.push(digestPane);
268
+ }
269
+ }
270
+ }
271
+
272
+ // Groups are filled in rank order so the pane cap spends its budget on the
273
+ // panes that matter, not on whichever directory herdr happened to list first.
274
+ const ranked = [...all].sort(compareLeadPanes);
275
+ const kept = ranked.slice(0, paneCap);
276
+ const droppedByDirectory = new Map<string, HerdrStatusCounts>();
277
+ for (const pane of ranked.slice(paneCap)) {
278
+ const key = pane.directory ?? "";
279
+ let dropped = droppedByDirectory.get(key);
280
+ if (!dropped) {
281
+ dropped = emptyCounts();
282
+ droppedByDirectory.set(key, dropped);
283
+ }
284
+ dropped[pane.status] += 1;
285
+ }
286
+
287
+ const groupsByDirectory = new Map<string, HerdrDigestGroup>();
288
+ for (const pane of all) {
289
+ const key = pane.directory ?? "";
290
+ let group = groupsByDirectory.get(key);
291
+ if (!group) {
292
+ group = {
293
+ directory: pane.directory,
294
+ name: directoryName(pane.directory),
295
+ counts: emptyCounts(),
296
+ panes: [],
297
+ omitted: totalCount(droppedByDirectory.get(key) ?? EMPTY_COUNTS),
298
+ omittedCounts: { ...(droppedByDirectory.get(key) ?? EMPTY_COUNTS) },
299
+ };
300
+ groupsByDirectory.set(key, group);
301
+ }
302
+ group.counts[pane.status] += 1;
303
+ }
304
+ const keptIds = new Set(kept.map((pane) => `${pane.workspaceId}/${pane.paneId}`));
305
+ for (const pane of all) {
306
+ if (!keptIds.has(`${pane.workspaceId}/${pane.paneId}`)) continue;
307
+ groupsByDirectory.get(pane.directory ?? "")?.panes.push(pane);
308
+ }
309
+
310
+ const groups = [...groupsByDirectory.values()];
311
+ for (const group of groups) group.panes.sort(compareLeadPanes);
312
+ // Groups sort by what is waiting, then what is moving, then size — the same
313
+ // precedence the lead lists use, applied one level up.
314
+ groups.sort((left, right) =>
315
+ right.counts.blocked - left.counts.blocked
316
+ || right.counts.working - left.counts.working
317
+ || (right.panes.length + right.omitted) - (left.panes.length + left.omitted)
318
+ || left.name.localeCompare(right.name, undefined, { numeric: true }));
319
+
320
+ const notes: string[] = [];
321
+ if (!topology.running) {
322
+ // "Last known" is only useful with a when: a layout saved an hour ago and
323
+ // one saved in March are the same claim with very different weight.
324
+ const saved = topology.savedAt ? ` Last saved ${new Date(topology.savedAt).toISOString()}.` : "";
325
+ notes.push(topology.unavailable === "unreadable"
326
+ // The session IS running; herdr just would not answer. Saying "stopped"
327
+ // here would be the one thing a digest must never do — state, of live
328
+ // work, that it is not live.
329
+ ? `A herdr server for this session is running but did not answer, so this is the persisted last-known layout, not what is on screen. The usual cause is a protocol skew between the installed herdr client and a long-lived server; restarting that session's server (which exits its pane processes) is what clears it.${saved}`
330
+ : `Not live: this is the persisted last-known layout. Agent status, terminal ids and geometry are absent from it — no pane here is claimed to be running.${saved}`);
331
+ }
332
+ if (counts.unknown > 0) {
333
+ notes.push(
334
+ `${counts.unknown} ${counts.unknown === 1 ? "pane reports" : "panes report"} unknown: herdr sees an agent it cannot classify. Unknown is the absence of a signal, not completion.`,
335
+ );
336
+ }
337
+
338
+ return {
339
+ session: topology.session,
340
+ live: topology.running,
341
+ observedAt: topology.observedAt,
342
+ savedAt: topology.savedAt ?? null,
343
+ unavailable: topology.running ? null : topology.unavailable ?? "not_running",
344
+ totals: {
345
+ workspaces: topology.workspaces.length,
346
+ tabs: tabTotal,
347
+ panes: all.length,
348
+ agents: agentTotal,
349
+ },
350
+ counts,
351
+ needsYou: ranked.filter((pane) => pane.status === "blocked").slice(0, leadCap),
352
+ working: ranked.filter((pane) => pane.status === "working").slice(0, leadCap),
353
+ groups,
354
+ shapes,
355
+ truncated: all.length > paneCap,
356
+ notes,
357
+ };
358
+ }
359
+
360
+ /**
361
+ * One row, led by whatever is most specific to this pane.
362
+ *
363
+ * Rows that all begin `term-w1:p1`, `term-w1:p2` share a long prefix, and a
364
+ * scanning reader learns to skip the opening of every line — so the identity
365
+ * column becomes the one column nobody reads. The lead is therefore the pane's
366
+ * label when it has one, and its pane id when it does not, since a short id
367
+ * differs within the first couple of characters where a terminal id does not.
368
+ *
369
+ * The pane id is enough to act on: `herdr agent <verb>` accepts the pane
370
+ * currently hosting an agent as a target. The directory is the basename only —
371
+ * the full path is carried once, in the grouped section, and repeating it on
372
+ * every row buys nothing.
373
+ */
374
+ function paneLine(pane: HerdrDigestPane): string {
375
+ const parts = pane.label
376
+ ? [pane.label, pane.agent ?? "shell", pane.paneId]
377
+ : [pane.paneId, pane.agent ?? "shell"];
378
+ if (pane.directory) parts.push(directoryName(pane.directory));
379
+ if (pane.focused) parts.push("focused");
380
+ return ` ${parts.join(" ")}`;
381
+ }
382
+
383
+ /** "2 idle not shown" — a collapsed label a reader can decide from. */
384
+ function omittedLabel(counts: HerdrStatusCounts): string | null {
385
+ const total = totalCount(counts);
386
+ if (total === 0) return null;
387
+ const order: HerdrAgentStatus[] = ["blocked", "working", "idle", "done", "unknown"];
388
+ const present = order.filter((status) => counts[status] > 0);
389
+ if (present.length === 1) return `+${total} ${present[0]} not shown`;
390
+ return `+${total} not shown (${present.map((status) => `${counts[status]} ${status}`).join(" · ")})`;
391
+ }
392
+
393
+ function countsLine(counts: HerdrStatusCounts): string {
394
+ const order: HerdrAgentStatus[] = ["blocked", "working", "idle", "done", "unknown"];
395
+ const parts = order.filter((status) => counts[status] > 0).map((status) => `${counts[status]} ${status}`);
396
+ return parts.length ? parts.join(" · ") : "no panes";
397
+ }
398
+
399
+ /**
400
+ * Render a digest as the text an operator should actually read.
401
+ *
402
+ * Ordering is the whole design: what is waiting, then what is moving, then
403
+ * where things live, then how the screen is arranged. A reader who stops after
404
+ * the first section has still got the answer that mattered.
405
+ *
406
+ * Section heads are verb phrases rather than bare nouns, and there is no
407
+ * trailing caveat block: a qualifier that changes how a count should be read
408
+ * sits directly under that count, because a scanning reader reaches the top of
409
+ * the output and not the bottom of it.
410
+ */
411
+ export function renderHerdrWorkspaceDigest(digest: HerdrWorkspaceDigest): string {
412
+ const lines: string[] = [];
413
+ const state = digest.live
414
+ ? "live"
415
+ : digest.unavailable === "unreadable" ? "running, not readable" : "not running";
416
+ const { workspaces, tabs, panes } = digest.totals;
417
+ lines.push(
418
+ `herdr · ${digest.session} — ${state} · ${workspaces} workspace${workspaces === 1 ? "" : "s"}, ${tabs} tab${tabs === 1 ? "" : "s"}, ${panes} pane${panes === 1 ? "" : "s"} (${countsLine(digest.counts)})`,
419
+ );
420
+ // Caveats sit under the counts they qualify, not in a block at the bottom.
421
+ // A qualifier that changes how a number should be read is body text; a
422
+ // scanning reader never reaches a footnote, and these are the lines that stop
423
+ // someone believing `unknown` meant done or that a stale layout was current.
424
+ for (const note of digest.notes) lines.push(` ${note}`);
425
+
426
+ if (digest.needsYou.length) {
427
+ lines.push("", "Waiting on you");
428
+ for (const pane of digest.needsYou) lines.push(paneLine(pane));
429
+ }
430
+ if (digest.working.length) {
431
+ lines.push("", "Working now");
432
+ for (const pane of digest.working) lines.push(paneLine(pane));
433
+ }
434
+
435
+ if (digest.groups.length) {
436
+ lines.push("", "Grouped by directory");
437
+ for (const group of digest.groups) {
438
+ const total = totalCount(group.counts);
439
+ const where = group.directory ? ` (${group.directory})` : "";
440
+ const hidden = omittedLabel(group.omittedCounts);
441
+ lines.push(` ${group.name}${where} — ${total} pane${total === 1 ? "" : "s"} · ${countsLine(group.counts)}${hidden ? ` · ${hidden}` : ""}`);
442
+ }
443
+ }
444
+
445
+ if (digest.shapes.length) {
446
+ lines.push("", "Arranged like this");
447
+ for (const shape of digest.shapes) {
448
+ const marks = [shape.arrangement];
449
+ if (shape.zoomed) marks.push("zoomed");
450
+ if (shape.focused) marks.push("focused");
451
+ // The tab id leads because two workspaces can carry the same label, and
452
+ // a label can itself contain the separator — the id cannot do either.
453
+ lines.push(` ${shape.tabId} ${shape.workspaceLabel} / ${shape.tabLabel} — ${marks.join(" · ")}`);
454
+ }
455
+ }
456
+
457
+ return lines.join("\n");
458
+ }
package/src/index.ts CHANGED
@@ -7,9 +7,12 @@ export * from "./a2a.js";
7
7
  export * from "./scout-agent-card.js";
8
8
  export * from "./relay-agent-card.js";
9
9
  export * from "./mesh.js";
10
+ export * from "./machines.js";
10
11
  export * from "./conversations.js";
11
12
  export * from "./conversation-projection.js";
12
13
  export * from "./channel-identity.js";
14
+ export * from "./channel-invites.js";
15
+ export * from "./channel-reception.js";
13
16
  export * from "./collaboration.js";
14
17
  export * from "./assigned-roles.js";
15
18
  export * from "./orchestrator-prompt.js";
@@ -53,5 +56,6 @@ export * from "./terminal-surface-id.js";
53
56
  export * from "./terminal-sessions.js";
54
57
  export * from "./terminal-workspaces.js";
55
58
  export * from "./terminal-host-topology.js";
59
+ export * from "./herdr-workspace-digest.js";
56
60
  export * from "./broker-routes.js";
57
61
  export * from "./rendezvous.js";