@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,166 @@
1
+ /**
2
+ * Channel invitations: a portable, scoped, revocable capability that lets a
3
+ * teammate bring an agent session they are *already running* into a Scout
4
+ * channel.
5
+ *
6
+ * Three boundaries shape this module.
7
+ *
8
+ * 1. **The secret never lands in a record.** Only `tokenHash` is persisted, so
9
+ * a snapshot, a journal line, or a log dump cannot be replayed into channel
10
+ * membership. The raw token exists in the invitation URL and nowhere else.
11
+ * 2. **Membership is not reception.** Redeeming an invitation makes an actor a
12
+ * channel participant. Whether that actor can actually *receive* is a
13
+ * separate, live judgement made from endpoint evidence — see
14
+ * {@link deriveChannelReception}.
15
+ * 3. **Reachability is stated, not assumed.** A loopback route is recorded as
16
+ * `local_only` so a surface can say plainly that the link will not work for
17
+ * a teammate on another machine.
18
+ *
19
+ * Invites live in the owning conversation's metadata under
20
+ * {@link CHANNEL_INVITES_METADATA_KEY}; the broker remains the only writer.
21
+ */
22
+ export const CHANNEL_INVITES_METADATA_KEY = "channelInvites";
23
+ export const CHANNEL_INVITE_SCOPE = "channel_participation";
24
+ export function channelInviteState(invite, nowMs) {
25
+ if (invite.revokedAt !== undefined)
26
+ return "revoked";
27
+ if (invite.expiresAt !== null && invite.expiresAt <= nowMs)
28
+ return "expired";
29
+ if (invite.maxRedemptions !== null
30
+ && invite.redemptions.length >= invite.maxRedemptions) {
31
+ return "exhausted";
32
+ }
33
+ return "active";
34
+ }
35
+ /**
36
+ * Decide whether one redemption attempt may proceed.
37
+ *
38
+ * Retries are first-class: an identity that already redeemed this invitation
39
+ * is accepted again with its original redemption, even once the invitation is
40
+ * exhausted. Re-running an agent's join command must never mint a second
41
+ * identity, burn a second slot, or replace a live session.
42
+ */
43
+ export function evaluateChannelInviteRedemption(input) {
44
+ const { invite, request, nowMs } = input;
45
+ const actorId = request.actorId?.trim();
46
+ if (!actorId) {
47
+ return {
48
+ ok: false,
49
+ rejection: {
50
+ reason: "missing_identity",
51
+ message: "Redemption needs the actor id of the agent session that is joining.",
52
+ },
53
+ };
54
+ }
55
+ if (request.channelId?.trim() && request.channelId.trim() !== invite.channelId) {
56
+ return {
57
+ ok: false,
58
+ rejection: {
59
+ reason: "channel_mismatch",
60
+ message: "This invitation does not belong to the requested channel.",
61
+ },
62
+ };
63
+ }
64
+ if (request.scope?.trim() && request.scope.trim() !== invite.scope) {
65
+ return {
66
+ ok: false,
67
+ rejection: {
68
+ reason: "scope_mismatch",
69
+ message: `This invitation grants ${invite.scope} only.`,
70
+ },
71
+ };
72
+ }
73
+ const existing = findChannelInviteRedemption(invite, request);
74
+ if (existing) {
75
+ // Revocation still bites: a revoked invitation stops being a usable
76
+ // capability even for an identity that once held it.
77
+ if (invite.revokedAt !== undefined) {
78
+ return { ok: false, rejection: channelInviteRejection("revoked") };
79
+ }
80
+ return { ok: true, existing };
81
+ }
82
+ const state = channelInviteState(invite, nowMs);
83
+ if (state !== "active") {
84
+ return { ok: false, rejection: channelInviteRejection(state) };
85
+ }
86
+ return { ok: true, existing: null };
87
+ }
88
+ /**
89
+ * The redemption already recorded for this identity, if any.
90
+ *
91
+ * **Session identity is decisive.** When a request carries a session id, only a
92
+ * redemption for that exact session counts as the same joiner. A durable agent
93
+ * running two concrete sessions is two distinct attachments: this feature
94
+ * exists to attach *the exact session* a teammate is already working in, so
95
+ * letting a second session inherit the first one's membership through a shared
96
+ * agent id would hand it the wrong context and silently skip a use of the
97
+ * invitation.
98
+ *
99
+ * Only when no session is carried does the durable actor or agent id decide.
100
+ */
101
+ export function findChannelInviteRedemption(invite, request) {
102
+ const sessionId = request.sessionId?.trim();
103
+ if (sessionId) {
104
+ return invite.redemptions.find((redemption) => redemption.sessionId?.trim() === sessionId) ?? null;
105
+ }
106
+ const actorId = request.actorId?.trim();
107
+ const agentId = request.agentId?.trim();
108
+ return invite.redemptions.find((redemption) => {
109
+ if (actorId && redemption.actorId === actorId)
110
+ return true;
111
+ return Boolean(agentId) && redemption.agentId?.trim() === agentId;
112
+ }) ?? null;
113
+ }
114
+ function channelInviteRejection(state) {
115
+ switch (state) {
116
+ case "expired":
117
+ return { reason: "expired", message: "This invitation has expired. Ask for a new one." };
118
+ case "revoked":
119
+ return { reason: "revoked", message: "This invitation was revoked." };
120
+ case "exhausted":
121
+ return {
122
+ reason: "exhausted",
123
+ message: "This invitation has already been used the maximum number of times.",
124
+ };
125
+ }
126
+ }
127
+ export function unknownChannelInviteRejection() {
128
+ // Deliberately identical for a malformed, unknown, and deleted token: a
129
+ // probe must not learn which invitations exist.
130
+ return { reason: "unknown_token", message: "This invitation link is not valid." };
131
+ }
132
+ /** Read the invitations stored on a conversation record. */
133
+ export function readChannelInvites(metadata) {
134
+ const raw = metadata?.[CHANNEL_INVITES_METADATA_KEY];
135
+ if (!Array.isArray(raw))
136
+ return [];
137
+ return raw.filter((entry) => Boolean(entry)
138
+ && typeof entry === "object"
139
+ && typeof entry.id === "string"
140
+ && typeof entry.tokenHash === "string"
141
+ && Array.isArray(entry.redemptions));
142
+ }
143
+ /** Match a presented token against stored invitations by its digest. */
144
+ export function findChannelInviteByTokenHash(invites, tokenHash) {
145
+ const normalized = tokenHash.trim().toLowerCase();
146
+ if (!normalized)
147
+ return null;
148
+ return invites.find((invite) => invite.tokenHash.toLowerCase() === normalized) ?? null;
149
+ }
150
+ export function channelInvitePublicView(invite, nowMs) {
151
+ return {
152
+ id: invite.id,
153
+ channelId: invite.channelId,
154
+ createdByActorId: invite.createdByActorId,
155
+ scope: invite.scope,
156
+ state: channelInviteState(invite, nowMs),
157
+ tokenHint: invite.tokenHint,
158
+ createdAt: invite.createdAt,
159
+ expiresAt: invite.expiresAt,
160
+ maxRedemptions: invite.maxRedemptions,
161
+ redemptionCount: invite.redemptions.length,
162
+ ...(invite.invitee ? { invitee: invite.invitee } : {}),
163
+ route: invite.route,
164
+ redemptions: invite.redemptions,
165
+ };
166
+ }
@@ -0,0 +1,78 @@
1
+ import type { AgentState, DeliveryTransport } from "./common.js";
2
+ /**
3
+ * Honest reception state for a channel member.
4
+ *
5
+ * Channel membership is a durable fact. Whether a member can actually receive
6
+ * a message right now is a live judgement, and the two must never be conflated
7
+ * — a roster that renders every member as "listening" because a row exists is
8
+ * the specific failure this module prevents.
9
+ *
10
+ * Two independent axes carry the truth:
11
+ *
12
+ * - {@link ChannelReceptionRouteKind} — what the attached transport *can* do.
13
+ * A `claude_resume` or `codex_exec` endpoint has no persistent listener at
14
+ * all: a delivery wakes a fresh run. That is a legitimate route, but calling
15
+ * it "listening" would be a lie.
16
+ * - {@link ChannelReceptionState} — what the route is doing at this instant,
17
+ * read from endpoint state and heartbeat freshness.
18
+ */
19
+ export type ChannelReceptionState =
20
+ /** A member with no agent session attached yet — the invite is unredeemed. */
21
+ "waiting_for_agent"
22
+ /** An attached session that is still warming up or waking. */
23
+ | "connecting"
24
+ /** A live route that will carry a message now. */
25
+ | "ready_to_receive"
26
+ /** Attached, but no usable route exists — explain why, never imply delivery. */
27
+ | "unavailable"
28
+ /** A route that existed and has since gone quiet or been stopped. */
29
+ | "disconnected";
30
+ export type ChannelReceptionRouteKind =
31
+ /** The agent holds an open channel and sees a post without being started. */
32
+ "persistent"
33
+ /**
34
+ * Delivery reaches the invited session by resuming or steering it. Context is
35
+ * preserved and the session is never replaced, but nothing is watching
36
+ * between messages.
37
+ */
38
+ | "wake_on_delivery"
39
+ /** No route at all. */
40
+ | "none";
41
+ export declare function channelReceptionRouteKind(transport: DeliveryTransport | null | undefined): ChannelReceptionRouteKind;
42
+ /** How long an idle persistent route may stay silent before we stop vouching for it. */
43
+ export declare const CHANNEL_RECEPTION_STALE_AFTER_MS: number;
44
+ export interface ChannelReceptionEndpointEvidence {
45
+ state: AgentState;
46
+ transport: DeliveryTransport;
47
+ sessionId?: string | null;
48
+ lastSeenAt?: number | null;
49
+ }
50
+ export interface ChannelReceptionEvidence {
51
+ /** The member's attached harness session, when the invite was redeemed. */
52
+ attachedSessionId?: string | null;
53
+ endpoint?: ChannelReceptionEndpointEvidence | null;
54
+ nowMs: number;
55
+ staleAfterMs?: number;
56
+ }
57
+ export interface ChannelReception {
58
+ state: ChannelReceptionState;
59
+ routeKind: ChannelReceptionRouteKind;
60
+ /** True only when a process is attached and watching right now. */
61
+ listening: boolean;
62
+ /** Badge-length label. */
63
+ summary: string;
64
+ /** One sentence a person can act on. Always populated. */
65
+ detail: string;
66
+ /** When the evidence behind this reading was last observed. */
67
+ evidenceAt: number | null;
68
+ }
69
+ /**
70
+ * Derive a member's reception state from live evidence only.
71
+ *
72
+ * Every branch returns a `detail` sentence, including the happy path: a
73
+ * surface should never have to invent an explanation, and a `wake_on_delivery`
74
+ * route always says so out loud.
75
+ */
76
+ export declare function deriveChannelReception(evidence: ChannelReceptionEvidence): ChannelReception;
77
+ export declare function channelReceptionStateLabel(state: ChannelReceptionState): string;
78
+ //# sourceMappingURL=channel-reception.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel-reception.d.ts","sourceRoot":"","sources":["../src/channel-reception.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAEjE;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,MAAM,qBAAqB;AAC/B,8EAA8E;AAC5E,mBAAmB;AACrB,8DAA8D;GAC5D,YAAY;AACd,kDAAkD;GAChD,kBAAkB;AACpB,gFAAgF;GAC9E,aAAa;AACf,qEAAqE;GACnE,cAAc,CAAC;AAEnB,MAAM,MAAM,yBAAyB;AACnC,6EAA6E;AAC3E,YAAY;AACd;;;;GAIG;GACD,kBAAkB;AACpB,uBAAuB;GACrB,MAAM,CAAC;AAwBX,wBAAgB,yBAAyB,CACvC,SAAS,EAAE,iBAAiB,GAAG,IAAI,GAAG,SAAS,GAC9C,yBAAyB,CAG3B;AAED,wFAAwF;AACxF,eAAO,MAAM,gCAAgC,QAAiB,CAAC;AAE/D,MAAM,WAAW,gCAAgC;IAC/C,KAAK,EAAE,UAAU,CAAC;IAClB,SAAS,EAAE,iBAAiB,CAAC;IAC7B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,WAAW,wBAAwB;IACvC,2EAA2E;IAC3E,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,QAAQ,CAAC,EAAE,gCAAgC,GAAG,IAAI,CAAC;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,qBAAqB,CAAC;IAC7B,SAAS,EAAE,yBAAyB,CAAC;IACrC,mEAAmE;IACnE,SAAS,EAAE,OAAO,CAAC;IACnB,0BAA0B;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,MAAM,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,wBAAwB,GACjC,gBAAgB,CA8KlB;AAYD,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,qBAAqB,GAAG,MAAM,CAQ/E"}
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Transports where a process is actually attached and watching. Everything
3
+ * else either spawns per delivery or injects into a surface with no listener
4
+ * contract, and is reported as `wake_on_delivery`.
5
+ */
6
+ const PERSISTENT_RECEIVE_TRANSPORTS = new Set([
7
+ "local_socket",
8
+ "websocket",
9
+ "pairing_bridge",
10
+ "peer_broker",
11
+ "claude_channel",
12
+ "claude_stream_json",
13
+ "codex_app_server",
14
+ "pi_rpc",
15
+ "grok_acp",
16
+ "kimi_acp",
17
+ "cursor_acp",
18
+ "opencode_acp",
19
+ "devin_acp",
20
+ "cursor_sdk_local",
21
+ ]);
22
+ export function channelReceptionRouteKind(transport) {
23
+ if (!transport)
24
+ return "none";
25
+ return PERSISTENT_RECEIVE_TRANSPORTS.has(transport) ? "persistent" : "wake_on_delivery";
26
+ }
27
+ /** How long an idle persistent route may stay silent before we stop vouching for it. */
28
+ export const CHANNEL_RECEPTION_STALE_AFTER_MS = 10 * 60 * 1000;
29
+ /**
30
+ * Derive a member's reception state from live evidence only.
31
+ *
32
+ * Every branch returns a `detail` sentence, including the happy path: a
33
+ * surface should never have to invent an explanation, and a `wake_on_delivery`
34
+ * route always says so out loud.
35
+ */
36
+ export function deriveChannelReception(evidence) {
37
+ const endpoint = evidence.endpoint ?? null;
38
+ const attachedSessionId = evidence.attachedSessionId?.trim() || null;
39
+ const routeKind = channelReceptionRouteKind(endpoint?.transport);
40
+ const evidenceAt = endpoint?.lastSeenAt ?? null;
41
+ if (!endpoint) {
42
+ return attachedSessionId
43
+ ? {
44
+ state: "unavailable",
45
+ routeKind: "none",
46
+ listening: false,
47
+ summary: "No route",
48
+ detail: `Session ${attachedSessionId} is attached to this channel but the broker has no registered endpoint for it, so messages cannot be delivered.`,
49
+ evidenceAt,
50
+ }
51
+ : {
52
+ state: "waiting_for_agent",
53
+ routeKind: "none",
54
+ listening: false,
55
+ summary: "Waiting for agent",
56
+ detail: "This member has joined the channel but no agent session has redeemed the invitation yet.",
57
+ evidenceAt,
58
+ };
59
+ }
60
+ const base = { routeKind, evidenceAt };
61
+ // An endpoint is only evidence about *this* member when the channel knows
62
+ // which session it attached. Without that link there is a route on the
63
+ // machine but nothing tying it to this membership, so it cannot be read as
64
+ // this member being reachable.
65
+ if (!attachedSessionId) {
66
+ return {
67
+ ...base,
68
+ state: "waiting_for_agent",
69
+ listening: false,
70
+ summary: "Waiting for agent",
71
+ detail: `This member has not redeemed an invitation from a session yet. A ${endpoint.transport} route exists elsewhere for them, but nothing attaches it to this channel, so a message here has no session to arrive in.`,
72
+ };
73
+ }
74
+ // The invitation attached one concrete session. A live endpoint belonging to
75
+ // a different session is somebody else's evidence: reporting it as this
76
+ // member's readiness is exactly the false listening claim to avoid.
77
+ const endpointSessionId = endpoint.sessionId?.trim() || null;
78
+ if (endpointSessionId && endpointSessionId !== attachedSessionId) {
79
+ return {
80
+ ...base,
81
+ state: "disconnected",
82
+ listening: false,
83
+ summary: "Session replaced",
84
+ detail: `This channel attached session ${attachedSessionId}, but the live ${endpoint.transport} route now belongs to ${endpointSessionId}. Re-redeem the invitation from the session you want in the room.`,
85
+ };
86
+ }
87
+ switch (endpoint.state) {
88
+ case "attaching":
89
+ case "waking":
90
+ return {
91
+ ...base,
92
+ state: "connecting",
93
+ listening: false,
94
+ summary: "Connecting",
95
+ detail: `The ${endpoint.transport} route is ${endpoint.state}; messages sent now will be delivered once it settles.`,
96
+ };
97
+ case "registered":
98
+ // `registered` means the broker knows how to reach this endpoint, not
99
+ // that a session is attached and watching. Only the live states below
100
+ // are evidence of an active receive route.
101
+ return {
102
+ ...base,
103
+ state: "connecting",
104
+ listening: false,
105
+ summary: "Registered",
106
+ detail: `Session ${attachedSessionId} is registered over ${endpoint.transport} but has not been observed attached and watching, so it is not yet an active receive route.`,
107
+ };
108
+ case "offline":
109
+ case "stopped":
110
+ return {
111
+ ...base,
112
+ state: "disconnected",
113
+ listening: false,
114
+ summary: "Disconnected",
115
+ detail: `The attached session's ${endpoint.transport} route is ${endpoint.state}. Restart or re-attach the session to receive messages.`,
116
+ };
117
+ case "superseded":
118
+ return {
119
+ ...base,
120
+ state: "disconnected",
121
+ listening: false,
122
+ summary: "Superseded",
123
+ detail: "A newer route replaced this one. The member's membership stands, but this endpoint no longer receives.",
124
+ };
125
+ case "unreachable":
126
+ case "failed":
127
+ return {
128
+ ...base,
129
+ state: "unavailable",
130
+ listening: false,
131
+ summary: endpoint.state === "failed" ? "Failed" : "Unreachable",
132
+ detail: `The broker last saw this ${endpoint.transport} route as ${endpoint.state}; it cannot currently accept a delivery.`,
133
+ };
134
+ default:
135
+ break;
136
+ }
137
+ // Past this point every branch makes a positive claim about the attached
138
+ // session. An endpoint that does not say which session it serves cannot
139
+ // support one: the route may well be fine, but "session X is receiving" is a
140
+ // claim about a specific session and this is not evidence for it. The
141
+ // negative states above are unaffected -- a dead route is dead either way.
142
+ if (!endpointSessionId) {
143
+ return {
144
+ ...base,
145
+ state: "connecting",
146
+ listening: false,
147
+ summary: "Unconfirmed",
148
+ detail: `A ${endpoint.transport} route is registered for this member, but it does not identify which session it serves, so Scout cannot confirm that session ${attachedSessionId} is the one receiving.`,
149
+ };
150
+ }
151
+ if (routeKind === "wake_on_delivery") {
152
+ return {
153
+ ...base,
154
+ state: "ready_to_receive",
155
+ listening: false,
156
+ summary: "Wakes on delivery",
157
+ // Say what actually happens. These transports resume or steer the exact
158
+ // invited session, so context survives -- claiming a message "starts a
159
+ // new run" would misdescribe them. What they lack is a listener between
160
+ // messages, and that is the only claim being withheld here.
161
+ detail: `Messages reach this member by ${endpoint.transport === "tmux" ? "steering" : "resuming"} session ${attachedSessionId} over ${endpoint.transport}. That session keeps its context and is never replaced, but nothing is watching the channel between messages.`,
162
+ };
163
+ }
164
+ const staleAfterMs = evidence.staleAfterMs ?? CHANNEL_RECEPTION_STALE_AFTER_MS;
165
+ const lastSeenAt = endpoint.lastSeenAt ?? null;
166
+ // Registration is not observation. A persistent route claims a process is
167
+ // watching right now; with no timestamp the broker has never seen it do so,
168
+ // and an unobserved row is not grounds for telling a person their agent is
169
+ // listening. A wake-on-delivery route makes no such claim, so its
170
+ // registration alone remains sufficient.
171
+ if (lastSeenAt === null) {
172
+ return {
173
+ ...base,
174
+ state: "connecting",
175
+ listening: false,
176
+ summary: "Unconfirmed",
177
+ detail: `The ${endpoint.transport} route is registered for this session but has never been observed live, so Scout cannot yet confirm it is listening.`,
178
+ };
179
+ }
180
+ if (evidence.nowMs - lastSeenAt > staleAfterMs) {
181
+ return {
182
+ ...base,
183
+ state: "disconnected",
184
+ listening: false,
185
+ summary: "Silent",
186
+ detail: `The ${endpoint.transport} route is registered but has not been seen for ${formatDuration(evidence.nowMs - lastSeenAt)}, so it is no longer treated as listening.`,
187
+ evidenceAt: lastSeenAt,
188
+ };
189
+ }
190
+ return {
191
+ ...base,
192
+ state: "ready_to_receive",
193
+ listening: true,
194
+ summary: "Ready to receive",
195
+ detail: `An attached ${endpoint.transport} session is watching this channel and will see new messages without being started.`,
196
+ evidenceAt: lastSeenAt,
197
+ };
198
+ }
199
+ function formatDuration(ms) {
200
+ const minutes = Math.floor(ms / 60_000);
201
+ if (minutes < 1)
202
+ return "under a minute";
203
+ if (minutes < 60)
204
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
205
+ const hours = Math.floor(minutes / 60);
206
+ if (hours < 24)
207
+ return `${hours} hour${hours === 1 ? "" : "s"}`;
208
+ const days = Math.floor(hours / 24);
209
+ return `${days} day${days === 1 ? "" : "s"}`;
210
+ }
211
+ export function channelReceptionStateLabel(state) {
212
+ switch (state) {
213
+ case "waiting_for_agent": return "Waiting for agent";
214
+ case "connecting": return "Connecting";
215
+ case "ready_to_receive": return "Ready to receive";
216
+ case "unavailable": return "Unavailable";
217
+ case "disconnected": return "Disconnected";
218
+ }
219
+ }
package/dist/common.d.ts CHANGED
@@ -6,7 +6,7 @@ export type DeliveryStatus = "accepted" | "peer_acked" | "running" | "completed"
6
6
  export type DeliveryFailureReason = "peer_unreachable" | "peer_rejected" | "agent_offline" | "timeout" | "harness_error" | "dispatch" | "cancelled";
7
7
  export type DeliveryPolicy = "best_effort" | "must_ack" | "durable" | "ephemeral";
8
8
  export type DeliveryTargetKind = "participant" | "agent" | "bridge" | "device" | "voice_session" | "webhook";
9
- export type DeliveryTransport = "local_socket" | "websocket" | "pairing_bridge" | "peer_broker" | "http" | "webhook" | "telegram" | "discord" | "sms" | "email" | "tts" | "native_voice" | "claude_channel" | "claude_stream_json" | "codex_app_server" | "codex_exec" | "claude_resume" | "pi_rpc" | "grok_acp" | "kimi_acp" | "cursor_acp" | "opencode_acp" | "tmux" | "cursor_exec" | "cursor_cli_text" | "cursor_cli_stream_json" | "cursor_sdk_local";
9
+ export type DeliveryTransport = "local_socket" | "websocket" | "pairing_bridge" | "peer_broker" | "http" | "webhook" | "telegram" | "discord" | "sms" | "email" | "tts" | "native_voice" | "claude_channel" | "claude_stream_json" | "codex_app_server" | "codex_exec" | "claude_resume" | "pi_rpc" | "grok_acp" | "kimi_acp" | "cursor_acp" | "opencode_acp" | "devin_acp" | "tmux" | "cursor_exec" | "cursor_cli_text" | "cursor_cli_stream_json" | "cursor_sdk_local";
10
10
  export type DeliveryReason = "conversation_visibility" | "direct_message" | "mention" | "thread_reply" | "invocation" | "bridge_outbound" | "speech";
11
11
  export type AdvertiseScope = "local" | "mesh";
12
12
  export type ShareMode = "local" | "summary" | "shared";
@@ -1 +1 @@
1
- {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC;AAE7B,MAAM,MAAM,SAAS,GACjB,QAAQ,GACR,QAAQ,GACR,OAAO,GACP,SAAS,GACT,QAAQ,GACR,QAAQ,GACR,QAAQ,CAAC;AAEb,MAAM,MAAM,UAAU,GAClB,YAAY,GACZ,WAAW,GACX,QAAQ,GACR,SAAS,GACT,MAAM,GACN,QAAQ,GACR,SAAS,GACT,SAAS,GACT,aAAa,GACb,QAAQ,GACR,YAAY,GACZ,SAAS,CAAC;AAEd,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,WAAW,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE5E,MAAM,MAAM,cAAc,GAEtB,UAAU,GACV,YAAY,GACZ,SAAS,GACT,WAAW,GACX,UAAU,GACV,QAAQ,GACR,WAAW,GAEX,SAAS,GACT,QAAQ,GACR,MAAM,GACN,cAAc,CAAC;AAEnB,MAAM,MAAM,qBAAqB,GAC7B,kBAAkB,GAClB,eAAe,GACf,eAAe,GACf,SAAS,GACT,eAAe,GACf,UAAU,GACV,WAAW,CAAC;AAEhB,MAAM,MAAM,cAAc,GAAG,aAAa,GAAG,UAAU,GAAG,SAAS,GAAG,WAAW,CAAC;AAElF,MAAM,MAAM,kBAAkB,GAC1B,aAAa,GACb,OAAO,GACP,QAAQ,GACR,QAAQ,GACR,eAAe,GACf,SAAS,CAAC;AAEd,MAAM,MAAM,iBAAiB,GACzB,cAAc,GACd,WAAW,GACX,gBAAgB,GAChB,aAAa,GACb,MAAM,GACN,SAAS,GACT,UAAU,GACV,SAAS,GACT,KAAK,GACL,OAAO,GACP,KAAK,GACL,cAAc,GACd,gBAAgB,GAChB,oBAAoB,GACpB,kBAAkB,GAClB,YAAY,GACZ,eAAe,GACf,QAAQ,GACR,UAAU,GACV,UAAU,GACV,YAAY,GACZ,cAAc,GACd,MAAM,GACN,aAAa,GACb,iBAAiB,GACjB,wBAAwB,GACxB,kBAAkB,CAAC;AAEvB,MAAM,MAAM,cAAc,GACtB,yBAAyB,GACzB,gBAAgB,GAChB,SAAS,GACT,cAAc,GACd,YAAY,GACZ,iBAAiB,GACjB,QAAQ,CAAC;AAEb,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,MAAM,CAAC;AAE9C,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEvD,MAAM,WAAW,WAAW;IAC1B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB"}
1
+ {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC;AAE7B,MAAM,MAAM,SAAS,GACjB,QAAQ,GACR,QAAQ,GACR,OAAO,GACP,SAAS,GACT,QAAQ,GACR,QAAQ,GACR,QAAQ,CAAC;AAEb,MAAM,MAAM,UAAU,GAClB,YAAY,GACZ,WAAW,GACX,QAAQ,GACR,SAAS,GACT,MAAM,GACN,QAAQ,GACR,SAAS,GACT,SAAS,GACT,aAAa,GACb,QAAQ,GACR,YAAY,GACZ,SAAS,CAAC;AAEd,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,WAAW,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE5E,MAAM,MAAM,cAAc,GAEtB,UAAU,GACV,YAAY,GACZ,SAAS,GACT,WAAW,GACX,UAAU,GACV,QAAQ,GACR,WAAW,GAEX,SAAS,GACT,QAAQ,GACR,MAAM,GACN,cAAc,CAAC;AAEnB,MAAM,MAAM,qBAAqB,GAC7B,kBAAkB,GAClB,eAAe,GACf,eAAe,GACf,SAAS,GACT,eAAe,GACf,UAAU,GACV,WAAW,CAAC;AAEhB,MAAM,MAAM,cAAc,GAAG,aAAa,GAAG,UAAU,GAAG,SAAS,GAAG,WAAW,CAAC;AAElF,MAAM,MAAM,kBAAkB,GAC1B,aAAa,GACb,OAAO,GACP,QAAQ,GACR,QAAQ,GACR,eAAe,GACf,SAAS,CAAC;AAEd,MAAM,MAAM,iBAAiB,GACzB,cAAc,GACd,WAAW,GACX,gBAAgB,GAChB,aAAa,GACb,MAAM,GACN,SAAS,GACT,UAAU,GACV,SAAS,GACT,KAAK,GACL,OAAO,GACP,KAAK,GACL,cAAc,GACd,gBAAgB,GAChB,oBAAoB,GACpB,kBAAkB,GAClB,YAAY,GACZ,eAAe,GACf,QAAQ,GACR,UAAU,GACV,UAAU,GACV,YAAY,GACZ,cAAc,GACd,WAAW,GACX,MAAM,GACN,aAAa,GACb,iBAAiB,GACjB,wBAAwB,GACxB,kBAAkB,CAAC;AAEvB,MAAM,MAAM,cAAc,GACtB,yBAAyB,GACzB,gBAAgB,GAChB,SAAS,GACT,cAAc,GACd,YAAY,GACZ,iBAAiB,GACjB,QAAQ,CAAC;AAEb,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,MAAM,CAAC;AAE9C,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEvD,MAAM,WAAW,WAAW;IAC1B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB"}
@@ -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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EACV,gBAAgB,EAEhB,oBAAoB,EACpB,cAAc,EACd,8BAA8B,EAC/B,MAAM,6BAA6B,CAAC;AAErC,oFAAoF;AACpF,eAAO,MAAM,qBAAqB,KAAK,CAAC;AACxC,yFAAyF;AACzF,eAAO,MAAM,qBAAqB,KAAK,CAAC;AAExC,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC,gBAAgB,EAAE,MAAM,CAAC,CAAC;AAEjE,MAAM,MAAM,eAAe,GAAG;IAC5B;;;;OAIG;IACH,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,2EAA2E;IAC3E,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,gBAAgB,CAAC;IACzB,+EAA+E;IAC/E,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB;;;;OAIG;IACH,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,+EAA+E;IAC/E,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,iBAAiB,CAAC;IAC1B,KAAK,EAAE,eAAe,EAAE,CAAC;IACzB,mDAAmD;IACnD,OAAO,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,aAAa,EAAE,iBAAiB,CAAC;CAClC,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,mEAAmE;IACnE,IAAI,EAAE,OAAO,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,kFAAkF;IAClF,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB;;;;OAIG;IACH,WAAW,EAAE,8BAA8B,GAAG,IAAI,CAAC;IACnD,MAAM,EAAE;QACN,UAAU,EAAE,MAAM,CAAC;QACnB,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,qEAAqE;QACrE,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,MAAM,EAAE,iBAAiB,CAAC;IAC1B,qFAAqF;IACrF,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,gDAAgD;IAChD,OAAO,EAAE,eAAe,EAAE,CAAC;IAC3B,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAC3B,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAC3B,SAAS,EAAE,OAAO,CAAC;IACnB,wEAAwE;IACxE,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB,CAAC;AAaF,wBAAgB,eAAe,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CAEhE;AAiDD;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,cAAc,GAAG,IAAI,EAC7B,SAAS,EAAE,MAAM,GAChB,MAAM,CAuBR;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,oBAAoB,EAC9B,OAAO,GAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAO,GACnD,oBAAoB,CAuHtB;AAyCD;;;;;;;;;;;GAWG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,oBAAoB,GAAG,MAAM,CA+C/E"}