@openscout/protocol 0.1.0

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.
package/README.md ADDED
@@ -0,0 +1,245 @@
1
+ # OpenScout Control Protocol
2
+
3
+ `@openscout/protocol` defines the durable local communication and execution contract for OpenScout.
4
+
5
+ This package is intentionally not named `relay`. Relay is now a surface and compatibility layer. The control protocol is the canonical model underneath it.
6
+
7
+ ## What Makes This A Good Local Communication Protocol
8
+
9
+ The target is a protocol that is:
10
+
11
+ - explicit: conversation, work, delivery, and external bindings are separate records
12
+ - durable: the broker is the only writer and local state is stored canonically
13
+ - addressable: actors, conversations, messages, invocations, flights, and deliveries all have stable IDs
14
+ - replayable: read models can be rebuilt from durable records and events
15
+ - observable: status, ownership, outputs, and failures are inspectable
16
+ - recoverable: broker restarts do not have to erase the story of what happened
17
+ - harness-agnostic: harness details live at endpoints and adapters, not in the protocol itself
18
+
19
+ If someone asks why this is better than terminal scrollback or ad hoc file sharing, that is the answer.
20
+
21
+ ## Core Model
22
+
23
+ The protocol keeps a small set of nouns and gives each one a single job:
24
+
25
+ - `actor`: an identity in the system such as a person, helper, agent, system process, bridge, or device
26
+ - `agent`: a durable autonomous target with capabilities and one or more endpoints
27
+ - `conversation`: an addressable context boundary such as a channel, direct message, thread, or system conversation
28
+ - `message`: a human-readable conversation record
29
+ - `invocation`: an explicit request for work
30
+ - `flight`: the tracked lifecycle of one invocation
31
+ - `delivery`: a transport-specific fan-out intent for a message or invocation
32
+ - `binding`: a mapping from an OpenScout conversation to an external thread or channel
33
+ - `event`: an append-only fact emitted whenever one of the durable records changes
34
+
35
+ ## Emerging Collaboration Model
36
+
37
+ The protocol package now also exports a collaboration vocabulary for the next layer above
38
+ messages and invocations. This is the first thin slice toward explicit human-agent and
39
+ agent-agent workflow tracking. It is intentionally small.
40
+
41
+ The current canonical collaboration kinds are:
42
+
43
+ - `question`: a lightweight information-seeking interaction with states such as `open`,
44
+ `answered`, `closed`, and `declined`
45
+ - `work_item`: a durable execution object with states such as `open`, `working`,
46
+ `waiting`, `review`, `done`, and `cancelled`
47
+
48
+ These are peers, not points on one severity ladder:
49
+
50
+ - a question can resolve directly
51
+ - a question can attach to a work item
52
+ - a question can spawn a work item
53
+ - a work item can accumulate progress, waiting conditions, and review state without
54
+ pretending it started as a question
55
+
56
+ Acceptance is modeled separately from workflow state so that a reply and satisfaction do
57
+ not collapse into one transition. A work item can be done without peer acceptance, and a
58
+ question can be answered without being closed yet.
59
+
60
+ The exported collaboration shapes are a protocol vocabulary for upcoming runtime and UI
61
+ work. They do not yet imply that the broker persists the full collaboration layer today.
62
+ See [docs/collaboration-workflows-v1.md](../../docs/collaboration-workflows-v1.md) for the
63
+ v1 model.
64
+
65
+ ## Identity Model
66
+
67
+ The important distinction is between a helper and an agent:
68
+
69
+ - `person`: the actual human identity
70
+ - `helper`: a session-bound assistant working on behalf of a person
71
+ - `agent`: a durable autonomous player with its own identity and capabilities
72
+ - `system`: runtime-owned internal identity
73
+ - `bridge`: external platform adapter identity
74
+ - `device`: a concrete endpoint such as a native app client or speaker session
75
+
76
+ This lets a person work with a helper in Codex or Claude while still invoking real agents as first-class targets.
77
+
78
+ ## Core Design Rules
79
+
80
+ 1. A message is conversation.
81
+ 2. An invocation is work.
82
+ 3. A flight is the tracked lifecycle of that work.
83
+ 4. Delivery is planned explicitly per target and transport.
84
+ 5. Bindings map external channels into the same internal model.
85
+ 6. Voice is metadata and transport, not the canonical message body.
86
+ 7. The broker is the only canonical writer.
87
+
88
+ ## Bootstrap And Startup
89
+
90
+ The intended machine lifecycle is:
91
+
92
+ 1. `scout init` creates machine-local settings, a relay agent registry, and repo-local `.openscout/project.json` when needed.
93
+ 2. The runtime installs a launch agent under `~/Library/LaunchAgents/` for the broker.
94
+ 3. `launchd` keeps the broker process alive and restarts it if it exits.
95
+ 4. Workspace discovery scans configured roots and repo-local manifests to map projects to agent identities.
96
+ 5. Agents register endpoints that describe their harness, transport, session, cwd, and project root.
97
+ 6. Surfaces such as the desktop shell, CLI, and relay compatibility layer talk to the broker instead of writing shared files directly.
98
+
99
+ ## Conversation, Work, And Delivery
100
+
101
+ ### Conversation
102
+
103
+ Conversation is human-readable history:
104
+
105
+ - channels
106
+ - direct messages
107
+ - group direct messages
108
+ - threads
109
+ - system conversations
110
+
111
+ Conversation state is designed for:
112
+
113
+ - visibility
114
+ - unread tracking
115
+ - mentions
116
+ - search
117
+ - auditability
118
+
119
+ ### Invocation
120
+
121
+ Invocation is a request for action:
122
+
123
+ - consult an agent
124
+ - execute a task
125
+ - summarize state
126
+ - report status
127
+ - wake an agent
128
+
129
+ Invocations create flights. Flights stream lifecycle state separately from the chat surface they came from.
130
+
131
+ ### Delivery
132
+
133
+ Each authored message exists once. Delivery fans out into typed intents.
134
+
135
+ The runtime plans deliveries separately for:
136
+
137
+ - conversation visibility
138
+ - notifications
139
+ - explicit invocations
140
+ - bridge outbound traffic
141
+ - speech playback
142
+
143
+ That means a single message can be visible to a channel, notify a mention, invoke an agent, and bridge outbound without duplicating the body.
144
+
145
+ ## Lifecycle
146
+
147
+ ```mermaid
148
+ sequenceDiagram
149
+ participant S as "Surface (CLI/Desktop/Bridge)"
150
+ participant B as "Broker"
151
+ participant DB as "Local Store"
152
+ participant H as "Agent Harness"
153
+
154
+ S->>B: Post message or invocation
155
+ B->>DB: Persist durable record
156
+ B->>DB: Append control event
157
+ B->>H: Plan delivery or wake target endpoint
158
+ H->>B: Flight update (queued/running/waiting/completed/failed)
159
+ B->>DB: Persist flight and delivery state
160
+ H->>B: Result message, artifact, or status
161
+ B->>DB: Persist output and append event
162
+ B-->>S: Stream updated conversation and work state
163
+ ```
164
+
165
+ The important part is the separation:
166
+
167
+ - messages make the conversation legible
168
+ - invocations make work explicit
169
+ - flights track execution without overloading chat
170
+ - deliveries make routing visible instead of implicit
171
+
172
+ ## Storage Model
173
+
174
+ The runtime package owns the SQLite schema. The durable model is:
175
+
176
+ - `nodes`
177
+ - `actors`
178
+ - `agents`
179
+ - `agent_endpoints`
180
+ - `conversations`
181
+ - `conversation_members`
182
+ - `messages`
183
+ - `message_mentions`
184
+ - `message_attachments`
185
+ - `invocations`
186
+ - `flights`
187
+ - `bindings`
188
+ - `deliveries`
189
+ - `delivery_attempts`
190
+ - `events`
191
+
192
+ SQLite is the canonical store because it stays local and inspectable while handling append races, indexing, leases, retries, and subscriptions better than raw JSONL.
193
+
194
+ ## Why Work Does Not Get Lost
195
+
196
+ The protocol is designed so that the system does not depend on terminal scrollback to remember what happened.
197
+
198
+ - messages are durable conversation records
199
+ - invocations are durable work requests
200
+ - flights are durable execution state
201
+ - deliveries and delivery attempts make routing and failures inspectable
202
+ - bindings make external channel mappings durable
203
+ - append-only events let read models be rebuilt
204
+
205
+ That does not require every surface to be smart. The broker owns the hard part, and the surfaces can recover by reading the canonical store.
206
+
207
+ ## Harness-Agnostic By Design
208
+
209
+ OpenScout should not fork its protocol per harness.
210
+
211
+ - endpoint records describe `harness`, `transport`, `session_id`, `cwd`, and `project_root`
212
+ - the same invocation and flight model applies whether the endpoint is Claude, Codex, tmux, or a future harness
213
+ - harness-specific launch and wake behavior belongs in runtime adapters
214
+ - bridge integrations stay at the edge and map into the same durable model
215
+
216
+ This keeps the protocol stable even when the execution layer changes.
217
+
218
+ ## Modalities
219
+
220
+ The protocol supports multiple modalities, but text remains canonical.
221
+
222
+ - HTTP: commands, admin, webhook intake
223
+ - WebSocket: subscriptions, streaming flight updates, typing/presence
224
+ - local socket: trusted local clients such as the native app and CLI
225
+ - bridges: Telegram, Discord, telecom adapters
226
+ - voice: transcripts, playback directives, media references
227
+
228
+ Raw media does not belong in the primary message log. The protocol stores transcript, speech, and attachment metadata while media transport stays on a dedicated transport.
229
+
230
+ ## What The Protocol Is Not
231
+
232
+ OpenScout is intentionally not trying to be:
233
+
234
+ - a hosted chat service
235
+ - a workflow engine with mandatory plan bureaucracy
236
+ - a harness-specific control plane
237
+ - a replacement for Telegram, Discord, or tmux
238
+
239
+ It is the durable local substrate that makes agent communication legible, inspectable, and recoverable.
240
+
241
+ ## Migration Direction
242
+
243
+ The control protocol replaces Relay as the core architecture.
244
+
245
+ Any remaining Relay-specific tools should be treated as surfaces or compatibility utilities, not as canonical storage or runtime paths.
@@ -0,0 +1,49 @@
1
+ import type { ActorKind, AdvertiseScope, AgentState, MetadataMap, ScoutId } from "./common.js";
2
+ export type AgentClass = "general" | "builder" | "reviewer" | "researcher" | "operator" | "bridge" | "system";
3
+ export type AgentCapability = "chat" | "invoke" | "deliver" | "speak" | "listen" | "bridge" | "summarize" | "review" | "execute";
4
+ export type AgentHarness = "codex" | "claude" | "native" | "worker" | "bridge" | "http";
5
+ export type WakePolicy = "manual" | "on_demand" | "keep_warm";
6
+ export interface ActorIdentity {
7
+ id: ScoutId;
8
+ kind: ActorKind;
9
+ displayName: string;
10
+ handle?: string;
11
+ labels?: string[];
12
+ metadata?: MetadataMap;
13
+ }
14
+ export interface AgentDefinition extends ActorIdentity {
15
+ kind: "agent";
16
+ definitionId: ScoutId;
17
+ nodeQualifier?: string;
18
+ workspaceQualifier?: string;
19
+ selector?: string;
20
+ defaultSelector?: string;
21
+ agentClass: AgentClass;
22
+ capabilities: AgentCapability[];
23
+ wakePolicy: WakePolicy;
24
+ homeNodeId: ScoutId;
25
+ authorityNodeId: ScoutId;
26
+ advertiseScope: AdvertiseScope;
27
+ ownerId?: ScoutId;
28
+ }
29
+ export interface HelperDefinition extends ActorIdentity {
30
+ kind: "helper";
31
+ ownerId: ScoutId;
32
+ nodeId: ScoutId;
33
+ engine: AgentHarness;
34
+ capabilities: AgentCapability[];
35
+ }
36
+ export interface AgentEndpoint {
37
+ id: ScoutId;
38
+ agentId: ScoutId;
39
+ nodeId: ScoutId;
40
+ harness: AgentHarness;
41
+ transport: "local_socket" | "http" | "websocket" | "claude_stream_json" | "codex_app_server" | "codex_exec" | "claude_resume" | "tmux";
42
+ state: AgentState;
43
+ address?: string;
44
+ sessionId?: string;
45
+ pane?: string;
46
+ cwd?: string;
47
+ projectRoot?: string;
48
+ metadata?: MetadataMap;
49
+ }
package/dist/actors.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import type { ScoutId } from "./common.js";
2
+ export interface AgentSelector {
3
+ raw: string;
4
+ label: string;
5
+ definitionId: ScoutId;
6
+ nodeQualifier?: string;
7
+ workspaceQualifier?: string;
8
+ }
9
+ export interface AgentSelectorCandidate {
10
+ agentId: ScoutId;
11
+ definitionId?: ScoutId;
12
+ nodeQualifier?: string;
13
+ workspaceQualifier?: string;
14
+ aliases?: string[];
15
+ }
16
+ export declare function normalizeAgentSelectorSegment(value: string): string;
17
+ export declare function parseAgentSelector(value: string): AgentSelector | null;
18
+ export declare function formatAgentSelector(input: {
19
+ definitionId: ScoutId;
20
+ nodeQualifier?: string;
21
+ workspaceQualifier?: string;
22
+ }, options?: {
23
+ includeSigil?: boolean;
24
+ }): string;
25
+ export declare function extractAgentSelectors(text: string): AgentSelector[];
26
+ export declare function agentSelectorMatches(selector: AgentSelector, candidate: AgentSelectorCandidate): boolean;
27
+ export declare function resolveAgentSelector<T extends AgentSelectorCandidate>(selector: AgentSelector, candidates: T[]): T | null;
@@ -0,0 +1,103 @@
1
+ function trimSelectorPrefix(value) {
2
+ return value
3
+ .trim()
4
+ .replace(/^@+/, "")
5
+ .replace(/[.,!?;:)\]]+$/, "");
6
+ }
7
+ export function normalizeAgentSelectorSegment(value) {
8
+ return value
9
+ .trim()
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9._/-]+/g, "-")
12
+ .replace(/[\\/]+/g, "-")
13
+ .replace(/^-+|-+$/g, "");
14
+ }
15
+ export function parseAgentSelector(value) {
16
+ const raw = trimSelectorPrefix(value);
17
+ if (!raw) {
18
+ return null;
19
+ }
20
+ const [definitionAndNode, workspacePart] = raw.split("#", 2);
21
+ const [definitionPart, nodePart] = definitionAndNode.split("@", 2);
22
+ const definitionId = normalizeAgentSelectorSegment(definitionPart);
23
+ const nodeQualifier = nodePart ? normalizeAgentSelectorSegment(nodePart) : undefined;
24
+ const workspaceQualifier = workspacePart ? normalizeAgentSelectorSegment(workspacePart) : undefined;
25
+ if (!definitionId) {
26
+ return null;
27
+ }
28
+ return {
29
+ raw,
30
+ label: formatAgentSelector({
31
+ definitionId,
32
+ nodeQualifier,
33
+ workspaceQualifier,
34
+ }),
35
+ definitionId,
36
+ ...(nodeQualifier ? { nodeQualifier } : {}),
37
+ ...(workspaceQualifier ? { workspaceQualifier } : {}),
38
+ };
39
+ }
40
+ export function formatAgentSelector(input, options = {}) {
41
+ const definitionId = normalizeAgentSelectorSegment(input.definitionId);
42
+ if (!definitionId) {
43
+ return options.includeSigil === false ? "" : "@";
44
+ }
45
+ const nodeQualifier = input.nodeQualifier ? normalizeAgentSelectorSegment(input.nodeQualifier) : "";
46
+ const workspaceQualifier = input.workspaceQualifier ? normalizeAgentSelectorSegment(input.workspaceQualifier) : "";
47
+ const prefix = options.includeSigil === false ? "" : "@";
48
+ return [
49
+ `${prefix}${definitionId}`,
50
+ nodeQualifier ? `@${nodeQualifier}` : "",
51
+ workspaceQualifier ? `#${workspaceQualifier}` : "",
52
+ ].join("");
53
+ }
54
+ export function extractAgentSelectors(text) {
55
+ const matches = Array.from(text.matchAll(/(^|\s)@([a-z0-9._/-]+(?:@[a-z0-9._/-]+)?(?:#[a-z0-9._/-]+)?)/gi));
56
+ const selectors = new Map();
57
+ for (const match of matches) {
58
+ const candidate = parseAgentSelector(match[2] ?? "");
59
+ if (!candidate) {
60
+ continue;
61
+ }
62
+ selectors.set(candidate.label, candidate);
63
+ }
64
+ return Array.from(selectors.values());
65
+ }
66
+ function candidateAliases(candidate) {
67
+ const definitionId = normalizeAgentSelectorSegment(candidate.definitionId || candidate.agentId);
68
+ const nodeQualifier = candidate.nodeQualifier ? normalizeAgentSelectorSegment(candidate.nodeQualifier) : undefined;
69
+ const workspaceQualifier = candidate.workspaceQualifier ? normalizeAgentSelectorSegment(candidate.workspaceQualifier) : undefined;
70
+ const aliases = [
71
+ definitionId,
72
+ formatAgentSelector({ definitionId, nodeQualifier }),
73
+ formatAgentSelector({ definitionId, workspaceQualifier }),
74
+ formatAgentSelector({ definitionId, nodeQualifier, workspaceQualifier }),
75
+ ...((candidate.aliases ?? []).map((alias) => alias.trim()).filter(Boolean)),
76
+ ];
77
+ return Array.from(new Set(aliases.map((alias) => trimSelectorPrefix(alias)).filter(Boolean)));
78
+ }
79
+ export function agentSelectorMatches(selector, candidate) {
80
+ const definitionId = normalizeAgentSelectorSegment(candidate.definitionId || candidate.agentId);
81
+ if (selector.definitionId !== definitionId) {
82
+ return false;
83
+ }
84
+ const nodeQualifier = candidate.nodeQualifier ? normalizeAgentSelectorSegment(candidate.nodeQualifier) : undefined;
85
+ if (selector.nodeQualifier && selector.nodeQualifier !== nodeQualifier) {
86
+ return candidateAliases(candidate).includes(trimSelectorPrefix(selector.label));
87
+ }
88
+ const workspaceQualifier = candidate.workspaceQualifier ? normalizeAgentSelectorSegment(candidate.workspaceQualifier) : undefined;
89
+ if (selector.workspaceQualifier && selector.workspaceQualifier !== workspaceQualifier) {
90
+ return candidateAliases(candidate).includes(trimSelectorPrefix(selector.label));
91
+ }
92
+ return true;
93
+ }
94
+ export function resolveAgentSelector(selector, candidates) {
95
+ const matches = candidates.filter((candidate) => agentSelectorMatches(selector, candidate));
96
+ if (matches.length === 1) {
97
+ return matches[0];
98
+ }
99
+ if (!selector.nodeQualifier && !selector.workspaceQualifier) {
100
+ return matches.find((candidate) => normalizeAgentSelectorSegment(candidate.agentId) === selector.definitionId) ?? matches[0] ?? null;
101
+ }
102
+ return null;
103
+ }
@@ -0,0 +1,85 @@
1
+ import type { MetadataMap, ScoutId } from "./common.js";
2
+ export type CollaborationKind = "question" | "work_item";
3
+ export type CollaborationPriority = "low" | "normal" | "high" | "urgent";
4
+ export type CollaborationAcceptanceState = "none" | "pending" | "accepted" | "reopened";
5
+ export type QuestionState = "open" | "answered" | "closed" | "declined";
6
+ export type WorkItemState = "open" | "working" | "waiting" | "review" | "done" | "cancelled";
7
+ export type CollaborationRelationKind = "blocks" | "spawns" | "relates_to" | "references";
8
+ export interface CollaborationRelation {
9
+ kind: CollaborationRelationKind;
10
+ targetId: ScoutId;
11
+ metadata?: MetadataMap;
12
+ }
13
+ export interface CollaborationWaitingOn {
14
+ kind: "actor" | "question" | "work_item" | "approval" | "artifact" | "condition";
15
+ label: string;
16
+ targetId?: ScoutId;
17
+ metadata?: MetadataMap;
18
+ }
19
+ export interface CollaborationProgress {
20
+ completedSteps?: number;
21
+ totalSteps?: number;
22
+ checkpoint?: string;
23
+ summary?: string;
24
+ percent?: number;
25
+ }
26
+ export interface CollaborationRecordBase {
27
+ id: ScoutId;
28
+ kind: CollaborationKind;
29
+ title: string;
30
+ summary?: string;
31
+ createdById: ScoutId;
32
+ ownerId?: ScoutId;
33
+ nextMoveOwnerId?: ScoutId;
34
+ conversationId?: ScoutId;
35
+ parentId?: ScoutId;
36
+ priority?: CollaborationPriority;
37
+ labels?: string[];
38
+ relations?: CollaborationRelation[];
39
+ createdAt: number;
40
+ updatedAt: number;
41
+ metadata?: MetadataMap;
42
+ }
43
+ export interface QuestionRecord extends CollaborationRecordBase {
44
+ kind: "question";
45
+ state: QuestionState;
46
+ acceptanceState: CollaborationAcceptanceState;
47
+ askedById?: ScoutId;
48
+ askedOfId?: ScoutId;
49
+ answerMessageId?: ScoutId;
50
+ spawnedWorkItemId?: ScoutId;
51
+ closedAt?: number;
52
+ }
53
+ export interface WorkItemRecord extends CollaborationRecordBase {
54
+ kind: "work_item";
55
+ state: WorkItemState;
56
+ acceptanceState: CollaborationAcceptanceState;
57
+ requestedById?: ScoutId;
58
+ waitingOn?: CollaborationWaitingOn;
59
+ progress?: CollaborationProgress;
60
+ startedAt?: number;
61
+ reviewRequestedAt?: number;
62
+ completedAt?: number;
63
+ }
64
+ export type CollaborationRecord = QuestionRecord | WorkItemRecord;
65
+ export type CollaborationEventKind = "created" | "claimed" | "answered" | "accepted" | "reopened" | "waiting" | "progressed" | "handoff" | "review_requested" | "done" | "declined" | "cancelled";
66
+ export interface CollaborationEvent {
67
+ id: ScoutId;
68
+ recordId: ScoutId;
69
+ recordKind: CollaborationKind;
70
+ kind: CollaborationEventKind;
71
+ actorId: ScoutId;
72
+ at: number;
73
+ summary?: string;
74
+ metadata?: MetadataMap;
75
+ }
76
+ export declare function isQuestionTerminalState(state: QuestionState): boolean;
77
+ export declare function isWorkItemTerminalState(state: WorkItemState): boolean;
78
+ export declare function collaborationRequiresNextMoveOwner(record: CollaborationRecord): boolean;
79
+ export declare function collaborationRequiresOwner(record: CollaborationRecord): boolean;
80
+ export declare function collaborationRequiresWaitingOn(record: CollaborationRecord): boolean;
81
+ export declare function collaborationRequiresAcceptance(record: CollaborationRecord): boolean;
82
+ export declare function validateCollaborationRecord(record: CollaborationRecord): string[];
83
+ export declare function assertValidCollaborationRecord(record: CollaborationRecord): void;
84
+ export declare function validateCollaborationEvent(event: CollaborationEvent, record?: CollaborationRecord): string[];
85
+ export declare function assertValidCollaborationEvent(event: CollaborationEvent, record?: CollaborationRecord): void;
@@ -0,0 +1,113 @@
1
+ export function isQuestionTerminalState(state) {
2
+ return state === "closed" || state === "declined";
3
+ }
4
+ export function isWorkItemTerminalState(state) {
5
+ return state === "done" || state === "cancelled";
6
+ }
7
+ export function collaborationRequiresNextMoveOwner(record) {
8
+ if (record.kind === "question") {
9
+ return !isQuestionTerminalState(record.state);
10
+ }
11
+ return !isWorkItemTerminalState(record.state);
12
+ }
13
+ export function collaborationRequiresOwner(record) {
14
+ return record.kind === "work_item" && !isWorkItemTerminalState(record.state);
15
+ }
16
+ export function collaborationRequiresWaitingOn(record) {
17
+ return record.kind === "work_item" && record.state === "waiting";
18
+ }
19
+ export function collaborationRequiresAcceptance(record) {
20
+ if (record.acceptanceState === "none") {
21
+ return false;
22
+ }
23
+ if (record.kind === "question") {
24
+ return Boolean(record.askedById && record.askedOfId);
25
+ }
26
+ return Boolean(record.requestedById);
27
+ }
28
+ export function validateCollaborationRecord(record) {
29
+ const errors = [];
30
+ if (!record.id.trim()) {
31
+ errors.push("collaboration record id is required");
32
+ }
33
+ if (!record.title.trim()) {
34
+ errors.push("collaboration title is required");
35
+ }
36
+ if (!record.createdById.trim()) {
37
+ errors.push("createdById is required");
38
+ }
39
+ if (record.parentId && record.parentId === record.id) {
40
+ errors.push("parentId cannot reference the record itself");
41
+ }
42
+ if (record.createdAt > record.updatedAt) {
43
+ errors.push("updatedAt must be greater than or equal to createdAt");
44
+ }
45
+ if (collaborationRequiresOwner(record) && !record.ownerId) {
46
+ errors.push("non-terminal work items require ownerId");
47
+ }
48
+ if (collaborationRequiresNextMoveOwner(record) && !record.nextMoveOwnerId) {
49
+ errors.push("non-terminal collaboration records require nextMoveOwnerId");
50
+ }
51
+ if (record.kind === "work_item" && collaborationRequiresWaitingOn(record) && !record.waitingOn) {
52
+ errors.push("waiting work items require waitingOn");
53
+ }
54
+ if (record.kind === "question") {
55
+ if (record.spawnedWorkItemId && record.spawnedWorkItemId === record.id) {
56
+ errors.push("question spawnedWorkItemId cannot reference the question itself");
57
+ }
58
+ }
59
+ else if (record.waitingOn?.targetId && record.waitingOn.targetId === record.id) {
60
+ errors.push("waitingOn.targetId cannot reference the work item itself");
61
+ }
62
+ if (record.acceptanceState !== "none" && !collaborationRequiresAcceptance(record)) {
63
+ errors.push("acceptanceState requires the corresponding requester and reviewer identities");
64
+ }
65
+ return errors;
66
+ }
67
+ export function assertValidCollaborationRecord(record) {
68
+ const errors = validateCollaborationRecord(record);
69
+ if (errors.length > 0) {
70
+ throw new Error(errors.join("; "));
71
+ }
72
+ }
73
+ export function validateCollaborationEvent(event, record) {
74
+ const errors = [];
75
+ if (!event.id.trim()) {
76
+ errors.push("collaboration event id is required");
77
+ }
78
+ if (!event.recordId.trim()) {
79
+ errors.push("collaboration event recordId is required");
80
+ }
81
+ if (!event.actorId.trim()) {
82
+ errors.push("collaboration event actorId is required");
83
+ }
84
+ if (record) {
85
+ if (record.id !== event.recordId) {
86
+ errors.push("collaboration event recordId does not match the target record");
87
+ }
88
+ if (record.kind !== event.recordKind) {
89
+ errors.push("collaboration event recordKind does not match the target record");
90
+ }
91
+ }
92
+ if (event.kind === "answered" && event.recordKind !== "question") {
93
+ errors.push("answered events only apply to questions");
94
+ }
95
+ if (event.kind === "declined" && event.recordKind !== "question") {
96
+ errors.push("declined events only apply to questions");
97
+ }
98
+ if ((event.kind === "waiting"
99
+ || event.kind === "progressed"
100
+ || event.kind === "review_requested"
101
+ || event.kind === "done"
102
+ || event.kind === "cancelled")
103
+ && event.recordKind !== "work_item") {
104
+ errors.push(`${event.kind} events only apply to work items`);
105
+ }
106
+ return errors;
107
+ }
108
+ export function assertValidCollaborationEvent(event, record) {
109
+ const errors = validateCollaborationEvent(event, record);
110
+ if (errors.length > 0) {
111
+ throw new Error(errors.join("; "));
112
+ }
113
+ }
@@ -0,0 +1,14 @@
1
+ export type ScoutId = string;
2
+ export type ActorKind = "person" | "helper" | "agent" | "system" | "bridge" | "device";
3
+ export type AgentState = "offline" | "idle" | "active" | "waiting" | "degraded";
4
+ export type VisibilityScope = "private" | "workspace" | "public" | "system";
5
+ export type DeliveryStatus = "pending" | "leased" | "sent" | "acknowledged" | "failed" | "cancelled";
6
+ export type DeliveryPolicy = "best_effort" | "must_ack" | "durable" | "ephemeral";
7
+ export type DeliveryTargetKind = "participant" | "agent" | "bridge" | "device" | "voice_session" | "webhook";
8
+ export type DeliveryTransport = "local_socket" | "websocket" | "peer_broker" | "http" | "webhook" | "telegram" | "discord" | "sms" | "email" | "tts" | "native_voice" | "claude_stream_json" | "codex_app_server" | "codex_exec" | "claude_resume" | "tmux";
9
+ export type DeliveryReason = "conversation_visibility" | "direct_message" | "mention" | "thread_reply" | "invocation" | "bridge_outbound" | "speech";
10
+ export type AdvertiseScope = "local" | "mesh";
11
+ export type ShareMode = "local" | "summary" | "shared";
12
+ export interface MetadataMap {
13
+ [key: string]: unknown;
14
+ }
package/dist/common.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import type { MetadataMap, ScoutId, ShareMode, VisibilityScope } from "./common.js";
2
+ export type ConversationKind = "channel" | "direct" | "group_direct" | "thread" | "system";
3
+ export interface ConversationDefinition {
4
+ id: ScoutId;
5
+ kind: ConversationKind;
6
+ title: string;
7
+ visibility: VisibilityScope;
8
+ shareMode: ShareMode;
9
+ authorityNodeId: ScoutId;
10
+ participantIds: ScoutId[];
11
+ topic?: string;
12
+ parentConversationId?: ScoutId;
13
+ messageId?: ScoutId;
14
+ metadata?: MetadataMap;
15
+ }
16
+ export interface ConversationBinding {
17
+ id: ScoutId;
18
+ conversationId: ScoutId;
19
+ platform: string;
20
+ mode: "inbound" | "outbound" | "bidirectional";
21
+ externalChannelId: string;
22
+ externalThreadId?: string;
23
+ metadata?: MetadataMap;
24
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import type { DeliveryPolicy, DeliveryReason, DeliveryStatus, DeliveryTargetKind, DeliveryTransport, MetadataMap, ScoutId } from "./common.js";
2
+ export interface DeliveryTarget {
3
+ id: ScoutId;
4
+ kind: DeliveryTargetKind;
5
+ transport: DeliveryTransport;
6
+ address?: string;
7
+ bindingId?: ScoutId;
8
+ metadata?: MetadataMap;
9
+ }
10
+ export interface DeliveryIntent {
11
+ id: ScoutId;
12
+ messageId?: ScoutId;
13
+ invocationId?: ScoutId;
14
+ targetId: ScoutId;
15
+ targetNodeId?: ScoutId;
16
+ targetKind: DeliveryTargetKind;
17
+ transport: DeliveryTransport;
18
+ reason: DeliveryReason;
19
+ policy: DeliveryPolicy;
20
+ status: DeliveryStatus;
21
+ bindingId?: ScoutId;
22
+ leaseOwner?: string;
23
+ leaseExpiresAt?: number;
24
+ metadata?: MetadataMap;
25
+ }
26
+ export interface DeliveryAttempt {
27
+ id: ScoutId;
28
+ deliveryId: ScoutId;
29
+ attempt: number;
30
+ status: "sent" | "acknowledged" | "failed";
31
+ error?: string;
32
+ externalRef?: string;
33
+ createdAt: number;
34
+ metadata?: MetadataMap;
35
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,56 @@
1
+ import type { ScoutId } from "./common.js";
2
+ import type { NodeDefinition } from "./mesh.js";
3
+ import type { ConversationBinding, ConversationDefinition } from "./conversations.js";
4
+ import type { DeliveryAttempt, DeliveryIntent } from "./deliveries.js";
5
+ import type { FlightRecord, InvocationRequest } from "./invocations.js";
6
+ import type { MessageRecord } from "./messages.js";
7
+ import type { AgentDefinition, AgentEndpoint, ActorIdentity } from "./actors.js";
8
+ import type { CollaborationEvent, CollaborationRecord } from "./collaboration.js";
9
+ export interface ControlEventBase<K extends string, P> {
10
+ id: ScoutId;
11
+ kind: K;
12
+ ts: number;
13
+ actorId: ScoutId;
14
+ nodeId?: ScoutId;
15
+ payload: P;
16
+ }
17
+ export type NodeUpsertedEvent = ControlEventBase<"node.upserted", {
18
+ node: NodeDefinition;
19
+ }>;
20
+ export type ActorRegisteredEvent = ControlEventBase<"actor.registered", {
21
+ actor: ActorIdentity;
22
+ }>;
23
+ export type AgentRegisteredEvent = ControlEventBase<"agent.registered", {
24
+ agent: AgentDefinition;
25
+ }>;
26
+ export type AgentEndpointUpsertedEvent = ControlEventBase<"agent.endpoint.upserted", {
27
+ endpoint: AgentEndpoint;
28
+ }>;
29
+ export type ConversationUpsertedEvent = ControlEventBase<"conversation.upserted", {
30
+ conversation: ConversationDefinition;
31
+ }>;
32
+ export type BindingUpsertedEvent = ControlEventBase<"binding.upserted", {
33
+ binding: ConversationBinding;
34
+ }>;
35
+ export type MessagePostedEvent = ControlEventBase<"message.posted", {
36
+ message: MessageRecord;
37
+ }>;
38
+ export type InvocationRequestedEvent = ControlEventBase<"invocation.requested", {
39
+ invocation: InvocationRequest;
40
+ }>;
41
+ export type FlightUpdatedEvent = ControlEventBase<"flight.updated", {
42
+ flight: FlightRecord;
43
+ }>;
44
+ export type DeliveryPlannedEvent = ControlEventBase<"delivery.planned", {
45
+ delivery: DeliveryIntent;
46
+ }>;
47
+ export type DeliveryAttemptedEvent = ControlEventBase<"delivery.attempted", {
48
+ attempt: DeliveryAttempt;
49
+ }>;
50
+ export type CollaborationUpsertedEvent = ControlEventBase<"collaboration.upserted", {
51
+ record: CollaborationRecord;
52
+ }>;
53
+ export type CollaborationEventAppendedEvent = ControlEventBase<"collaboration.event.appended", {
54
+ event: CollaborationEvent;
55
+ }>;
56
+ export type ControlEvent = NodeUpsertedEvent | ActorRegisteredEvent | AgentRegisteredEvent | AgentEndpointUpsertedEvent | ConversationUpsertedEvent | BindingUpsertedEvent | MessagePostedEvent | InvocationRequestedEvent | FlightUpdatedEvent | DeliveryPlannedEvent | DeliveryAttemptedEvent | CollaborationUpsertedEvent | CollaborationEventAppendedEvent;
package/dist/events.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ export * from "./common.js";
2
+ export * from "./actors.js";
3
+ export * from "./agent-selectors.js";
4
+ export * from "./mesh.js";
5
+ export * from "./conversations.js";
6
+ export * from "./collaboration.js";
7
+ export * from "./messages.js";
8
+ export * from "./invocations.js";
9
+ export * from "./deliveries.js";
10
+ export * from "./transports.js";
11
+ export * from "./events.js";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ export * from "./common.js";
2
+ export * from "./actors.js";
3
+ export * from "./agent-selectors.js";
4
+ export * from "./mesh.js";
5
+ export * from "./conversations.js";
6
+ export * from "./collaboration.js";
7
+ export * from "./messages.js";
8
+ export * from "./invocations.js";
9
+ export * from "./deliveries.js";
10
+ export * from "./transports.js";
11
+ export * from "./events.js";
@@ -0,0 +1,38 @@
1
+ import type { AgentHarness } from "./actors.js";
2
+ import type { MetadataMap, ScoutId } from "./common.js";
3
+ export type InvocationAction = "consult" | "execute" | "summarize" | "status" | "wake";
4
+ export type FlightState = "queued" | "waking" | "running" | "waiting" | "completed" | "failed" | "cancelled";
5
+ export interface InvocationExecutionPreference {
6
+ harness?: AgentHarness;
7
+ }
8
+ export interface InvocationRequest {
9
+ id: ScoutId;
10
+ requesterId: ScoutId;
11
+ requesterNodeId: ScoutId;
12
+ targetAgentId: ScoutId;
13
+ targetNodeId?: ScoutId;
14
+ action: InvocationAction;
15
+ task: string;
16
+ conversationId?: ScoutId;
17
+ messageId?: ScoutId;
18
+ context?: MetadataMap;
19
+ execution?: InvocationExecutionPreference;
20
+ ensureAwake: boolean;
21
+ stream: boolean;
22
+ timeoutMs?: number;
23
+ createdAt: number;
24
+ metadata?: MetadataMap;
25
+ }
26
+ export interface FlightRecord {
27
+ id: ScoutId;
28
+ invocationId: ScoutId;
29
+ requesterId: ScoutId;
30
+ targetAgentId: ScoutId;
31
+ state: FlightState;
32
+ summary?: string;
33
+ output?: string;
34
+ error?: string;
35
+ startedAt?: number;
36
+ completedAt?: number;
37
+ metadata?: MetadataMap;
38
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/mesh.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { AdvertiseScope, MetadataMap, ScoutId } from "./common.js";
2
+ export interface NodeDefinition {
3
+ id: ScoutId;
4
+ meshId: ScoutId;
5
+ name: string;
6
+ hostName?: string;
7
+ advertiseScope: AdvertiseScope;
8
+ brokerUrl?: string;
9
+ tailnetName?: string;
10
+ capabilities?: string[];
11
+ labels?: string[];
12
+ metadata?: MetadataMap;
13
+ lastSeenAt?: number;
14
+ registeredAt: number;
15
+ }
package/dist/mesh.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import type { DeliveryPolicy, DeliveryReason, MetadataMap, ScoutId, VisibilityScope } from "./common.js";
2
+ export type MessageClass = "agent" | "log" | "system" | "status" | "artifact";
3
+ export interface MessageSpeechDirective {
4
+ text: string;
5
+ voice?: string;
6
+ interruptible?: boolean;
7
+ }
8
+ export interface MessageAttachment {
9
+ id: ScoutId;
10
+ mediaType: string;
11
+ fileName?: string;
12
+ blobKey?: string;
13
+ url?: string;
14
+ metadata?: MetadataMap;
15
+ }
16
+ export interface MessageMention {
17
+ actorId: ScoutId;
18
+ label?: string;
19
+ }
20
+ export interface MessageAudience {
21
+ visibleTo?: ScoutId[];
22
+ notify?: ScoutId[];
23
+ invoke?: ScoutId[];
24
+ reason?: DeliveryReason;
25
+ }
26
+ export interface MessageRecord {
27
+ id: ScoutId;
28
+ conversationId: ScoutId;
29
+ actorId: ScoutId;
30
+ originNodeId: ScoutId;
31
+ class: MessageClass;
32
+ body: string;
33
+ replyToMessageId?: ScoutId;
34
+ threadConversationId?: ScoutId;
35
+ mentions?: MessageMention[];
36
+ attachments?: MessageAttachment[];
37
+ speech?: MessageSpeechDirective;
38
+ audience?: MessageAudience;
39
+ visibility: VisibilityScope;
40
+ policy: DeliveryPolicy;
41
+ createdAt: number;
42
+ metadata?: MetadataMap;
43
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,66 @@
1
+ import type { DeliveryTransport, MetadataMap, ScoutId } from "./common.js";
2
+ import type { AgentDefinition, AgentEndpoint, ActorIdentity } from "./actors.js";
3
+ import type { ConversationBinding, ConversationDefinition } from "./conversations.js";
4
+ import type { InvocationRequest } from "./invocations.js";
5
+ import type { NodeDefinition } from "./mesh.js";
6
+ import type { MessageRecord } from "./messages.js";
7
+ import type { CollaborationEvent, CollaborationRecord } from "./collaboration.js";
8
+ export interface SubscriptionRequest {
9
+ actorId: ScoutId;
10
+ conversationIds?: ScoutId[];
11
+ flightIds?: ScoutId[];
12
+ eventKinds?: string[];
13
+ }
14
+ export interface PostMessageCommand {
15
+ kind: "conversation.post";
16
+ message: MessageRecord;
17
+ }
18
+ export interface InvokeAgentCommand {
19
+ kind: "agent.invoke";
20
+ invocation: InvocationRequest;
21
+ }
22
+ export interface EnsureAwakeCommand {
23
+ kind: "agent.ensure_awake";
24
+ agentId: ScoutId;
25
+ requesterId: ScoutId;
26
+ reason: string;
27
+ metadata?: MetadataMap;
28
+ }
29
+ export interface SubscribeCommand {
30
+ kind: "stream.subscribe";
31
+ subscription: SubscriptionRequest;
32
+ transport: Extract<DeliveryTransport, "local_socket" | "websocket">;
33
+ }
34
+ export interface NodeUpsertCommand {
35
+ kind: "node.upsert";
36
+ node: NodeDefinition;
37
+ }
38
+ export interface ActorUpsertCommand {
39
+ kind: "actor.upsert";
40
+ actor: ActorIdentity;
41
+ }
42
+ export interface AgentUpsertCommand {
43
+ kind: "agent.upsert";
44
+ agent: AgentDefinition;
45
+ }
46
+ export interface AgentEndpointUpsertCommand {
47
+ kind: "agent.endpoint.upsert";
48
+ endpoint: AgentEndpoint;
49
+ }
50
+ export interface ConversationUpsertCommand {
51
+ kind: "conversation.upsert";
52
+ conversation: ConversationDefinition;
53
+ }
54
+ export interface BindingUpsertCommand {
55
+ kind: "binding.upsert";
56
+ binding: ConversationBinding;
57
+ }
58
+ export interface CollaborationUpsertCommand {
59
+ kind: "collaboration.upsert";
60
+ record: CollaborationRecord;
61
+ }
62
+ export interface CollaborationEventAppendCommand {
63
+ kind: "collaboration.event.append";
64
+ event: CollaborationEvent;
65
+ }
66
+ export type ControlCommand = NodeUpsertCommand | ActorUpsertCommand | AgentUpsertCommand | AgentEndpointUpsertCommand | ConversationUpsertCommand | BindingUpsertCommand | CollaborationUpsertCommand | CollaborationEventAppendCommand | PostMessageCommand | InvokeAgentCommand | EnsureAwakeCommand | SubscribeCommand;
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@openscout/protocol",
3
+ "version": "0.1.0",
4
+ "description": "Typed protocol for the OpenScout local control plane",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "bun": "./src/index.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "types": "./src/index.ts",
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "rm -rf dist && tsc -p tsconfig.json",
20
+ "check": "tsc --noEmit -p tsconfig.json"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "devDependencies": {
26
+ "typescript": "^5"
27
+ }
28
+ }