@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,343 @@
1
+ import type { MetadataMap, ScoutId } from "./common.js";
2
+
3
+ /**
4
+ * Channel invitations: a portable, scoped, revocable capability that lets a
5
+ * teammate bring an agent session they are *already running* into a Scout
6
+ * channel.
7
+ *
8
+ * Three boundaries shape this module.
9
+ *
10
+ * 1. **The secret never lands in a record.** Only `tokenHash` is persisted, so
11
+ * a snapshot, a journal line, or a log dump cannot be replayed into channel
12
+ * membership. The raw token exists in the invitation URL and nowhere else.
13
+ * 2. **Membership is not reception.** Redeeming an invitation makes an actor a
14
+ * channel participant. Whether that actor can actually *receive* is a
15
+ * separate, live judgement made from endpoint evidence — see
16
+ * {@link deriveChannelReception}.
17
+ * 3. **Reachability is stated, not assumed.** A loopback route is recorded as
18
+ * `local_only` so a surface can say plainly that the link will not work for
19
+ * a teammate on another machine.
20
+ *
21
+ * Invites live in the owning conversation's metadata under
22
+ * {@link CHANNEL_INVITES_METADATA_KEY}; the broker remains the only writer.
23
+ */
24
+
25
+ export const CHANNEL_INVITES_METADATA_KEY = "channelInvites";
26
+
27
+ /**
28
+ * The only scope this pilot grants. It permits posting, reading, and replying
29
+ * inside one channel — never project, shell, or filesystem authority.
30
+ */
31
+ export type ChannelInviteScope = "channel_participation";
32
+
33
+ export const CHANNEL_INVITE_SCOPE: ChannelInviteScope = "channel_participation";
34
+
35
+ /** How far the recorded route actually reaches. */
36
+ export type ChannelInviteReachability =
37
+ /** Loopback only: useless to anybody not on the authority machine. */
38
+ | "local_only"
39
+ /** A LAN/mDNS doorway name or address on the local network. */
40
+ | "lan"
41
+ /** A tailnet/mesh route that survives leaving the LAN. */
42
+ | "mesh"
43
+ /** No evidence either way; do not claim it works. */
44
+ | "unknown";
45
+
46
+ export interface ChannelInviteRoute {
47
+ /** The broker node that owns the channel and must accept the redemption. */
48
+ authorityNodeId: ScoutId;
49
+ /** Host an invited agent should dial. */
50
+ host: string;
51
+ /** Absolute base URL the invitation document points at. */
52
+ baseUrl: string;
53
+ reachability: ChannelInviteReachability;
54
+ /**
55
+ * Why this route may not reach the invitee. Present whenever reachability is
56
+ * anything but `mesh`; surfaces must render it rather than imply the link
57
+ * works everywhere.
58
+ */
59
+ caveat?: string;
60
+ }
61
+
62
+ /** One concrete agent session that accepted one invitation. */
63
+ export interface ChannelInviteRedemption {
64
+ id: ScoutId;
65
+ /** The channel participant this redemption created or confirmed. */
66
+ actorId: ScoutId;
67
+ agentId?: ScoutId;
68
+ /** The pre-existing harness session attached to the channel. */
69
+ sessionId?: string;
70
+ endpointId?: ScoutId;
71
+ nodeId?: ScoutId;
72
+ harness?: string;
73
+ projectRoot?: string;
74
+ displayName?: string;
75
+ redeemedAt: number;
76
+ }
77
+
78
+ export interface ChannelInviteInvitee {
79
+ displayName: string;
80
+ handle?: string;
81
+ /** Set when the invitation names a durable person actor up front. */
82
+ actorId?: ScoutId;
83
+ }
84
+
85
+ export interface ChannelInviteRecord {
86
+ id: ScoutId;
87
+ channelId: ScoutId;
88
+ scope: ChannelInviteScope;
89
+ /** Lowercase hex digest of the raw token. The raw token is never stored. */
90
+ tokenHash: string;
91
+ /** A short non-secret fragment so humans can tell two invitations apart. */
92
+ tokenHint: string;
93
+ createdAt: number;
94
+ createdByActorId: ScoutId;
95
+ expiresAt: number | null;
96
+ maxRedemptions: number | null;
97
+ revokedAt?: number;
98
+ revokedByActorId?: ScoutId;
99
+ invitee?: ChannelInviteInvitee;
100
+ route: ChannelInviteRoute;
101
+ redemptions: ChannelInviteRedemption[];
102
+ metadata?: MetadataMap;
103
+ }
104
+
105
+ export type ChannelInviteState = "active" | "expired" | "revoked" | "exhausted";
106
+
107
+ export function channelInviteState(
108
+ invite: ChannelInviteRecord,
109
+ nowMs: number,
110
+ ): ChannelInviteState {
111
+ if (invite.revokedAt !== undefined) return "revoked";
112
+ if (invite.expiresAt !== null && invite.expiresAt <= nowMs) return "expired";
113
+ if (
114
+ invite.maxRedemptions !== null
115
+ && invite.redemptions.length >= invite.maxRedemptions
116
+ ) {
117
+ return "exhausted";
118
+ }
119
+ return "active";
120
+ }
121
+
122
+ export type ChannelInviteRejectionReason =
123
+ | "unknown_token"
124
+ | "expired"
125
+ | "revoked"
126
+ | "exhausted"
127
+ | "scope_mismatch"
128
+ | "channel_mismatch"
129
+ | "missing_identity";
130
+
131
+ export interface ChannelInviteRejection {
132
+ reason: ChannelInviteRejectionReason;
133
+ message: string;
134
+ }
135
+
136
+ export interface ChannelInviteRedemptionRequest {
137
+ actorId: ScoutId;
138
+ agentId?: ScoutId;
139
+ sessionId?: string;
140
+ endpointId?: ScoutId;
141
+ nodeId?: ScoutId;
142
+ harness?: string;
143
+ projectRoot?: string;
144
+ displayName?: string;
145
+ channelId?: ScoutId;
146
+ scope?: string;
147
+ }
148
+
149
+ export type ChannelInviteRedemptionOutcome =
150
+ | {
151
+ ok: true;
152
+ /**
153
+ * The redemption already on file for this identity. Present on a retry;
154
+ * the caller must reuse it rather than appending a second row or
155
+ * consuming another slot.
156
+ */
157
+ existing: ChannelInviteRedemption | null;
158
+ }
159
+ | { ok: false; rejection: ChannelInviteRejection };
160
+
161
+ /**
162
+ * Decide whether one redemption attempt may proceed.
163
+ *
164
+ * Retries are first-class: an identity that already redeemed this invitation
165
+ * is accepted again with its original redemption, even once the invitation is
166
+ * exhausted. Re-running an agent's join command must never mint a second
167
+ * identity, burn a second slot, or replace a live session.
168
+ */
169
+ export function evaluateChannelInviteRedemption(input: {
170
+ invite: ChannelInviteRecord;
171
+ request: ChannelInviteRedemptionRequest;
172
+ nowMs: number;
173
+ }): ChannelInviteRedemptionOutcome {
174
+ const { invite, request, nowMs } = input;
175
+
176
+ const actorId = request.actorId?.trim();
177
+ if (!actorId) {
178
+ return {
179
+ ok: false,
180
+ rejection: {
181
+ reason: "missing_identity",
182
+ message: "Redemption needs the actor id of the agent session that is joining.",
183
+ },
184
+ };
185
+ }
186
+
187
+ if (request.channelId?.trim() && request.channelId.trim() !== invite.channelId) {
188
+ return {
189
+ ok: false,
190
+ rejection: {
191
+ reason: "channel_mismatch",
192
+ message: "This invitation does not belong to the requested channel.",
193
+ },
194
+ };
195
+ }
196
+
197
+ if (request.scope?.trim() && request.scope.trim() !== invite.scope) {
198
+ return {
199
+ ok: false,
200
+ rejection: {
201
+ reason: "scope_mismatch",
202
+ message: `This invitation grants ${invite.scope} only.`,
203
+ },
204
+ };
205
+ }
206
+
207
+ const existing = findChannelInviteRedemption(invite, request);
208
+ if (existing) {
209
+ // Revocation still bites: a revoked invitation stops being a usable
210
+ // capability even for an identity that once held it.
211
+ if (invite.revokedAt !== undefined) {
212
+ return { ok: false, rejection: channelInviteRejection("revoked") };
213
+ }
214
+ return { ok: true, existing };
215
+ }
216
+
217
+ const state = channelInviteState(invite, nowMs);
218
+ if (state !== "active") {
219
+ return { ok: false, rejection: channelInviteRejection(state) };
220
+ }
221
+
222
+ return { ok: true, existing: null };
223
+ }
224
+
225
+ /**
226
+ * The redemption already recorded for this identity, if any.
227
+ *
228
+ * **Session identity is decisive.** When a request carries a session id, only a
229
+ * redemption for that exact session counts as the same joiner. A durable agent
230
+ * running two concrete sessions is two distinct attachments: this feature
231
+ * exists to attach *the exact session* a teammate is already working in, so
232
+ * letting a second session inherit the first one's membership through a shared
233
+ * agent id would hand it the wrong context and silently skip a use of the
234
+ * invitation.
235
+ *
236
+ * Only when no session is carried does the durable actor or agent id decide.
237
+ */
238
+ export function findChannelInviteRedemption(
239
+ invite: ChannelInviteRecord,
240
+ request: Pick<ChannelInviteRedemptionRequest, "actorId" | "sessionId" | "agentId">,
241
+ ): ChannelInviteRedemption | null {
242
+ const sessionId = request.sessionId?.trim();
243
+ if (sessionId) {
244
+ return invite.redemptions.find(
245
+ (redemption) => redemption.sessionId?.trim() === sessionId,
246
+ ) ?? null;
247
+ }
248
+
249
+ const actorId = request.actorId?.trim();
250
+ const agentId = request.agentId?.trim();
251
+ return invite.redemptions.find((redemption) => {
252
+ if (actorId && redemption.actorId === actorId) return true;
253
+ return Boolean(agentId) && redemption.agentId?.trim() === agentId;
254
+ }) ?? null;
255
+ }
256
+
257
+ function channelInviteRejection(
258
+ state: Exclude<ChannelInviteState, "active">,
259
+ ): ChannelInviteRejection {
260
+ switch (state) {
261
+ case "expired":
262
+ return { reason: "expired", message: "This invitation has expired. Ask for a new one." };
263
+ case "revoked":
264
+ return { reason: "revoked", message: "This invitation was revoked." };
265
+ case "exhausted":
266
+ return {
267
+ reason: "exhausted",
268
+ message: "This invitation has already been used the maximum number of times.",
269
+ };
270
+ }
271
+ }
272
+
273
+ export function unknownChannelInviteRejection(): ChannelInviteRejection {
274
+ // Deliberately identical for a malformed, unknown, and deleted token: a
275
+ // probe must not learn which invitations exist.
276
+ return { reason: "unknown_token", message: "This invitation link is not valid." };
277
+ }
278
+
279
+ /** Read the invitations stored on a conversation record. */
280
+ export function readChannelInvites(
281
+ metadata: MetadataMap | undefined,
282
+ ): ChannelInviteRecord[] {
283
+ const raw = metadata?.[CHANNEL_INVITES_METADATA_KEY];
284
+ if (!Array.isArray(raw)) return [];
285
+ return raw.filter((entry): entry is ChannelInviteRecord =>
286
+ Boolean(entry)
287
+ && typeof entry === "object"
288
+ && typeof (entry as ChannelInviteRecord).id === "string"
289
+ && typeof (entry as ChannelInviteRecord).tokenHash === "string"
290
+ && Array.isArray((entry as ChannelInviteRecord).redemptions));
291
+ }
292
+
293
+ /** Match a presented token against stored invitations by its digest. */
294
+ export function findChannelInviteByTokenHash(
295
+ invites: ChannelInviteRecord[],
296
+ tokenHash: string,
297
+ ): ChannelInviteRecord | null {
298
+ const normalized = tokenHash.trim().toLowerCase();
299
+ if (!normalized) return null;
300
+ return invites.find((invite) => invite.tokenHash.toLowerCase() === normalized) ?? null;
301
+ }
302
+
303
+ /**
304
+ * The public view of an invitation: everything a human or agent needs to
305
+ * understand the offer, and nothing that could be replayed into membership.
306
+ */
307
+ export interface ChannelInvitePublicView {
308
+ id: ScoutId;
309
+ channelId: ScoutId;
310
+ /** Public authorship, used to show who may revoke this invitation. */
311
+ createdByActorId: ScoutId;
312
+ scope: ChannelInviteScope;
313
+ state: ChannelInviteState;
314
+ tokenHint: string;
315
+ createdAt: number;
316
+ expiresAt: number | null;
317
+ maxRedemptions: number | null;
318
+ redemptionCount: number;
319
+ invitee?: ChannelInviteInvitee;
320
+ route: ChannelInviteRoute;
321
+ redemptions: ChannelInviteRedemption[];
322
+ }
323
+
324
+ export function channelInvitePublicView(
325
+ invite: ChannelInviteRecord,
326
+ nowMs: number,
327
+ ): ChannelInvitePublicView {
328
+ return {
329
+ id: invite.id,
330
+ channelId: invite.channelId,
331
+ createdByActorId: invite.createdByActorId,
332
+ scope: invite.scope,
333
+ state: channelInviteState(invite, nowMs),
334
+ tokenHint: invite.tokenHint,
335
+ createdAt: invite.createdAt,
336
+ expiresAt: invite.expiresAt,
337
+ maxRedemptions: invite.maxRedemptions,
338
+ redemptionCount: invite.redemptions.length,
339
+ ...(invite.invitee ? { invitee: invite.invitee } : {}),
340
+ route: invite.route,
341
+ redemptions: invite.redemptions,
342
+ };
343
+ }
@@ -0,0 +1,308 @@
1
+ import type { AgentState, DeliveryTransport } from "./common.js";
2
+
3
+ /**
4
+ * Honest reception state for a channel member.
5
+ *
6
+ * Channel membership is a durable fact. Whether a member can actually receive
7
+ * a message right now is a live judgement, and the two must never be conflated
8
+ * — a roster that renders every member as "listening" because a row exists is
9
+ * the specific failure this module prevents.
10
+ *
11
+ * Two independent axes carry the truth:
12
+ *
13
+ * - {@link ChannelReceptionRouteKind} — what the attached transport *can* do.
14
+ * A `claude_resume` or `codex_exec` endpoint has no persistent listener at
15
+ * all: a delivery wakes a fresh run. That is a legitimate route, but calling
16
+ * it "listening" would be a lie.
17
+ * - {@link ChannelReceptionState} — what the route is doing at this instant,
18
+ * read from endpoint state and heartbeat freshness.
19
+ */
20
+
21
+ export type ChannelReceptionState =
22
+ /** A member with no agent session attached yet — the invite is unredeemed. */
23
+ | "waiting_for_agent"
24
+ /** An attached session that is still warming up or waking. */
25
+ | "connecting"
26
+ /** A live route that will carry a message now. */
27
+ | "ready_to_receive"
28
+ /** Attached, but no usable route exists — explain why, never imply delivery. */
29
+ | "unavailable"
30
+ /** A route that existed and has since gone quiet or been stopped. */
31
+ | "disconnected";
32
+
33
+ export type ChannelReceptionRouteKind =
34
+ /** The agent holds an open channel and sees a post without being started. */
35
+ | "persistent"
36
+ /**
37
+ * Delivery reaches the invited session by resuming or steering it. Context is
38
+ * preserved and the session is never replaced, but nothing is watching
39
+ * between messages.
40
+ */
41
+ | "wake_on_delivery"
42
+ /** No route at all. */
43
+ | "none";
44
+
45
+ /**
46
+ * Transports where a process is actually attached and watching. Everything
47
+ * else either spawns per delivery or injects into a surface with no listener
48
+ * contract, and is reported as `wake_on_delivery`.
49
+ */
50
+ const PERSISTENT_RECEIVE_TRANSPORTS = new Set<DeliveryTransport>([
51
+ "local_socket",
52
+ "websocket",
53
+ "pairing_bridge",
54
+ "peer_broker",
55
+ "claude_channel",
56
+ "claude_stream_json",
57
+ "codex_app_server",
58
+ "pi_rpc",
59
+ "grok_acp",
60
+ "kimi_acp",
61
+ "cursor_acp",
62
+ "opencode_acp",
63
+ "devin_acp",
64
+ "cursor_sdk_local",
65
+ ]);
66
+
67
+ export function channelReceptionRouteKind(
68
+ transport: DeliveryTransport | null | undefined,
69
+ ): ChannelReceptionRouteKind {
70
+ if (!transport) return "none";
71
+ return PERSISTENT_RECEIVE_TRANSPORTS.has(transport) ? "persistent" : "wake_on_delivery";
72
+ }
73
+
74
+ /** How long an idle persistent route may stay silent before we stop vouching for it. */
75
+ export const CHANNEL_RECEPTION_STALE_AFTER_MS = 10 * 60 * 1000;
76
+
77
+ export interface ChannelReceptionEndpointEvidence {
78
+ state: AgentState;
79
+ transport: DeliveryTransport;
80
+ sessionId?: string | null;
81
+ lastSeenAt?: number | null;
82
+ }
83
+
84
+ export interface ChannelReceptionEvidence {
85
+ /** The member's attached harness session, when the invite was redeemed. */
86
+ attachedSessionId?: string | null;
87
+ endpoint?: ChannelReceptionEndpointEvidence | null;
88
+ nowMs: number;
89
+ staleAfterMs?: number;
90
+ }
91
+
92
+ export interface ChannelReception {
93
+ state: ChannelReceptionState;
94
+ routeKind: ChannelReceptionRouteKind;
95
+ /** True only when a process is attached and watching right now. */
96
+ listening: boolean;
97
+ /** Badge-length label. */
98
+ summary: string;
99
+ /** One sentence a person can act on. Always populated. */
100
+ detail: string;
101
+ /** When the evidence behind this reading was last observed. */
102
+ evidenceAt: number | null;
103
+ }
104
+
105
+ /**
106
+ * Derive a member's reception state from live evidence only.
107
+ *
108
+ * Every branch returns a `detail` sentence, including the happy path: a
109
+ * surface should never have to invent an explanation, and a `wake_on_delivery`
110
+ * route always says so out loud.
111
+ */
112
+ export function deriveChannelReception(
113
+ evidence: ChannelReceptionEvidence,
114
+ ): ChannelReception {
115
+ const endpoint = evidence.endpoint ?? null;
116
+ const attachedSessionId = evidence.attachedSessionId?.trim() || null;
117
+ const routeKind = channelReceptionRouteKind(endpoint?.transport);
118
+ const evidenceAt = endpoint?.lastSeenAt ?? null;
119
+
120
+ if (!endpoint) {
121
+ return attachedSessionId
122
+ ? {
123
+ state: "unavailable",
124
+ routeKind: "none",
125
+ listening: false,
126
+ summary: "No route",
127
+ detail:
128
+ `Session ${attachedSessionId} is attached to this channel but the broker has no registered endpoint for it, so messages cannot be delivered.`,
129
+ evidenceAt,
130
+ }
131
+ : {
132
+ state: "waiting_for_agent",
133
+ routeKind: "none",
134
+ listening: false,
135
+ summary: "Waiting for agent",
136
+ detail: "This member has joined the channel but no agent session has redeemed the invitation yet.",
137
+ evidenceAt,
138
+ };
139
+ }
140
+
141
+ const base = { routeKind, evidenceAt } as const;
142
+
143
+ // An endpoint is only evidence about *this* member when the channel knows
144
+ // which session it attached. Without that link there is a route on the
145
+ // machine but nothing tying it to this membership, so it cannot be read as
146
+ // this member being reachable.
147
+ if (!attachedSessionId) {
148
+ return {
149
+ ...base,
150
+ state: "waiting_for_agent",
151
+ listening: false,
152
+ summary: "Waiting for agent",
153
+ 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.`,
154
+ };
155
+ }
156
+
157
+ // The invitation attached one concrete session. A live endpoint belonging to
158
+ // a different session is somebody else's evidence: reporting it as this
159
+ // member's readiness is exactly the false listening claim to avoid.
160
+ const endpointSessionId = endpoint.sessionId?.trim() || null;
161
+ if (endpointSessionId && endpointSessionId !== attachedSessionId) {
162
+ return {
163
+ ...base,
164
+ state: "disconnected",
165
+ listening: false,
166
+ summary: "Session replaced",
167
+ 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.`,
168
+ };
169
+ }
170
+
171
+ switch (endpoint.state) {
172
+ case "attaching":
173
+ case "waking":
174
+ return {
175
+ ...base,
176
+ state: "connecting",
177
+ listening: false,
178
+ summary: "Connecting",
179
+ detail: `The ${endpoint.transport} route is ${endpoint.state}; messages sent now will be delivered once it settles.`,
180
+ };
181
+ case "registered":
182
+ // `registered` means the broker knows how to reach this endpoint, not
183
+ // that a session is attached and watching. Only the live states below
184
+ // are evidence of an active receive route.
185
+ return {
186
+ ...base,
187
+ state: "connecting",
188
+ listening: false,
189
+ summary: "Registered",
190
+ 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.`,
191
+ };
192
+ case "offline":
193
+ case "stopped":
194
+ return {
195
+ ...base,
196
+ state: "disconnected",
197
+ listening: false,
198
+ summary: "Disconnected",
199
+ detail: `The attached session's ${endpoint.transport} route is ${endpoint.state}. Restart or re-attach the session to receive messages.`,
200
+ };
201
+ case "superseded":
202
+ return {
203
+ ...base,
204
+ state: "disconnected",
205
+ listening: false,
206
+ summary: "Superseded",
207
+ detail: "A newer route replaced this one. The member's membership stands, but this endpoint no longer receives.",
208
+ };
209
+ case "unreachable":
210
+ case "failed":
211
+ return {
212
+ ...base,
213
+ state: "unavailable",
214
+ listening: false,
215
+ summary: endpoint.state === "failed" ? "Failed" : "Unreachable",
216
+ detail: `The broker last saw this ${endpoint.transport} route as ${endpoint.state}; it cannot currently accept a delivery.`,
217
+ };
218
+ default:
219
+ break;
220
+ }
221
+
222
+ // Past this point every branch makes a positive claim about the attached
223
+ // session. An endpoint that does not say which session it serves cannot
224
+ // support one: the route may well be fine, but "session X is receiving" is a
225
+ // claim about a specific session and this is not evidence for it. The
226
+ // negative states above are unaffected -- a dead route is dead either way.
227
+ if (!endpointSessionId) {
228
+ return {
229
+ ...base,
230
+ state: "connecting",
231
+ listening: false,
232
+ summary: "Unconfirmed",
233
+ 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.`,
234
+ };
235
+ }
236
+
237
+ if (routeKind === "wake_on_delivery") {
238
+ return {
239
+ ...base,
240
+ state: "ready_to_receive",
241
+ listening: false,
242
+ summary: "Wakes on delivery",
243
+ // Say what actually happens. These transports resume or steer the exact
244
+ // invited session, so context survives -- claiming a message "starts a
245
+ // new run" would misdescribe them. What they lack is a listener between
246
+ // messages, and that is the only claim being withheld here.
247
+ 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.`,
248
+ };
249
+ }
250
+
251
+ const staleAfterMs = evidence.staleAfterMs ?? CHANNEL_RECEPTION_STALE_AFTER_MS;
252
+ const lastSeenAt = endpoint.lastSeenAt ?? null;
253
+
254
+ // Registration is not observation. A persistent route claims a process is
255
+ // watching right now; with no timestamp the broker has never seen it do so,
256
+ // and an unobserved row is not grounds for telling a person their agent is
257
+ // listening. A wake-on-delivery route makes no such claim, so its
258
+ // registration alone remains sufficient.
259
+ if (lastSeenAt === null) {
260
+ return {
261
+ ...base,
262
+ state: "connecting",
263
+ listening: false,
264
+ summary: "Unconfirmed",
265
+ detail: `The ${endpoint.transport} route is registered for this session but has never been observed live, so Scout cannot yet confirm it is listening.`,
266
+ };
267
+ }
268
+
269
+ if (evidence.nowMs - lastSeenAt > staleAfterMs) {
270
+ return {
271
+ ...base,
272
+ state: "disconnected",
273
+ listening: false,
274
+ summary: "Silent",
275
+ 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.`,
276
+ evidenceAt: lastSeenAt,
277
+ };
278
+ }
279
+
280
+ return {
281
+ ...base,
282
+ state: "ready_to_receive",
283
+ listening: true,
284
+ summary: "Ready to receive",
285
+ detail: `An attached ${endpoint.transport} session is watching this channel and will see new messages without being started.`,
286
+ evidenceAt: lastSeenAt,
287
+ };
288
+ }
289
+
290
+ function formatDuration(ms: number): string {
291
+ const minutes = Math.floor(ms / 60_000);
292
+ if (minutes < 1) return "under a minute";
293
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"}`;
294
+ const hours = Math.floor(minutes / 60);
295
+ if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"}`;
296
+ const days = Math.floor(hours / 24);
297
+ return `${days} day${days === 1 ? "" : "s"}`;
298
+ }
299
+
300
+ export function channelReceptionStateLabel(state: ChannelReceptionState): string {
301
+ switch (state) {
302
+ case "waiting_for_agent": return "Waiting for agent";
303
+ case "connecting": return "Connecting";
304
+ case "ready_to_receive": return "Ready to receive";
305
+ case "unavailable": return "Unavailable";
306
+ case "disconnected": return "Disconnected";
307
+ }
308
+ }
package/src/common.ts CHANGED
@@ -82,6 +82,7 @@ export type DeliveryTransport =
82
82
  | "kimi_acp"
83
83
  | "cursor_acp"
84
84
  | "opencode_acp"
85
+ | "devin_acp"
85
86
  | "tmux"
86
87
  | "cursor_exec"
87
88
  | "cursor_cli_text"
@@ -1,7 +1,7 @@
1
1
  import type { ConversationKind } from "./conversations.js";
2
2
  import type { ObservedActivity } from "./observed-status.js";
3
3
 
4
- export const CONVERSATION_PROJECTION_VERSION = 1;
4
+ export const CONVERSATION_PROJECTION_VERSION = 2;
5
5
 
6
6
  export type ConversationProjectionEntityKind =
7
7
  | "scout_conversation"