@openscout/protocol 0.2.97 → 0.2.102

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.
@@ -8,9 +8,13 @@ export declare const scoutBrokerPaths: {
8
8
  readonly capabilities: "/v1/capabilities";
9
9
  readonly runtimeCatalog: "/v1/runtime-catalog";
10
10
  readonly topologySnapshot: "/v1/topology/snapshot";
11
+ readonly meshNodes: "/v1/mesh/nodes";
12
+ readonly meshNodeState: "/v1/mesh/node-state";
11
13
  readonly tailDiscover: "/v1/tail/discover";
12
14
  readonly tailRecent: "/v1/tail/recent";
13
15
  readonly repoWatchSnapshot: "/v1/repo-watch/snapshot";
16
+ readonly machines: "/v1/machines";
17
+ readonly machinesScan: "/v1/machines/scan";
14
18
  readonly messages: "/v1/messages";
15
19
  readonly brokerMessages: "/v1/broker/messages";
16
20
  readonly eventsStream: "/v1/events/stream";
@@ -35,7 +39,10 @@ export declare const scoutBrokerPaths: {
35
39
  };
36
40
  export declare function scoutBrokerMessagesListPath(search: URLSearchParams): string;
37
41
  export declare function scoutBrokerMessagesPath(search: URLSearchParams): string;
42
+ /** Name a conversation. An empty title hands it back to automatic naming. */
43
+ export declare function scoutBrokerConversationTitlePath(conversationId: string): string;
38
44
  export declare function scoutBrokerInvocationPath(invocationId: string): string;
39
45
  export declare function scoutBrokerInvocationStreamPath(invocationId: string): string;
40
46
  export declare function scoutBrokerInvocationLifecyclePath(invocationId: string): string;
47
+ export declare function scoutBrokerMachinePath(machineRef: string): string;
41
48
  //# sourceMappingURL=broker-routes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"broker-routes.d.ts","sourceRoot":"","sources":["../src/broker-routes.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCnB,CAAC;AAEX,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAG3E;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAKvE;AAED,wBAAgB,yBAAyB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAEtE;AAED,wBAAgB,+BAA+B,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAE5E;AAED,wBAAgB,kCAAkC,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAE/E"}
1
+ {"version":3,"file":"broker-routes.d.ts","sourceRoot":"","sources":["../src/broker-routes.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCnB,CAAC;AAEX,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAG3E;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAKvE;AAED,6EAA6E;AAC7E,wBAAgB,gCAAgC,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAE/E;AAED,wBAAgB,yBAAyB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAEtE;AAED,wBAAgB,+BAA+B,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAE5E;AAED,wBAAgB,kCAAkC,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAE/E;AAED,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAEjE"}
@@ -11,9 +11,16 @@ export const scoutBrokerPaths = {
11
11
  capabilities: "/v1/capabilities",
12
12
  runtimeCatalog: "/v1/runtime-catalog",
13
13
  topologySnapshot: "/v1/topology/snapshot",
14
+ meshNodes: "/v1/mesh/nodes",
15
+ // Observe-tier compact node state. Read against a PEER broker's base URL
16
+ // through the signed/pinned mesh client; the local home feed is never
17
+ // widened for remote callers (mesh trust cone §4).
18
+ meshNodeState: "/v1/mesh/node-state",
14
19
  tailDiscover: "/v1/tail/discover",
15
20
  tailRecent: "/v1/tail/recent",
16
21
  repoWatchSnapshot: "/v1/repo-watch/snapshot",
22
+ machines: "/v1/machines",
23
+ machinesScan: "/v1/machines/scan",
17
24
  messages: "/v1/messages",
18
25
  brokerMessages: "/v1/broker/messages",
19
26
  eventsStream: "/v1/events/stream",
@@ -46,6 +53,10 @@ export function scoutBrokerMessagesPath(search) {
46
53
  ? `${scoutBrokerPaths.v1.brokerMessages}?${q}`
47
54
  : scoutBrokerPaths.v1.brokerMessages;
48
55
  }
56
+ /** Name a conversation. An empty title hands it back to automatic naming. */
57
+ export function scoutBrokerConversationTitlePath(conversationId) {
58
+ return `${scoutBrokerPaths.v1.conversations}/${encodeURIComponent(conversationId)}/title`;
59
+ }
49
60
  export function scoutBrokerInvocationPath(invocationId) {
50
61
  return `${scoutBrokerPaths.v1.invocations}/${encodeURIComponent(invocationId)}`;
51
62
  }
@@ -55,3 +66,6 @@ export function scoutBrokerInvocationStreamPath(invocationId) {
55
66
  export function scoutBrokerInvocationLifecyclePath(invocationId) {
56
67
  return `${scoutBrokerInvocationPath(invocationId)}/lifecycle`;
57
68
  }
69
+ export function scoutBrokerMachinePath(machineRef) {
70
+ return `${scoutBrokerPaths.v1.machines}/${encodeURIComponent(machineRef)}`;
71
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ConversationKind } from "./conversations.js";
2
2
  import type { ObservedActivity } from "./observed-status.js";
3
- export declare const CONVERSATION_PROJECTION_VERSION = 1;
3
+ export declare const CONVERSATION_PROJECTION_VERSION = 2;
4
4
  export type ConversationProjectionEntityKind = "scout_conversation" | "observed_session";
5
5
  export type ConversationProjectionVisibility = "visible" | "hidden";
6
6
  export type ConversationProjectionItem = {
@@ -1,4 +1,4 @@
1
- export const CONVERSATION_PROJECTION_VERSION = 1;
1
+ export const CONVERSATION_PROJECTION_VERSION = 2;
2
2
  export function scoutConversationFeedId(conversationId) {
3
3
  return `conv:${conversationId}`;
4
4
  }
@@ -0,0 +1,151 @@
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
+ import type { HerdrAgentStatus, HerdrSessionTopology, HerdrTabLayout, HerdrTopologyUnavailableReason } from "./terminal-host-topology.js";
31
+ /** Total emitted panes across every group before the digest declares truncation. */
32
+ export declare const HERDR_DIGEST_PANE_CAP = 40;
33
+ /** Ceiling on each of the two lead lists; attention is a shortlist, not an inventory. */
34
+ export declare const HERDR_DIGEST_LEAD_CAP = 12;
35
+ export type HerdrStatusCounts = Record<HerdrAgentStatus, number>;
36
+ export type HerdrDigestPane = {
37
+ /**
38
+ * What `herdr agent <verb>` accepts as a target for this pane: the stable
39
+ * terminal id when herdr reported one, else the pane id. Null on a persisted
40
+ * projection, where neither is live — a pane you cannot address yet.
41
+ */
42
+ target: string | null;
43
+ paneId: string;
44
+ workspaceId: string;
45
+ tabId: string;
46
+ label: string | null;
47
+ /** Detected agent label ("claude", "codex"), or null for a plain shell. */
48
+ agent: string | null;
49
+ status: HerdrAgentStatus;
50
+ /** Working directory: the foreground process's when known, else the pane's. */
51
+ directory: string | null;
52
+ focused: boolean;
53
+ /**
54
+ * Scrollback backlog depth in lines. The cheapest activity signal a
55
+ * projection carries — a deep backlog on an idle pane is work that already
56
+ * happened, not work happening. Null when herdr did not report scroll state.
57
+ */
58
+ backlog: number | null;
59
+ };
60
+ export type HerdrDigestGroup = {
61
+ /** The panes' literal shared working directory; null when they report none. */
62
+ directory: string | null;
63
+ /** Last path segment — how an operator names the place. */
64
+ name: string;
65
+ counts: HerdrStatusCounts;
66
+ panes: HerdrDigestPane[];
67
+ /** Panes in this group dropped by the pane cap. */
68
+ omitted: number;
69
+ /**
70
+ * What was dropped, by status. A collapsed label has to carry its own scent:
71
+ * a reader decides whether the hidden rows matter from the label alone, so
72
+ * "2 idle not shown" is a usable label and "2 not shown" is not.
73
+ */
74
+ omittedCounts: HerdrStatusCounts;
75
+ };
76
+ export type HerdrDigestShape = {
77
+ workspaceId: string;
78
+ workspaceLabel: string;
79
+ tabId: string;
80
+ tabLabel: string;
81
+ paneCount: number;
82
+ /** One readable phrase for the arrangement, e.g. "2×2 grid". */
83
+ arrangement: string;
84
+ zoomed: boolean;
85
+ focused: boolean;
86
+ };
87
+ export type HerdrWorkspaceDigest = {
88
+ session: string;
89
+ /** True only for a projection read from a running herdr server. */
90
+ live: boolean;
91
+ observedAt: number;
92
+ /** Set when the projection is the persisted last-known state, not a live read. */
93
+ savedAt: number | null;
94
+ /**
95
+ * Why a non-live projection is not live. `unreadable` means a server for the
96
+ * session IS running and did not answer — the digest must not present that as
97
+ * a stopped session. Null on a live read.
98
+ */
99
+ unavailable: HerdrTopologyUnavailableReason | null;
100
+ totals: {
101
+ workspaces: number;
102
+ tabs: number;
103
+ panes: number;
104
+ /** Panes herdr recognized an agent in. The rest are plain shells. */
105
+ agents: number;
106
+ };
107
+ counts: HerdrStatusCounts;
108
+ /** Blocked panes — the only entries that represent something waiting on a person. */
109
+ needsYou: HerdrDigestPane[];
110
+ /** Panes herdr observed as working, bounded. */
111
+ working: HerdrDigestPane[];
112
+ groups: HerdrDigestGroup[];
113
+ shapes: HerdrDigestShape[];
114
+ truncated: boolean;
115
+ /** Caveats the reader must carry with the numbers. Never decorative. */
116
+ notes: string[];
117
+ };
118
+ export declare function herdrStatusRank(status: HerdrAgentStatus): number;
119
+ /**
120
+ * Describe a tab's arrangement from its pane rects.
121
+ *
122
+ * Panes sharing an x start a column; panes sharing a y start a row. A layout
123
+ * whose columns all hold the same number of panes is a grid and is named as
124
+ * one; anything else is named by its columns and their depths, which is both
125
+ * shorter and more truthful than inventing a name for an irregular split.
126
+ */
127
+ export declare function describeHerdrArrangement(layout: HerdrTabLayout | null, paneCount: number): string;
128
+ /**
129
+ * Build the digest for one herdr session topology.
130
+ *
131
+ * Pure: the same topology always digests to the same value, so the whole
132
+ * presentation is testable without a herdr server.
133
+ */
134
+ export declare function digestHerdrTopology(topology: HerdrSessionTopology, options?: {
135
+ paneCap?: number;
136
+ leadCap?: number;
137
+ }): HerdrWorkspaceDigest;
138
+ /**
139
+ * Render a digest as the text an operator should actually read.
140
+ *
141
+ * Ordering is the whole design: what is waiting, then what is moving, then
142
+ * where things live, then how the screen is arranged. A reader who stops after
143
+ * the first section has still got the answer that mattered.
144
+ *
145
+ * Section heads are verb phrases rather than bare nouns, and there is no
146
+ * trailing caveat block: a qualifier that changes how a count should be read
147
+ * sits directly under that count, because a scanning reader reaches the top of
148
+ * the output and not the bottom of it.
149
+ */
150
+ export declare function renderHerdrWorkspaceDigest(digest: HerdrWorkspaceDigest): string;
151
+ //# sourceMappingURL=herdr-workspace-digest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"herdr-workspace-digest.d.ts","sourceRoot":"","sources":["../src/herdr-workspace-digest.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,343 @@
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
+ /** Total emitted panes across every group before the digest declares truncation. */
31
+ export const HERDR_DIGEST_PANE_CAP = 40;
32
+ /** Ceiling on each of the two lead lists; attention is a shortlist, not an inventory. */
33
+ export const HERDR_DIGEST_LEAD_CAP = 12;
34
+ const EMPTY_COUNTS = { blocked: 0, working: 0, idle: 0, done: 0, unknown: 0 };
35
+ /** Attention order. Blocked outranks working because it is the only state that waits on a person. */
36
+ const STATUS_RANK = {
37
+ blocked: 5,
38
+ working: 4,
39
+ idle: 3,
40
+ done: 2,
41
+ unknown: 1,
42
+ };
43
+ export function herdrStatusRank(status) {
44
+ return STATUS_RANK[status];
45
+ }
46
+ function emptyCounts() {
47
+ return { ...EMPTY_COUNTS };
48
+ }
49
+ function totalCount(counts) {
50
+ return counts.blocked + counts.working + counts.idle + counts.done + counts.unknown;
51
+ }
52
+ function paneDirectory(pane) {
53
+ return pane.foregroundCwd ?? pane.cwd ?? null;
54
+ }
55
+ function directoryName(directory) {
56
+ if (!directory)
57
+ return "no directory";
58
+ const segments = directory.replace(/\/+$/, "").split("/").filter(Boolean);
59
+ return segments[segments.length - 1] ?? directory;
60
+ }
61
+ function toDigestPane(pane) {
62
+ return {
63
+ target: pane.terminalId ?? pane.paneId ?? null,
64
+ paneId: pane.paneId,
65
+ workspaceId: pane.workspaceId,
66
+ tabId: pane.tabId,
67
+ label: pane.label,
68
+ agent: pane.agent,
69
+ status: pane.agentStatus,
70
+ directory: paneDirectory(pane),
71
+ focused: pane.focused,
72
+ backlog: pane.scroll?.maxOffsetFromBottom ?? null,
73
+ };
74
+ }
75
+ /**
76
+ * Rank panes for a lead list: attention state, then the focused pane (where the
77
+ * operator's eyes already are), then the deepest backlog, then pane id so the
78
+ * order never wobbles between polls.
79
+ */
80
+ function compareLeadPanes(left, right) {
81
+ const byStatus = STATUS_RANK[right.status] - STATUS_RANK[left.status];
82
+ if (byStatus !== 0)
83
+ return byStatus;
84
+ if (left.focused !== right.focused)
85
+ return left.focused ? -1 : 1;
86
+ const byBacklog = (right.backlog ?? -1) - (left.backlog ?? -1);
87
+ if (byBacklog !== 0)
88
+ return byBacklog;
89
+ return left.paneId.localeCompare(right.paneId, undefined, { numeric: true });
90
+ }
91
+ /**
92
+ * Describe a tab's arrangement from its pane rects.
93
+ *
94
+ * Panes sharing an x start a column; panes sharing a y start a row. A layout
95
+ * whose columns all hold the same number of panes is a grid and is named as
96
+ * one; anything else is named by its columns and their depths, which is both
97
+ * shorter and more truthful than inventing a name for an irregular split.
98
+ */
99
+ export function describeHerdrArrangement(layout, paneCount) {
100
+ if (paneCount <= 0)
101
+ return "no panes";
102
+ if (paneCount === 1)
103
+ return "single pane";
104
+ const panes = layout?.panes ?? [];
105
+ if (panes.length !== paneCount)
106
+ return `${paneCount} panes (layout unavailable)`;
107
+ const columns = new Map();
108
+ const rows = new Map();
109
+ for (const pane of panes) {
110
+ columns.set(pane.rect.x, (columns.get(pane.rect.x) ?? 0) + 1);
111
+ rows.set(pane.rect.y, (rows.get(pane.rect.y) ?? 0) + 1);
112
+ }
113
+ if (columns.size === 1)
114
+ return `${paneCount} stacked rows`;
115
+ if (rows.size === 1)
116
+ return `${paneCount} side-by-side columns`;
117
+ const depths = [...columns.entries()]
118
+ .sort((left, right) => left[0] - right[0])
119
+ .map(([, count]) => count);
120
+ const first = depths[0] ?? 0;
121
+ if (depths.every((depth) => depth === first) && first * columns.size === paneCount) {
122
+ return `${first}×${columns.size} grid`;
123
+ }
124
+ return `${columns.size} columns (${depths.join(" · ")})`;
125
+ }
126
+ /**
127
+ * Build the digest for one herdr session topology.
128
+ *
129
+ * Pure: the same topology always digests to the same value, so the whole
130
+ * presentation is testable without a herdr server.
131
+ */
132
+ export function digestHerdrTopology(topology, options = {}) {
133
+ const paneCap = options.paneCap ?? HERDR_DIGEST_PANE_CAP;
134
+ const leadCap = options.leadCap ?? HERDR_DIGEST_LEAD_CAP;
135
+ const counts = emptyCounts();
136
+ const shapes = [];
137
+ const all = [];
138
+ let tabTotal = 0;
139
+ let agentTotal = 0;
140
+ for (const workspace of topology.workspaces) {
141
+ const workspaceLabel = workspace.label ?? (workspace.number != null ? `workspace ${workspace.number}` : workspace.workspaceId);
142
+ for (const tab of workspace.tabs) {
143
+ tabTotal += 1;
144
+ shapes.push({
145
+ workspaceId: workspace.workspaceId,
146
+ workspaceLabel,
147
+ tabId: tab.tabId,
148
+ tabLabel: tab.label ?? (tab.number != null ? `tab ${tab.number}` : tab.tabId),
149
+ paneCount: tab.panes.length,
150
+ arrangement: describeHerdrArrangement(tab.layout, tab.panes.length),
151
+ zoomed: tab.layout?.zoomed ?? false,
152
+ focused: workspace.focused && tab.focused,
153
+ });
154
+ for (const pane of tab.panes) {
155
+ const digestPane = toDigestPane(pane);
156
+ counts[digestPane.status] += 1;
157
+ if (digestPane.agent)
158
+ agentTotal += 1;
159
+ all.push(digestPane);
160
+ }
161
+ }
162
+ }
163
+ // Groups are filled in rank order so the pane cap spends its budget on the
164
+ // panes that matter, not on whichever directory herdr happened to list first.
165
+ const ranked = [...all].sort(compareLeadPanes);
166
+ const kept = ranked.slice(0, paneCap);
167
+ const droppedByDirectory = new Map();
168
+ for (const pane of ranked.slice(paneCap)) {
169
+ const key = pane.directory ?? "";
170
+ let dropped = droppedByDirectory.get(key);
171
+ if (!dropped) {
172
+ dropped = emptyCounts();
173
+ droppedByDirectory.set(key, dropped);
174
+ }
175
+ dropped[pane.status] += 1;
176
+ }
177
+ const groupsByDirectory = new Map();
178
+ for (const pane of all) {
179
+ const key = pane.directory ?? "";
180
+ let group = groupsByDirectory.get(key);
181
+ if (!group) {
182
+ group = {
183
+ directory: pane.directory,
184
+ name: directoryName(pane.directory),
185
+ counts: emptyCounts(),
186
+ panes: [],
187
+ omitted: totalCount(droppedByDirectory.get(key) ?? EMPTY_COUNTS),
188
+ omittedCounts: { ...(droppedByDirectory.get(key) ?? EMPTY_COUNTS) },
189
+ };
190
+ groupsByDirectory.set(key, group);
191
+ }
192
+ group.counts[pane.status] += 1;
193
+ }
194
+ const keptIds = new Set(kept.map((pane) => `${pane.workspaceId}/${pane.paneId}`));
195
+ for (const pane of all) {
196
+ if (!keptIds.has(`${pane.workspaceId}/${pane.paneId}`))
197
+ continue;
198
+ groupsByDirectory.get(pane.directory ?? "")?.panes.push(pane);
199
+ }
200
+ const groups = [...groupsByDirectory.values()];
201
+ for (const group of groups)
202
+ group.panes.sort(compareLeadPanes);
203
+ // Groups sort by what is waiting, then what is moving, then size — the same
204
+ // precedence the lead lists use, applied one level up.
205
+ groups.sort((left, right) => right.counts.blocked - left.counts.blocked
206
+ || right.counts.working - left.counts.working
207
+ || (right.panes.length + right.omitted) - (left.panes.length + left.omitted)
208
+ || left.name.localeCompare(right.name, undefined, { numeric: true }));
209
+ const notes = [];
210
+ if (!topology.running) {
211
+ // "Last known" is only useful with a when: a layout saved an hour ago and
212
+ // one saved in March are the same claim with very different weight.
213
+ const saved = topology.savedAt ? ` Last saved ${new Date(topology.savedAt).toISOString()}.` : "";
214
+ notes.push(topology.unavailable === "unreadable"
215
+ // The session IS running; herdr just would not answer. Saying "stopped"
216
+ // here would be the one thing a digest must never do — state, of live
217
+ // work, that it is not live.
218
+ ? `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}`
219
+ : `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}`);
220
+ }
221
+ if (counts.unknown > 0) {
222
+ notes.push(`${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.`);
223
+ }
224
+ return {
225
+ session: topology.session,
226
+ live: topology.running,
227
+ observedAt: topology.observedAt,
228
+ savedAt: topology.savedAt ?? null,
229
+ unavailable: topology.running ? null : topology.unavailable ?? "not_running",
230
+ totals: {
231
+ workspaces: topology.workspaces.length,
232
+ tabs: tabTotal,
233
+ panes: all.length,
234
+ agents: agentTotal,
235
+ },
236
+ counts,
237
+ needsYou: ranked.filter((pane) => pane.status === "blocked").slice(0, leadCap),
238
+ working: ranked.filter((pane) => pane.status === "working").slice(0, leadCap),
239
+ groups,
240
+ shapes,
241
+ truncated: all.length > paneCap,
242
+ notes,
243
+ };
244
+ }
245
+ /**
246
+ * One row, led by whatever is most specific to this pane.
247
+ *
248
+ * Rows that all begin `term-w1:p1`, `term-w1:p2` share a long prefix, and a
249
+ * scanning reader learns to skip the opening of every line — so the identity
250
+ * column becomes the one column nobody reads. The lead is therefore the pane's
251
+ * label when it has one, and its pane id when it does not, since a short id
252
+ * differs within the first couple of characters where a terminal id does not.
253
+ *
254
+ * The pane id is enough to act on: `herdr agent <verb>` accepts the pane
255
+ * currently hosting an agent as a target. The directory is the basename only —
256
+ * the full path is carried once, in the grouped section, and repeating it on
257
+ * every row buys nothing.
258
+ */
259
+ function paneLine(pane) {
260
+ const parts = pane.label
261
+ ? [pane.label, pane.agent ?? "shell", pane.paneId]
262
+ : [pane.paneId, pane.agent ?? "shell"];
263
+ if (pane.directory)
264
+ parts.push(directoryName(pane.directory));
265
+ if (pane.focused)
266
+ parts.push("focused");
267
+ return ` ${parts.join(" ")}`;
268
+ }
269
+ /** "2 idle not shown" — a collapsed label a reader can decide from. */
270
+ function omittedLabel(counts) {
271
+ const total = totalCount(counts);
272
+ if (total === 0)
273
+ return null;
274
+ const order = ["blocked", "working", "idle", "done", "unknown"];
275
+ const present = order.filter((status) => counts[status] > 0);
276
+ if (present.length === 1)
277
+ return `+${total} ${present[0]} not shown`;
278
+ return `+${total} not shown (${present.map((status) => `${counts[status]} ${status}`).join(" · ")})`;
279
+ }
280
+ function countsLine(counts) {
281
+ const order = ["blocked", "working", "idle", "done", "unknown"];
282
+ const parts = order.filter((status) => counts[status] > 0).map((status) => `${counts[status]} ${status}`);
283
+ return parts.length ? parts.join(" · ") : "no panes";
284
+ }
285
+ /**
286
+ * Render a digest as the text an operator should actually read.
287
+ *
288
+ * Ordering is the whole design: what is waiting, then what is moving, then
289
+ * where things live, then how the screen is arranged. A reader who stops after
290
+ * the first section has still got the answer that mattered.
291
+ *
292
+ * Section heads are verb phrases rather than bare nouns, and there is no
293
+ * trailing caveat block: a qualifier that changes how a count should be read
294
+ * sits directly under that count, because a scanning reader reaches the top of
295
+ * the output and not the bottom of it.
296
+ */
297
+ export function renderHerdrWorkspaceDigest(digest) {
298
+ const lines = [];
299
+ const state = digest.live
300
+ ? "live"
301
+ : digest.unavailable === "unreadable" ? "running, not readable" : "not running";
302
+ const { workspaces, tabs, panes } = digest.totals;
303
+ lines.push(`herdr · ${digest.session} — ${state} · ${workspaces} workspace${workspaces === 1 ? "" : "s"}, ${tabs} tab${tabs === 1 ? "" : "s"}, ${panes} pane${panes === 1 ? "" : "s"} (${countsLine(digest.counts)})`);
304
+ // Caveats sit under the counts they qualify, not in a block at the bottom.
305
+ // A qualifier that changes how a number should be read is body text; a
306
+ // scanning reader never reaches a footnote, and these are the lines that stop
307
+ // someone believing `unknown` meant done or that a stale layout was current.
308
+ for (const note of digest.notes)
309
+ lines.push(` ${note}`);
310
+ if (digest.needsYou.length) {
311
+ lines.push("", "Waiting on you");
312
+ for (const pane of digest.needsYou)
313
+ lines.push(paneLine(pane));
314
+ }
315
+ if (digest.working.length) {
316
+ lines.push("", "Working now");
317
+ for (const pane of digest.working)
318
+ lines.push(paneLine(pane));
319
+ }
320
+ if (digest.groups.length) {
321
+ lines.push("", "Grouped by directory");
322
+ for (const group of digest.groups) {
323
+ const total = totalCount(group.counts);
324
+ const where = group.directory ? ` (${group.directory})` : "";
325
+ const hidden = omittedLabel(group.omittedCounts);
326
+ lines.push(` ${group.name}${where} — ${total} pane${total === 1 ? "" : "s"} · ${countsLine(group.counts)}${hidden ? ` · ${hidden}` : ""}`);
327
+ }
328
+ }
329
+ if (digest.shapes.length) {
330
+ lines.push("", "Arranged like this");
331
+ for (const shape of digest.shapes) {
332
+ const marks = [shape.arrangement];
333
+ if (shape.zoomed)
334
+ marks.push("zoomed");
335
+ if (shape.focused)
336
+ marks.push("focused");
337
+ // The tab id leads because two workspaces can carry the same label, and
338
+ // a label can itself contain the separator — the id cannot do either.
339
+ lines.push(` ${shape.tabId} ${shape.workspaceLabel} / ${shape.tabLabel} — ${marks.join(" · ")}`);
340
+ }
341
+ }
342
+ return lines.join("\n");
343
+ }
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ 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";
@@ -53,6 +54,7 @@ export * from "./terminal-surface-id.js";
53
54
  export * from "./terminal-sessions.js";
54
55
  export * from "./terminal-workspaces.js";
55
56
  export * from "./terminal-host-topology.js";
57
+ export * from "./herdr-workspace-digest.js";
56
58
  export * from "./broker-routes.js";
57
59
  export * from "./rendezvous.js";
58
60
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,UAAU,CAAC;AACzB,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,WAAW,CAAC;AAC1B,cAAc,oBAAoB,CAAC;AACnC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,uBAAuB,CAAC;AACtC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,cAAc,iCAAiC,CAAC;AAChD,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,wBAAwB,CAAC;AACvC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,8BAA8B,CAAC;AAC7C,cAAc,wBAAwB,CAAC;AACvC,cAAc,0BAA0B,CAAC;AACzC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,qBAAqB,CAAC;AACpC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,uBAAuB,CAAC;AACtC,cAAc,wBAAwB,CAAC;AACvC,cAAc,sBAAsB,CAAC;AACrC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,oBAAoB,CAAC;AACnC,cAAc,0BAA0B,CAAC;AACzC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,oBAAoB,CAAC;AACnC,cAAc,wBAAwB,CAAC;AACvC,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,wBAAwB,CAAC;AACvC,cAAc,0BAA0B,CAAC;AACzC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,oBAAoB,CAAC;AACnC,cAAc,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,UAAU,CAAC;AACzB,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,uBAAuB,CAAC;AACtC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,cAAc,iCAAiC,CAAC;AAChD,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,wBAAwB,CAAC;AACvC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,8BAA8B,CAAC;AAC7C,cAAc,wBAAwB,CAAC;AACvC,cAAc,0BAA0B,CAAC;AACzC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,qBAAqB,CAAC;AACpC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,uBAAuB,CAAC;AACtC,cAAc,wBAAwB,CAAC;AACvC,cAAc,sBAAsB,CAAC;AACrC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,oBAAoB,CAAC;AACnC,cAAc,0BAA0B,CAAC;AACzC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,oBAAoB,CAAC;AACnC,cAAc,wBAAwB,CAAC;AACvC,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,wBAAwB,CAAC;AACvC,cAAc,0BAA0B,CAAC;AACzC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,oBAAoB,CAAC;AACnC,cAAc,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ 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";
@@ -53,5 +54,6 @@ export * from "./terminal-surface-id.js";
53
54
  export * from "./terminal-sessions.js";
54
55
  export * from "./terminal-workspaces.js";
55
56
  export * from "./terminal-host-topology.js";
57
+ export * from "./herdr-workspace-digest.js";
56
58
  export * from "./broker-routes.js";
57
59
  export * from "./rendezvous.js";