@openscout/protocol 0.2.102 → 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 (48) 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 +1 -0
  5. package/dist/broker-routes.d.ts.map +1 -1
  6. package/dist/broker-routes.js +1 -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/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -0
  21. package/dist/machines.d.ts +2 -0
  22. package/dist/machines.d.ts.map +1 -1
  23. package/dist/mesh.d.ts +8 -0
  24. package/dist/mesh.d.ts.map +1 -1
  25. package/dist/runtime-catalog-contract.d.ts +1 -1
  26. package/dist/runtime-catalog-contract.d.ts.map +1 -1
  27. package/dist/runtime-catalog-contract.js +1 -1
  28. package/dist/runtime-catalog.generated.d.ts +29 -1
  29. package/dist/runtime-catalog.generated.d.ts.map +1 -1
  30. package/dist/runtime-catalog.generated.js +34 -1
  31. package/dist/runtime-execution.d.ts +1 -1
  32. package/dist/runtime-execution.d.ts.map +1 -1
  33. package/dist/transports.d.ts +39 -1
  34. package/dist/transports.d.ts.map +1 -1
  35. package/package.json +2 -2
  36. package/src/actors.ts +2 -0
  37. package/src/broker-routes.ts +1 -0
  38. package/src/channel-identity.ts +151 -0
  39. package/src/channel-invites.ts +343 -0
  40. package/src/channel-reception.ts +308 -0
  41. package/src/common.ts +1 -0
  42. package/src/index.ts +2 -0
  43. package/src/machines.ts +2 -0
  44. package/src/mesh.ts +8 -0
  45. package/src/runtime-catalog-contract.ts +1 -1
  46. package/src/runtime-catalog.generated.ts +34 -1
  47. package/src/runtime-catalog.v1.json +13 -2
  48. package/src/transports.ts +48 -0
@@ -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"
package/src/index.ts CHANGED
@@ -11,6 +11,8 @@ export * from "./machines.js";
11
11
  export * from "./conversations.js";
12
12
  export * from "./conversation-projection.js";
13
13
  export * from "./channel-identity.js";
14
+ export * from "./channel-invites.js";
15
+ export * from "./channel-reception.js";
14
16
  export * from "./collaboration.js";
15
17
  export * from "./assigned-roles.js";
16
18
  export * from "./orchestrator-prompt.js";
package/src/machines.ts CHANGED
@@ -82,6 +82,8 @@ export interface ScoutMachineEvidence extends MachineEvidenceBase {
82
82
  hostName?: string;
83
83
  brokerUrl?: string;
84
84
  webUrl?: string;
85
+ /** The node's named web doorway (`m1.scout.local`) — see NodeDefinition.webHost. */
86
+ webHost?: string;
85
87
  advertiseScope?: AdvertiseScope;
86
88
  tailnetName?: string;
87
89
  isSelf?: boolean;
package/src/mesh.ts CHANGED
@@ -89,6 +89,14 @@ export interface NodeDefinition {
89
89
  advertiseScope: AdvertiseScope;
90
90
  brokerUrl?: string;
91
91
  webUrl?: string;
92
+ /**
93
+ * The node's named web doorway (e.g. `m1.scout.local`), matching the edge's
94
+ * advertisedHost. `*.scout.local` names resolve to 127.0.0.1 on every LAN
95
+ * machine; the local edge recognizes them as peer doorways and proxies to
96
+ * this node's reachable route, so the name is a stable way to open a peer's
97
+ * Scout regardless of its current address.
98
+ */
99
+ webHost?: string;
92
100
  meshEntrypoints?: NodeMeshEntrypoint[];
93
101
  tailnetName?: string;
94
102
  capabilities?: string[];
@@ -2,7 +2,7 @@ import type { AgentHarness } from "./actors.js";
2
2
 
3
3
  /** Harness ids Scout can select for a new execution session. */
4
4
  export const SCOUT_LAUNCHABLE_HARNESSES = [
5
- "claude", "codex", "grok", "grok-acp", "kimi", "flue", "cursor", "opencode", "pi",
5
+ "claude", "codex", "grok", "grok-acp", "kimi", "flue", "cursor", "opencode", "pi", "devin",
6
6
  ] as const satisfies readonly AgentHarness[];
7
7
 
8
8
  export type ScoutLaunchableHarness = typeof SCOUT_LAUNCHABLE_HARNESSES[number];
@@ -2,7 +2,7 @@
2
2
 
3
3
  export const SCOUT_RUNTIME_CATALOG_DATA = {
4
4
  "schemaVersion": "openscout.runtime-catalog.v1",
5
- "revision": "2026-09-10.1",
5
+ "revision": "2026-09-14.1",
6
6
  "harnesses": [
7
7
  {
8
8
  "id": "claude",
@@ -312,6 +312,39 @@ export const SCOUT_RUNTIME_CATALOG_DATA = {
312
312
  "enabled": true,
313
313
  "reasoningEfforts": null,
314
314
  "models": []
315
+ },
316
+ {
317
+ "id": "devin",
318
+ "label": "Devin",
319
+ "enabled": true,
320
+ "reasoningEfforts": null,
321
+ "models": [
322
+ {
323
+ "id": "swe-2-high",
324
+ "label": "SWE-2 High",
325
+ "enabled": true,
326
+ "default": true,
327
+ "family": "SWE",
328
+ "version": "2 High",
329
+ "contextWindowTokens": 262000
330
+ },
331
+ {
332
+ "id": "swe-2-medium",
333
+ "label": "SWE-2 Medium",
334
+ "enabled": true,
335
+ "family": "SWE",
336
+ "version": "2 Medium",
337
+ "contextWindowTokens": 262000
338
+ },
339
+ {
340
+ "id": "swe-2-max",
341
+ "label": "SWE-2 Max",
342
+ "enabled": true,
343
+ "family": "SWE",
344
+ "version": "2 Max",
345
+ "contextWindowTokens": 262000
346
+ }
347
+ ]
315
348
  }
316
349
  ]
317
350
  } as const;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": "openscout.runtime-catalog.v1",
3
- "revision": "2026-09-10.1",
3
+ "revision": "2026-09-14.1",
4
4
  "harnesses": [
5
5
  {
6
6
  "id": "claude",
@@ -77,6 +77,17 @@
77
77
  { "id": "opencode/laguna-s-2.1-free", "label": "Laguna S 2.1 (free)", "enabled": true, "family": "Laguna", "version": "S 2.1 Free" }
78
78
  ]
79
79
  },
80
- { "id": "pi", "label": "Pi", "enabled": true, "reasoningEfforts": null, "models": [] }
80
+ { "id": "pi", "label": "Pi", "enabled": true, "reasoningEfforts": null, "models": [] },
81
+ {
82
+ "id": "devin",
83
+ "label": "Devin",
84
+ "enabled": true,
85
+ "reasoningEfforts": null,
86
+ "models": [
87
+ { "id": "swe-2-high", "label": "SWE-2 High", "enabled": true, "default": true, "family": "SWE", "version": "2 High", "contextWindowTokens": 262000 },
88
+ { "id": "swe-2-medium", "label": "SWE-2 Medium", "enabled": true, "family": "SWE", "version": "2 Medium", "contextWindowTokens": 262000 },
89
+ { "id": "swe-2-max", "label": "SWE-2 Max", "enabled": true, "family": "SWE", "version": "2 Max", "contextWindowTokens": 262000 }
90
+ ]
91
+ }
81
92
  ]
82
93
  }
package/src/transports.ts CHANGED
@@ -5,6 +5,11 @@ import type { InvocationRequest } from "./invocations.js";
5
5
  import type { NodeDefinition } from "./mesh.js";
6
6
  import type { MessageRecord } from "./messages.js";
7
7
  import type { CollaborationEvent, CollaborationRecord } from "./collaboration.js";
8
+ import type {
9
+ ChannelInviteRedemptionRequest,
10
+ ChannelInviteRoute,
11
+ ChannelInviteInvitee,
12
+ } from "./channel-invites.js";
8
13
 
9
14
  export interface SubscriptionRequest {
10
15
  actorId: ScoutId;
@@ -77,6 +82,46 @@ export interface CollaborationEventAppendCommand {
77
82
  event: CollaborationEvent;
78
83
  }
79
84
 
85
+ /**
86
+ * Invitation writes are broker commands rather than conversation upserts so
87
+ * the read-modify-write of a channel's invitation set happens inside the
88
+ * broker's serialized command queue. Two operators minting an invitation at
89
+ * the same instant must not clobber each other's record.
90
+ */
91
+ export interface ChannelInviteCreateCommand {
92
+ kind: "channel.invite.create";
93
+ channelId: ScoutId;
94
+ inviteId: ScoutId;
95
+ /** Digest only. The raw token never crosses this boundary. */
96
+ tokenHash: string;
97
+ tokenHint: string;
98
+ createdByActorId: ScoutId;
99
+ createdAt: number;
100
+ expiresAt: number | null;
101
+ maxRedemptions: number | null;
102
+ route: ChannelInviteRoute;
103
+ invitee?: ChannelInviteInvitee;
104
+ metadata?: MetadataMap;
105
+ }
106
+
107
+ export interface ChannelInviteRevokeCommand {
108
+ kind: "channel.invite.revoke";
109
+ channelId: ScoutId;
110
+ inviteId: ScoutId;
111
+ revokedByActorId: ScoutId;
112
+ revokedAt: number;
113
+ }
114
+
115
+ export interface ChannelInviteRedeemCommand {
116
+ kind: "channel.invite.redeem";
117
+ channelId: ScoutId;
118
+ /** Digest of the presented token; the broker matches, never the caller. */
119
+ tokenHash: string;
120
+ redemptionId: ScoutId;
121
+ redeemedAt: number;
122
+ request: ChannelInviteRedemptionRequest;
123
+ }
124
+
80
125
  export type ControlCommand =
81
126
  | NodeUpsertCommand
82
127
  | ActorUpsertCommand
@@ -86,6 +131,9 @@ export type ControlCommand =
86
131
  | BindingUpsertCommand
87
132
  | CollaborationUpsertCommand
88
133
  | CollaborationEventAppendCommand
134
+ | ChannelInviteCreateCommand
135
+ | ChannelInviteRevokeCommand
136
+ | ChannelInviteRedeemCommand
89
137
  | PostMessageCommand
90
138
  | InvokeAgentCommand
91
139
  | EnsureAwakeCommand