@openscout/protocol 0.2.108 → 0.2.110

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 (101) hide show
  1. package/dist/actors.d.ts +1 -1
  2. package/dist/actors.d.ts.map +1 -1
  3. package/dist/agent-notifications.d.ts +137 -0
  4. package/dist/agent-notifications.d.ts.map +1 -0
  5. package/dist/agent-notifications.js +148 -0
  6. package/dist/channel-invites.d.ts +3 -1
  7. package/dist/channel-invites.d.ts.map +1 -1
  8. package/dist/channel-membership.d.ts +10 -0
  9. package/dist/channel-membership.d.ts.map +1 -0
  10. package/dist/channel-membership.js +16 -0
  11. package/dist/chat-mentions.d.ts +4 -0
  12. package/dist/chat-mentions.d.ts.map +1 -0
  13. package/dist/chat-mentions.js +11 -0
  14. package/dist/chat-message-corrections.d.ts +28 -0
  15. package/dist/chat-message-corrections.d.ts.map +1 -0
  16. package/dist/chat-message-corrections.js +46 -0
  17. package/dist/chat-pins.d.ts +14 -0
  18. package/dist/chat-pins.d.ts.map +1 -0
  19. package/dist/chat-pins.js +37 -0
  20. package/dist/common.d.ts +1 -1
  21. package/dist/common.d.ts.map +1 -1
  22. package/dist/events.d.ts +4 -1
  23. package/dist/events.d.ts.map +1 -1
  24. package/dist/herdr-workspace-digest.d.ts +8 -4
  25. package/dist/herdr-workspace-digest.d.ts.map +1 -1
  26. package/dist/herdr-workspace-digest.js +18 -3
  27. package/dist/host-integrations.js +6 -6
  28. package/dist/index.d.ts +6 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +6 -0
  31. package/dist/invocations.d.ts +17 -2
  32. package/dist/invocations.d.ts.map +1 -1
  33. package/dist/invocations.js +1 -1
  34. package/dist/mcp-core-tools.d.ts +137 -0
  35. package/dist/mcp-core-tools.d.ts.map +1 -0
  36. package/dist/mcp-core-tools.js +27 -0
  37. package/dist/message-reactions.d.ts +5 -0
  38. package/dist/message-reactions.d.ts.map +1 -1
  39. package/dist/message-reactions.js +10 -1
  40. package/dist/orchestration-provider-map.d.ts.map +1 -1
  41. package/dist/orchestration-provider-map.js +9 -1
  42. package/dist/read-receipts.d.ts +38 -0
  43. package/dist/read-receipts.d.ts.map +1 -1
  44. package/dist/read-receipts.js +43 -1
  45. package/dist/runtime-catalog-contract.d.ts +14 -0
  46. package/dist/runtime-catalog-contract.d.ts.map +1 -1
  47. package/dist/runtime-catalog.d.ts.map +1 -1
  48. package/dist/runtime-catalog.generated.d.ts +88 -2
  49. package/dist/runtime-catalog.generated.d.ts.map +1 -1
  50. package/dist/runtime-catalog.generated.js +114 -12
  51. package/dist/runtime-catalog.js +28 -0
  52. package/dist/runtime-execution.d.ts +33 -2
  53. package/dist/runtime-execution.d.ts.map +1 -1
  54. package/dist/runtime-execution.js +34 -17
  55. package/dist/scout-agent-card.d.ts +2 -0
  56. package/dist/scout-agent-card.d.ts.map +1 -1
  57. package/dist/scout-agent-card.js +3 -0
  58. package/dist/scout-composer.d.ts.map +1 -1
  59. package/dist/scout-composer.js +23 -0
  60. package/dist/scout-delivery.d.ts +3 -1
  61. package/dist/scout-delivery.d.ts.map +1 -1
  62. package/dist/scout-dispatch.d.ts +13 -0
  63. package/dist/scout-dispatch.d.ts.map +1 -1
  64. package/dist/session-handle.d.ts +37 -0
  65. package/dist/session-handle.d.ts.map +1 -1
  66. package/dist/session-handle.js +58 -0
  67. package/dist/terminal-host-topology.d.ts +5 -2
  68. package/dist/terminal-host-topology.d.ts.map +1 -1
  69. package/dist/terminal-host-topology.js +2 -1
  70. package/dist/transports.d.ts +8 -1
  71. package/dist/transports.d.ts.map +1 -1
  72. package/package.json +4 -4
  73. package/src/actors.ts +2 -0
  74. package/src/agent-notifications.ts +241 -0
  75. package/src/channel-invites.ts +4 -1
  76. package/src/channel-membership.ts +22 -0
  77. package/src/chat-mentions.ts +11 -0
  78. package/src/chat-message-corrections.ts +40 -0
  79. package/src/chat-pins.ts +40 -0
  80. package/src/common.ts +2 -0
  81. package/src/events.ts +3 -0
  82. package/src/herdr-workspace-digest.ts +23 -6
  83. package/src/host-integrations.ts +6 -6
  84. package/src/index.ts +10 -0
  85. package/src/invocations.ts +18 -3
  86. package/src/mcp-core-tools.ts +46 -0
  87. package/src/message-reactions.ts +16 -2
  88. package/src/orchestration-provider-map.ts +12 -1
  89. package/src/read-receipts.ts +71 -0
  90. package/src/runtime-catalog-contract.ts +16 -0
  91. package/src/runtime-catalog.generated.ts +114 -12
  92. package/src/runtime-catalog.ts +32 -0
  93. package/src/runtime-catalog.v1.json +20 -13
  94. package/src/runtime-execution.ts +70 -16
  95. package/src/scout-agent-card.ts +5 -0
  96. package/src/scout-composer.ts +26 -0
  97. package/src/scout-delivery.ts +3 -1
  98. package/src/scout-dispatch.ts +13 -0
  99. package/src/session-handle.ts +88 -0
  100. package/src/terminal-host-topology.ts +5 -2
  101. package/src/transports.ts +9 -0
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Agent notifications: what an agent's notification IS, so the phone can pick
3
+ * the view that fits it.
4
+ *
5
+ * Every notification declares a `view` and the typed fields that view needs.
6
+ * The same record renders in three places: the lock screen (text only, via
7
+ * {@link collapseAgentNotification}), the long-press view, and the app's
8
+ * Notifications row. A client that doesn't know a `view` renders `text`.
9
+ *
10
+ * Everything that wants the operator is a question of some kind; the prefix
11
+ * says WHEN it was asked:
12
+ * - `turn.*` is mid-turn: the turn is paused until the operator answers.
13
+ * - `ask` is outside a turn: the agent finished and reached out on its own.
14
+ * Only declared things become notifications. Inferred attention never does.
15
+ *
16
+ * The record travels sealed (see the runtime's mobile-push sealing): the relay
17
+ * that delivers the push never reads any of it.
18
+ */
19
+
20
+ export const AGENT_NOTIFICATION_VERSION = 1 as const;
21
+
22
+ export type AgentNotificationRisk = "low" | "medium" | "high";
23
+
24
+ export type AgentNotificationFileChange = {
25
+ path: string;
26
+ added: number;
27
+ removed: number;
28
+ };
29
+
30
+ export type AgentNotificationArtifact = {
31
+ kind: "pr" | "build" | "file" | "link";
32
+ label: string;
33
+ meta?: string;
34
+ url?: string;
35
+ };
36
+
37
+ export type AgentNotificationView =
38
+ | { view: "turn.approve.command"; command: string; cwd?: string; risk: AgentNotificationRisk; why?: string }
39
+ | { view: "turn.approve.edit"; summary?: string; files: AgentNotificationFileChange[]; risk: AgentNotificationRisk }
40
+ | { view: "turn.approve.tool"; tool: string; summary?: string; risk: AgentNotificationRisk }
41
+ | { view: "turn.question.choice"; question: string; context?: string; options: string[]; multiSelect?: boolean }
42
+ | { view: "turn.question.open"; question: string; context?: string }
43
+ | { view: "turn.failed"; error: string; excerpt?: string[]; elapsed?: string }
44
+ | { view: "work.done"; summary: string; artifacts?: AgentNotificationArtifact[] }
45
+ | { view: "ask"; note: string; options?: string[] }
46
+ | { view: "text"; title: string; body?: string };
47
+
48
+ export type AgentNotificationViewName = AgentNotificationView["view"];
49
+
50
+ export type AgentNotificationAction =
51
+ | "approve"
52
+ | "deny"
53
+ | "reply"
54
+ | "open_chat"
55
+ | "open_link";
56
+
57
+ export type AgentNotification = AgentNotificationView & {
58
+ v: typeof AGENT_NOTIFICATION_VERSION;
59
+ /** Stable id of the thing that wants attention (inbox item, message). */
60
+ itemId: string;
61
+ /** The agent, as the operator knows it. */
62
+ sender: { name: string; agentId?: string };
63
+ project?: string;
64
+ /** The Mac it happened on. */
65
+ host?: string;
66
+ /** Declared and still open: Time Sensitive on the lock screen, Priority in the app. */
67
+ urgent: boolean;
68
+ createdAt: number;
69
+ /** Routing back into the app. */
70
+ route?: {
71
+ sessionId?: string;
72
+ turnId?: string;
73
+ blockId?: string;
74
+ conversationId?: string;
75
+ messageId?: string;
76
+ };
77
+ };
78
+
79
+ export type AgentNotificationCollapse = {
80
+ /** Lock-screen title: the agent. */
81
+ title: string;
82
+ /** Lock-screen subtitle: project · host. */
83
+ subtitle: string;
84
+ /** Lock-screen body: headline, then the most useful line that fits as text. */
85
+ body: string;
86
+ /** Row headline in the app. Same words as line 1 of the body. */
87
+ headline: string;
88
+ /** iOS notification category, which carries the actions. */
89
+ category: string;
90
+ /** One thread per agent, so stacks group by who sent them. */
91
+ threadId: string;
92
+ };
93
+
94
+ /**
95
+ * What travels sealed in a push: the record plus its lock-screen text,
96
+ * computed once here so the phone's extension applies it verbatim and no
97
+ * client re-derives the words.
98
+ */
99
+ export type SealedAgentNotification = AgentNotification & {
100
+ lockScreen: Pick<AgentNotificationCollapse, "title" | "subtitle" | "body" | "category" | "threadId">;
101
+ };
102
+
103
+ export function sealedAgentNotification(n: AgentNotification): SealedAgentNotification {
104
+ const { title, subtitle, body, category, threadId } = collapseAgentNotification(n);
105
+ return { ...n, lockScreen: { title, subtitle, body, category, threadId } };
106
+ }
107
+
108
+ function riskLabel(risk: AgentNotificationRisk): string {
109
+ return risk === "high" ? "High risk" : risk === "medium" ? "Medium risk" : "Low risk";
110
+ }
111
+
112
+ function plural(count: number, one: string, many: string): string {
113
+ return `${count} ${count === 1 ? one : many}`;
114
+ }
115
+
116
+ export function agentNotificationHeadline(n: AgentNotification): string {
117
+ switch (n.view) {
118
+ case "turn.approve.command": return "Wants to run a command";
119
+ case "turn.approve.edit": return `Wants to edit ${plural(n.files.length, "file", "files")}`;
120
+ case "turn.approve.tool": return `Wants to use ${n.tool}`;
121
+ case "turn.question.choice": return n.question;
122
+ case "turn.question.open": return n.question;
123
+ case "turn.failed": return `Turn failed: ${n.error}`;
124
+ case "work.done": return `Done: ${n.summary}`;
125
+ case "ask": return "Asked for you";
126
+ case "text": return n.title;
127
+ }
128
+ }
129
+
130
+ function detailLine(n: AgentNotification): string {
131
+ switch (n.view) {
132
+ case "turn.approve.command": return `${n.command} · ${riskLabel(n.risk)}`;
133
+ case "turn.approve.edit": {
134
+ const added = n.files.reduce((sum, f) => sum + f.added, 0);
135
+ const removed = n.files.reduce((sum, f) => sum + f.removed, 0);
136
+ const counts = `+${added} −${removed}`;
137
+ return n.summary ? `${counts} · ${n.summary}` : `${counts} · ${n.files.map((f) => f.path).join(", ")}`;
138
+ }
139
+ case "turn.approve.tool": return n.summary ? `${n.summary} · ${riskLabel(n.risk)}` : riskLabel(n.risk);
140
+ case "turn.question.choice": return n.options.join(" · ");
141
+ case "turn.question.open": return n.context ?? "";
142
+ case "turn.failed": return n.elapsed ? `After ${n.elapsed}. The session is still open.` : "The session is still open.";
143
+ case "work.done": return (n.artifacts ?? []).map((a) => a.meta ? `${a.label} ${a.meta}` : a.label).join(" · ");
144
+ case "ask": return n.note;
145
+ case "text": return n.body ?? "";
146
+ }
147
+ }
148
+
149
+ /** The actions a view offers, in order. The first is the primary one. */
150
+ export function agentNotificationActions(n: AgentNotification): AgentNotificationAction[] {
151
+ switch (n.view) {
152
+ case "turn.approve.command":
153
+ case "turn.approve.edit":
154
+ case "turn.approve.tool":
155
+ return ["approve", "deny"];
156
+ case "turn.question.choice":
157
+ case "turn.question.open":
158
+ case "ask":
159
+ return ["reply"];
160
+ case "turn.failed":
161
+ return ["open_chat"];
162
+ case "work.done":
163
+ return (n.artifacts ?? []).some((a) => a.url) ? ["open_link", "open_chat"] : ["open_chat"];
164
+ case "text":
165
+ return [];
166
+ }
167
+ }
168
+
169
+ /** iOS category ids. Registered by the app with their actions. */
170
+ export function agentNotificationCategory(n: AgentNotification): string {
171
+ switch (n.view) {
172
+ case "turn.approve.command":
173
+ case "turn.approve.edit":
174
+ case "turn.approve.tool":
175
+ return "scout.approval";
176
+ case "turn.question.choice":
177
+ case "turn.question.open":
178
+ case "ask":
179
+ return "scout.question";
180
+ case "turn.failed":
181
+ case "work.done":
182
+ return "scout.open";
183
+ case "text":
184
+ return "scout.text";
185
+ }
186
+ }
187
+
188
+ export function collapseAgentNotification(n: AgentNotification): AgentNotificationCollapse {
189
+ const headline = agentNotificationHeadline(n);
190
+ const line2 = detailLine(n).trim();
191
+ const where = [n.project, n.host].filter((part): part is string => Boolean(part && part.trim())).join(" · ");
192
+ return {
193
+ title: n.sender.name,
194
+ subtitle: where,
195
+ body: line2 && line2 !== headline ? `${headline}\n${line2}` : headline,
196
+ headline,
197
+ category: agentNotificationCategory(n),
198
+ threadId: `scout.agent.${n.sender.agentId ?? n.sender.name}`,
199
+ };
200
+ }
201
+
202
+ const MAX_TEXT = 600;
203
+
204
+ function clip(value: string, max = MAX_TEXT): string {
205
+ const trimmed = value.trim();
206
+ return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max - 1)}…`;
207
+ }
208
+
209
+ /**
210
+ * Bound every free-text field so a sealed record fits inside one APNs push
211
+ * (4 KB, shared with the alert text). Lists are capped too; the app loads the
212
+ * full item when it opens.
213
+ */
214
+ export function clampAgentNotification(n: AgentNotification): AgentNotification {
215
+ const base = {
216
+ ...n,
217
+ sender: { ...n.sender, name: clip(n.sender.name, 80) },
218
+ ...(n.project ? { project: clip(n.project, 80) } : {}),
219
+ ...(n.host ? { host: clip(n.host, 80) } : {}),
220
+ };
221
+ switch (base.view) {
222
+ case "turn.approve.command":
223
+ return { ...base, command: clip(base.command, 400), ...(base.why ? { why: clip(base.why, 300) } : {}) };
224
+ case "turn.approve.edit":
225
+ return { ...base, files: base.files.slice(0, 8).map((f) => ({ ...f, path: clip(f.path, 160) })), ...(base.summary ? { summary: clip(base.summary, 300) } : {}) };
226
+ case "turn.approve.tool":
227
+ return { ...base, tool: clip(base.tool, 80), ...(base.summary ? { summary: clip(base.summary, 300) } : {}) };
228
+ case "turn.question.choice":
229
+ return { ...base, question: clip(base.question, 400), options: base.options.slice(0, 6).map((o) => clip(o, 60)), ...(base.context ? { context: clip(base.context, 300) } : {}) };
230
+ case "turn.question.open":
231
+ return { ...base, question: clip(base.question, 400), ...(base.context ? { context: clip(base.context, 300) } : {}) };
232
+ case "turn.failed":
233
+ return { ...base, error: clip(base.error, 200), ...(base.excerpt ? { excerpt: base.excerpt.slice(-4).map((l) => clip(l, 120)) } : {}) };
234
+ case "work.done":
235
+ return { ...base, summary: clip(base.summary, 300), ...(base.artifacts ? { artifacts: base.artifacts.slice(0, 4) } : {}) };
236
+ case "ask":
237
+ return { ...base, note: clip(base.note, 500), ...(base.options ? { options: base.options.slice(0, 6).map((o) => clip(o, 60)) } : {}) };
238
+ case "text":
239
+ return { ...base, title: clip(base.title, 120), ...(base.body ? { body: clip(base.body, 400) } : {}) };
240
+ }
241
+ }
@@ -126,7 +126,8 @@ export type ChannelInviteRejectionReason =
126
126
  | "exhausted"
127
127
  | "scope_mismatch"
128
128
  | "channel_mismatch"
129
- | "missing_identity";
129
+ | "missing_identity"
130
+ | "membership_removed";
130
131
 
131
132
  export interface ChannelInviteRejection {
132
133
  reason: ChannelInviteRejectionReason;
@@ -305,6 +306,8 @@ export function findChannelInviteByTokenHash(
305
306
  * understand the offer, and nothing that could be replayed into membership.
306
307
  */
307
308
  export interface ChannelInvitePublicView {
309
+ /** Explicit where the issuer supports single-use human invitations. */
310
+ kind?: "teammate" | "agent" | "api";
308
311
  id: ScoutId;
309
312
  channelId: ScoutId;
310
313
  /** Public authorship, used to show who may revoke this invitation. */
@@ -0,0 +1,22 @@
1
+ import type { MetadataMap } from "./common.js";
2
+
3
+ /** Broker-written removals preserve history while invalidating older join links. */
4
+ export const CHANNEL_MEMBER_REMOVALS_KEY = "channelMemberRemovals";
5
+
6
+ export interface ChannelMemberRemoval {
7
+ removedAt: number;
8
+ removedByActorId: string;
9
+ blockedInviteIds: string[];
10
+ }
11
+
12
+ export function readChannelMemberRemoval(metadata: MetadataMap | undefined, actorId: string): ChannelMemberRemoval | null {
13
+ const records = metadata?.[CHANNEL_MEMBER_REMOVALS_KEY];
14
+ if (!records || typeof records !== "object" || Array.isArray(records) || !Object.hasOwn(records, actorId)) return null;
15
+ const value = (records as Record<string, unknown>)[actorId];
16
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
17
+ const entry = value as Record<string, unknown>;
18
+ if (typeof entry.removedAt !== "number" || !Number.isFinite(entry.removedAt)
19
+ || typeof entry.removedByActorId !== "string" || !Array.isArray(entry.blockedInviteIds)
20
+ || !entry.blockedInviteIds.every(id => typeof id === "string")) return null;
21
+ return { removedAt: entry.removedAt, removedByActorId: entry.removedByActorId, blockedInviteIds: entry.blockedInviteIds as string[] };
22
+ }
@@ -0,0 +1,11 @@
1
+ /** Explicit attention recipients. Text is payload and is never parsed as routing. */
2
+ export const MAX_CHAT_MENTIONS = 20;
3
+
4
+ export function parseChatMentionActorIds(value: unknown): string[] {
5
+ if (value === undefined) return [];
6
+ if (!Array.isArray(value) || value.length > MAX_CHAT_MENTIONS
7
+ || value.some(id => typeof id !== "string" || !id.trim() || id.length > 256)) {
8
+ throw new Error("Choose at most 20 valid mention recipients.");
9
+ }
10
+ return [...new Set(value.map(id => (id as string).trim()))].sort();
11
+ }
@@ -0,0 +1,40 @@
1
+ import type { MessageRecord } from "./messages.js";
2
+
3
+ export interface ChatMessageCorrection { revision: number; editedAt?: number; deletedAt?: number; changedBy: string }
4
+ export type ChatMessageChange = { expectedRevision: number; body: string } | { expectedRevision: number; deleted: true };
5
+ export class ChatMessageCorrectionError extends Error {
6
+ constructor(public readonly status: 400 | 403 | 404 | 409, message: string) { super(message); }
7
+ }
8
+ export function readChatMessageCorrection(metadata: unknown): ChatMessageCorrection | undefined {
9
+ const value = (metadata as { chatCorrection?: unknown } | null)?.chatCorrection;
10
+ if (!value || typeof value !== "object") return;
11
+ const item = value as ChatMessageCorrection;
12
+ if (!Number.isSafeInteger(item.revision) || item.revision < 1 || typeof item.changedBy !== "string") return;
13
+ return { revision: item.revision, changedBy: item.changedBy,
14
+ ...(typeof item.editedAt === "number" && Number.isFinite(item.editedAt) ? { editedAt: item.editedAt } : {}),
15
+ ...(typeof item.deletedAt === "number" && Number.isFinite(item.deletedAt) ? { deletedAt: item.deletedAt } : {}) };
16
+ }
17
+ export function parseChatMessageChange(value: unknown): ChatMessageChange {
18
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new ChatMessageCorrectionError(400, "Invalid message change.");
19
+ const input = value as Record<string, unknown>;
20
+ if (Object.keys(input).length !== 2 || !Number.isSafeInteger(input.expectedRevision) || (input.expectedRevision as number) < 0) throw new ChatMessageCorrectionError(400, "Invalid message revision.");
21
+ if (input.deleted === true) return { expectedRevision: input.expectedRevision as number, deleted: true };
22
+ if (typeof input.body !== "string" || !input.body.trim() || input.body.length > 32_000) throw new ChatMessageCorrectionError(400, "Message must contain between 1 and 32000 characters.");
23
+ return { expectedRevision: input.expectedRevision as number, body: input.body };
24
+ }
25
+
26
+ /** A correction affects the shared record, never a new delivery or invocation. */
27
+ export function correctChatMessage<T extends Pick<MessageRecord, "actorId" | "body"> & {
28
+ metadata?: MessageRecord["metadata"] | null; attachments?: unknown[]; mentions?: unknown[]; speech?: unknown;
29
+ }>(message: T, input: unknown, actorId: string, canModerate: boolean, now: number): T {
30
+ const change = parseChatMessageChange(input);
31
+ const prior = readChatMessageCorrection(message.metadata);
32
+ const deleting = "deleted" in change;
33
+ if (message.actorId !== actorId && !(deleting && canModerate)) throw new ChatMessageCorrectionError(403, "Only the author can edit this message. Hosts can also delete messages.");
34
+ if ((prior?.revision ?? 0) !== change.expectedRevision) throw new ChatMessageCorrectionError(409, "This message changed since you opened it. Review the latest version before trying again.");
35
+ if (prior?.deletedAt != null) throw new ChatMessageCorrectionError(409, "This message has already been deleted.");
36
+ const correction: ChatMessageCorrection = { ...prior, revision: (prior?.revision ?? 0) + 1, changedBy: actorId, ...(deleting ? { deletedAt: now } : { editedAt: now }) };
37
+ return { ...message, body: deleting ? "" : change.body,
38
+ ...(deleting ? { attachments: [], mentions: [], speech: undefined } : {}),
39
+ metadata: { ...message.metadata, chatCorrection: correction } };
40
+ }
@@ -0,0 +1,40 @@
1
+ /** Channel-visible references, distinct from a person's private saved messages. */
2
+ export interface ChatPin {
3
+ messageId: string;
4
+ pinnedBy: string;
5
+ pinnedAt: number;
6
+ }
7
+ export interface ChatPinChange { messageId: string; pinned: boolean }
8
+
9
+ export function readChatPins(value: unknown): ChatPin[] {
10
+ if (!Array.isArray(value)) return [];
11
+ const seen = new Set<string>();
12
+ return value.filter((item): item is ChatPin => {
13
+ if (!item || typeof item !== "object" || typeof item.messageId !== "string" || !item.messageId
14
+ || typeof item.pinnedBy !== "string" || !item.pinnedBy || typeof item.pinnedAt !== "number"
15
+ || !Number.isFinite(item.pinnedAt) || seen.has(item.messageId)) return false;
16
+ seen.add(item.messageId);
17
+ return true;
18
+ }).map(({ messageId, pinnedBy, pinnedAt }) => ({ messageId, pinnedBy, pinnedAt }));
19
+ }
20
+
21
+ export function parseChatPinChange(value: unknown): ChatPinChange {
22
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid channel pin change.");
23
+ const input = value as Record<string, unknown>;
24
+ if (Object.keys(input).length !== 2 || typeof input.messageId !== "string" || !input.messageId
25
+ || input.messageId.trim() !== input.messageId || input.messageId.length > 256 || typeof input.pinned !== "boolean") {
26
+ throw new Error("Invalid channel pin change.");
27
+ }
28
+ return { messageId: input.messageId, pinned: input.pinned };
29
+ }
30
+
31
+ export function applyChatPinChange(current: unknown, change: unknown, actorId: string, now: number): ChatPin[] {
32
+ const input = parseChatPinChange(change);
33
+ const pins = readChatPins(current);
34
+ if (!input.pinned) return pins.filter(pin => pin.messageId !== input.messageId);
35
+ // An idempotent repeat must not rewrite who originally pinned the message.
36
+ if (pins.some(pin => pin.messageId === input.messageId)) return pins;
37
+ if (pins.length >= 50) throw new Error("Channel pin limit reached. Unpin a message before adding another.");
38
+ if (!actorId || !Number.isFinite(now)) throw new Error("Invalid pin attribution.");
39
+ return [...pins, { messageId: input.messageId, pinnedBy: actorId, pinnedAt: now }];
40
+ }
package/src/common.ts CHANGED
@@ -65,6 +65,8 @@ export type DeliveryTransport =
65
65
  | "pairing_bridge"
66
66
  | "peer_broker"
67
67
  | "http"
68
+ | "mcp_poll"
69
+ | "devin_cloud_cli"
68
70
  | "webhook"
69
71
  | "telegram"
70
72
  | "discord"
package/src/events.ts CHANGED
@@ -61,6 +61,8 @@ export type MessagePostedEvent = ControlEventBase<"message.posted", {
61
61
  message: MessageRecord;
62
62
  }>;
63
63
 
64
+ export type MessageCorrectedEvent = ControlEventBase<"message.corrected", { message: MessageRecord }>;
65
+
64
66
  export type ConversationReadCursorUpdatedEvent = ControlEventBase<"conversation.read_cursor.updated", {
65
67
  cursor: ConversationReadCursor;
66
68
  }>;
@@ -122,6 +124,7 @@ export type ControlEvent =
122
124
  | ConversationUpsertedEvent
123
125
  | BindingUpsertedEvent
124
126
  | MessagePostedEvent
127
+ | MessageCorrectedEvent
125
128
  | ConversationReadCursorUpdatedEvent
126
129
  | InvocationRequestedEvent
127
130
  | FlightUpdatedEvent
@@ -29,6 +29,7 @@
29
29
  */
30
30
 
31
31
  import type {
32
+ HerdrAgentSessionRef,
32
33
  HerdrAgentStatus,
33
34
  HerdrPaneProjection,
34
35
  HerdrSessionTopology,
@@ -45,15 +46,19 @@ export type HerdrStatusCounts = Record<HerdrAgentStatus, number>;
45
46
 
46
47
  export type HerdrDigestPane = {
47
48
  /**
48
- * What `herdr agent <verb>` accepts as a target for this pane: the stable
49
- * terminal id when herdr reported one, else the pane id. Null on a persisted
50
- * projection, where neither is live — a pane you cannot address yet.
49
+ * Observed host locator: terminal id when reported, else pane id. This is
50
+ * not a Scout address or a guaranteed CLI target (Herdr versions differ).
51
+ * Read handoffs use the host session + pane id. Null on a saved projection.
51
52
  */
52
53
  target: string | null;
53
54
  paneId: string;
54
55
  workspaceId: string;
55
56
  tabId: string;
57
+ /** Operator-assigned pane name, not a Scout agent address. */
58
+ name?: string | null;
56
59
  label: string | null;
60
+ /** Herdr-reported identity, not a verified Scout session binding. */
61
+ agentSession?: HerdrAgentSessionRef | null;
57
62
  /** Detected agent label ("claude", "codex"), or null for a plain shell. */
58
63
  agent: string | null;
59
64
  status: HerdrAgentStatus;
@@ -168,7 +173,9 @@ function toDigestPane(pane: HerdrPaneProjection): HerdrDigestPane {
168
173
  paneId: pane.paneId,
169
174
  workspaceId: pane.workspaceId,
170
175
  tabId: pane.tabId,
176
+ name: pane.name ?? null,
171
177
  label: pane.label,
178
+ agentSession: pane.agentSession,
172
179
  agent: pane.agent,
173
180
  status: pane.agentStatus,
174
181
  directory: paneDirectory(pane),
@@ -262,6 +269,10 @@ export function digestHerdrTopology(
262
269
  });
263
270
  for (const pane of tab.panes) {
264
271
  const digestPane = toDigestPane(pane);
272
+ if (!topology.running) {
273
+ digestPane.target = null;
274
+ digestPane.status = "unknown";
275
+ }
265
276
  counts[digestPane.status] += 1;
266
277
  if (digestPane.agent) agentTotal += 1;
267
278
  all.push(digestPane);
@@ -372,10 +383,14 @@ export function digestHerdrTopology(
372
383
  * every row buys nothing.
373
384
  */
374
385
  function paneLine(pane: HerdrDigestPane): string {
375
- const parts = pane.label
376
- ? [pane.label, pane.agent ?? "shell", pane.paneId]
386
+ const title = pane.name ?? pane.label;
387
+ const parts = title
388
+ ? [title, pane.agent ?? "shell", pane.paneId]
377
389
  : [pane.paneId, pane.agent ?? "shell"];
390
+ if (pane.name && pane.label && pane.name !== pane.label) parts.push(pane.label);
378
391
  if (pane.directory) parts.push(directoryName(pane.directory));
392
+ parts.push(pane.status);
393
+ if (pane.agentSession) parts.push(`reported ${pane.agentSession.agent} ${pane.agentSession.kind}:${pane.agentSession.value}`);
379
394
  if (pane.focused) parts.push("focused");
380
395
  return ` ${parts.join(" ")}`;
381
396
  }
@@ -422,9 +437,10 @@ export function renderHerdrWorkspaceDigest(digest: HerdrWorkspaceDigest): string
422
437
  // scanning reader never reaches a footnote, and these are the lines that stop
423
438
  // someone believing `unknown` meant done or that a stale layout was current.
424
439
  for (const note of digest.notes) lines.push(` ${note}`);
440
+ lines.push(` Observed ${new Date(digest.observedAt).toISOString()}; pane names are host labels, not Scout addresses.`);
425
441
 
426
442
  if (digest.needsYou.length) {
427
- lines.push("", "Waiting on you");
443
+ lines.push("", "Requests");
428
444
  for (const pane of digest.needsYou) lines.push(paneLine(pane));
429
445
  }
430
446
  if (digest.working.length) {
@@ -439,6 +455,7 @@ export function renderHerdrWorkspaceDigest(digest: HerdrWorkspaceDigest): string
439
455
  const where = group.directory ? ` (${group.directory})` : "";
440
456
  const hidden = omittedLabel(group.omittedCounts);
441
457
  lines.push(` ${group.name}${where} — ${total} pane${total === 1 ? "" : "s"} · ${countsLine(group.counts)}${hidden ? ` · ${hidden}` : ""}`);
458
+ for (const pane of group.panes) lines.push(` ${paneLine(pane)}`);
442
459
  }
443
460
  }
444
461
 
@@ -38,8 +38,8 @@ export const SCOUT_HOST_INTEGRATIONS: readonly ScoutHostIntegration[] = [
38
38
  roles: ["execution_harness", "agent_host", "mcp_host"],
39
39
  harness: "claude",
40
40
  homepage: "https://claude.ai/claude-code",
41
- repositoryUrl: "https://github.com/arach/claude-scout",
42
- installHint: "/plugin marketplace add arach/claude-scout",
41
+ repositoryUrl: "https://github.com/oscout/claude-scout",
42
+ installHint: "/plugin marketplace add oscout/claude-scout",
43
43
  summary: "Claude Code can run Scout-routed work as a harness and expose Scout through a host plugin.",
44
44
  },
45
45
  {
@@ -48,8 +48,8 @@ export const SCOUT_HOST_INTEGRATIONS: readonly ScoutHostIntegration[] = [
48
48
  roles: ["execution_harness", "agent_host", "mcp_host"],
49
49
  harness: "codex",
50
50
  homepage: "https://github.com/openai/codex",
51
- repositoryUrl: "https://github.com/arach/codex-scout",
52
- installHint: "/plugin marketplace add arach/codex-scout",
51
+ repositoryUrl: "https://github.com/oscout/codex-scout",
52
+ installHint: "/plugin marketplace add oscout/codex-scout",
53
53
  summary: "Codex can run Scout-routed work as a harness and expose Scout through MCP/plugin surfaces.",
54
54
  },
55
55
  {
@@ -57,8 +57,8 @@ export const SCOUT_HOST_INTEGRATIONS: readonly ScoutHostIntegration[] = [
57
57
  label: "Cursor",
58
58
  roles: ["execution_harness", "agent_host", "mcp_host"],
59
59
  harness: "cursor",
60
- repositoryUrl: "https://github.com/arach/cursor-scout",
61
- installHint: "See https://github.com/arach/cursor-scout for the host-specific installer.",
60
+ repositoryUrl: "https://github.com/oscout/cursor-scout",
61
+ installHint: "See https://github.com/oscout/cursor-scout for the host-specific installer.",
62
62
  summary: "Cursor is an agent host and execution target when routed through its supported CLI or MCP surfaces.",
63
63
  },
64
64
  {
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export * from "./conversations.js";
12
12
  export * from "./conversation-projection.js";
13
13
  export * from "./channel-identity.js";
14
14
  export * from "./channel-invites.js";
15
+ export * from "./channel-membership.js";
15
16
  export * from "./channel-reception.js";
16
17
  export * from "./collaboration.js";
17
18
  export * from "./assigned-roles.js";
@@ -61,3 +62,12 @@ export * from "./terminal-host-topology.js";
61
62
  export * from "./herdr-workspace-digest.js";
62
63
  export * from "./broker-routes.js";
63
64
  export * from "./rendezvous.js";
65
+
66
+ export * from "./chat-pins.js";
67
+ export * from "./chat-message-corrections.js";
68
+
69
+ export * from "./chat-mentions.js";
70
+
71
+ export * from "./agent-notifications.js";
72
+
73
+ export * from "./mcp-core-tools.js";
@@ -27,8 +27,12 @@ export type InvocationSessionPolicy =
27
27
  /** @deprecated use reuse */
28
28
  | "any";
29
29
 
30
- /** Where a newly created harness session should be discoverable. */
31
- export type SessionPlacement = "background" | "foreground";
30
+ /**
31
+ * Where a newly created harness session should be discoverable.
32
+ * `attached` (Codex only) runs the session on an already-running Codex
33
+ * app-server (the daemon control socket) instead of a Scout-spawned one.
34
+ */
35
+ export type SessionPlacement = "background" | "foreground" | "attached";
32
36
 
33
37
  export type InvocationForkSourceKind =
34
38
  | "native_thread_clone"
@@ -72,6 +76,17 @@ export interface InvocationExecutionPreference {
72
76
  forkFromSessionId?: ScoutId;
73
77
  forkContext?: InvocationForkContextOptions;
74
78
  lineage?: InvocationSessionLineage;
79
+ /**
80
+ * Hard per-turn execution budget in milliseconds, honoured only by the ACP
81
+ * wrappers (Grok, Kimi, OpenCode, Devin, Cursor). Tmux, Claude stream-JSON,
82
+ * Codex app-server, and Pi RPC do not implement a turn budget. Distinct
83
+ * from the requester's wait budget (`timeoutMs`): a caller willing to wait
84
+ * 24 h does not license a 24 h turn. When set it takes precedence over the
85
+ * OPENSCOUT_ACP_HARD_CEILING_MS default (typically to raise it); bounded to
86
+ * 6 h at the command boundary and always clamped to the administrator
87
+ * maximum OPENSCOUT_ACP_HARD_CEILING_MAX_MS (default 6 h).
88
+ */
89
+ turnBudgetMs?: number;
75
90
  }
76
91
 
77
92
  export interface InvocationRequest {
@@ -158,7 +173,7 @@ function cleanTraceTimestamp(value: unknown): number | undefined {
158
173
  }
159
174
 
160
175
  function cleanTracePlacement(value: unknown): SessionPlacement | undefined {
161
- return value === "background" || value === "foreground" ? value : undefined;
176
+ return value === "background" || value === "foreground" || value === "attached" ? value : undefined;
162
177
  }
163
178
 
164
179
  function parseFlightSessionTraceEntry(value: unknown): FlightSessionTraceEntry | null {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The `mcp:core` tool tier: what the hosted MCP gateway (mcp.oscout.net)
3
+ * exposes to a remote client through the bridge. The bridge filters on these
4
+ * names, the gateway's agent guide counts them, and the public docs list them.
5
+ * Titles and access must match the registered tools; a desktop test enforces it.
6
+ */
7
+ export type ScoutMcpCoreToolAccess = "read" | "write";
8
+
9
+ export type ScoutMcpCoreTool = {
10
+ name: string;
11
+ title: string;
12
+ access: ScoutMcpCoreToolAccess;
13
+ summary: string;
14
+ };
15
+
16
+ export const SCOUT_MCP_CORE_TOOL_CATALOG = [
17
+ { name: "whoami", title: "Scout Whoami", access: "read", summary: "Show the Scout identity and broker this connection acts as." },
18
+ { name: "agents_search", title: "Search Scout Agents", access: "read", summary: "Find agents on the mesh that can take a request." },
19
+ { name: "agents_resolve", title: "Resolve Scout Agent", access: "read", summary: "Resolve one agent handle, or report why it is ambiguous." },
20
+ { name: "ask", title: "Ask", access: "write", summary: "Ask an agent to answer, review, or build something, and get a handle to follow." },
21
+ { name: "invocations_get", title: "Get Scout Ask", access: "read", summary: "Read the current state of an ask." },
22
+ { name: "invocations_wait", title: "Wait For Scout Ask", access: "read", summary: "Wait briefly for an ask to finish and return its state." },
23
+ { name: "messages_send", title: "Send Scout Message", access: "write", summary: "Send a direct message or a channel post." },
24
+ { name: "messages_reply", title: "Reply to Scout Message", access: "write", summary: "Reply inside an existing conversation." },
25
+ { name: "messages_inbox", title: "Read Scout Inbox", access: "read", summary: "Read recent messages addressed to this identity." },
26
+ { name: "messages_channel", title: "Read Scout Channel", access: "read", summary: "Read recent messages in a named channel." },
27
+ { name: "current_reply_context", title: "Current Scout Reply Context", access: "read", summary: "Check whether a reply would continue an inbound ask." },
28
+ { name: "broker_feed", title: "Read Agent Broker Feed", access: "read", summary: "Read one agent's messages, deliveries, and errors in one view." },
29
+ { name: "tail_events", title: "Read Tail Events", access: "read", summary: "Read recent activity from the coding agents on the Scout machine." },
30
+ { name: "labels_brief", title: "Brief Scout Label", access: "read", summary: "Summarize the records that share a label." },
31
+ { name: "labels_feed", title: "Read Scout Label Feed", access: "read", summary: "Read the event backlog for a label." },
32
+ { name: "work_update", title: "Update Scout Work", access: "write", summary: "Move a work item through progress, review, and done." },
33
+ { name: "notify_operator", title: "Notify Operator", access: "write", summary: "Send the human operator a non-blocking note." },
34
+ { name: "consult_operator", title: "Consult Operator Without Blocking", access: "write", summary: "Ask the operator for advice while continuing with a stated default." },
35
+ { name: "feedback_send", title: "Send Scout Feedback", access: "write", summary: "Report a bug or rough edge in Scout to the OpenScout team." },
36
+ { name: "sessions_attach", title: "Attach External Session", access: "write", summary: "Give this conversation a Scout mailbox so agents can reply to it." },
37
+ { name: "sessions_get", title: "Get External Session", access: "read", summary: "Read the mailbox attached to this conversation." },
38
+ { name: "sessions_poll", title: "Poll External Session", access: "read", summary: "Read pending items in the attached mailbox." },
39
+ { name: "sessions_ack", title: "Acknowledge External Session Item", access: "write", summary: "Mark a mailbox item as received." },
40
+ { name: "sessions_reply", title: "Reply to External Session Item", access: "write", summary: "Send the final answer for a delivered task." },
41
+ ] as const satisfies readonly ScoutMcpCoreTool[];
42
+
43
+ export type ScoutMcpCoreToolName = (typeof SCOUT_MCP_CORE_TOOL_CATALOG)[number]["name"];
44
+
45
+ export const SCOUT_MCP_CORE_TOOL_NAMES: readonly ScoutMcpCoreToolName[] =
46
+ SCOUT_MCP_CORE_TOOL_CATALOG.map((tool) => tool.name);