@sjawhar/pi-legion-envoy 1.36.0 → 1.37.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 CHANGED
@@ -85,10 +85,10 @@ extension files it does not contain.
85
85
 
86
86
  ## Native Dispatch tools
87
87
 
88
- The extension registers twelve native Dispatch tools: `dispatch_issue`, `dispatch_ask`, `dispatch_edit_ask`,
89
- `dispatch_resolve_ask`, `dispatch_comment`, `dispatch_suggest`, `dispatch_message`,
90
- `dispatch_doc_edit`, `dispatch_doc_read`, `dispatch_artifact`, `dispatch_read`, and
91
- `dispatch_search`, when Dispatch configuration resolves both a base URL and bearer token.
88
+ The extension registers fifteen native Dispatch tools: `dispatch_issue`, `dispatch_ask`, `dispatch_edit_ask`,
89
+ `dispatch_resolve_ask`, `dispatch_follow`, `dispatch_comment`, `dispatch_suggest`, `dispatch_message`,
90
+ `dispatch_doc_edit`, `dispatch_doc_read`, `dispatch_request_approval`, `dispatch_artifact`, `dispatch_read`,
91
+ `dispatch_search`, and `dispatch_open_asks`, when Dispatch configuration resolves both a base URL and bearer token.
92
92
 
93
93
  Configure the shared `envoy.json` with:
94
94
 
@@ -112,21 +112,23 @@ whose trimmed contents are the token — how the Legion daemon delivers it to a
112
112
  pane) wins over every other token source and never falls back when unreadable.
113
113
  Omitting `dispatch.serverUrl` while `dispatch.enabled` is true targets
114
114
  `http://localhost:8766`, the Go server's listen address. Invalid configuration,
115
- an invalid URL, or an empty token leaves the twelve tools unavailable and
115
+ an invalid URL, or an empty token leaves the fifteen tools unavailable and
116
116
  reports the source of the error.
117
117
 
118
118
  Owner-scoped calls use either an issue (a native `KEY` or external `owner/repo#n` reference) or
119
119
  an unlinked project document (`project` plus its `artifact` slug). A Legion session may omit
120
120
  `issue` when `LEGION_ISSUE` identifies its root issue and its working directory resolves to a
121
121
  repository. `dispatch_doc_read` and `dispatch_read` also accept `dispatch://` references,
122
- including `dispatch://PROJECT/artifact/<slug>`. `dispatch_search` needs only its query and
123
- returns no subscription topic. `dispatch_edit_ask` and `dispatch_resolve_ask` instead identify
124
- an existing ask with `ask`. Every mutation result carries `details.topic`: issue writes use
125
- `notifications.dispatch.issue.<KEY>.>` and project-document writes use
126
- `notifications.dispatch.document.<PROJECT>.<SLUG>.>`. The extension's `tool_result` hook
127
- subscribes to that exact topic, then registers the session so retained events arrive as Pi
128
- steering. `dispatch_doc_read` and `dispatch_read` return owner details without a subscription
129
- topic.
122
+ including `dispatch://PROJECT/artifact/<slug>`. `dispatch_search` needs only its query.
123
+ `dispatch_edit_ask`, `dispatch_resolve_ask`, and `dispatch_follow` instead identify an existing
124
+ ask with `ask`. No result carries a subscription topic: opening an ask or replying to one with
125
+ `dispatch_comment({ reply_to_ask })` makes the session a follower of that ask (its answer and
126
+ replies reach the session directly, server-side), the result carries `details.follows.ask`, and
127
+ the extension's `tool_result` hook tells the model so once per ask. Whole-issue or
128
+ whole-document subscription is the model's own `envoy_subscribe` of the topic every write
129
+ result names (`notifications.dispatch.issue.<KEY>.>` or
130
+ `notifications.dispatch.document.<PROJECT>.<SLUG>.>`). `dispatch_doc_read` and
131
+ `dispatch_read` return owner details only.
130
132
 
131
133
  The shared contract supplies the model-facing schemas and descriptions. The
132
134
  `dispatch` skill describes when to use each operation for issues, asks, review
package/dist/envoy.js CHANGED
@@ -29779,6 +29779,11 @@ var SubscriptionRemovedEventPayloadSchema = object({
29779
29779
  pending: boolean2().optional(),
29780
29780
  request_event_id: number2().int().positive().optional()
29781
29781
  });
29782
+ var AskFollowerEventPayloadSchema = object({
29783
+ ask_id: string2().optional(),
29784
+ session_id: string2().optional(),
29785
+ by: object({ kind: string2(), id: string2().optional() }).passthrough().optional()
29786
+ });
29782
29787
  // ../contracts/src/dispatch-snippet.ts
29783
29788
  var HTML_ENTITIES = [
29784
29789
  ["&lt;", "<"],
@@ -29944,6 +29949,15 @@ var dispatchToolSpecs = [
29944
29949
  reason: z.string({ min: 1 }).describe("Why the open ask no longer needs a human answer.")
29945
29950
  })
29946
29951
  },
29952
+ {
29953
+ name: "dispatch_follow",
29954
+ description: "Follow or unfollow an ask. Every session that opens or replies to an ask follows it: its answer, " + "edits, resolution, and replies reach that session directly. Unfollow to stop; follow to rejoin or " + "to hear an ask you never wrote to. Whole-issue subscription is separate: envoy_subscribe " + "notifications.dispatch.issue.<KEY>.>",
29955
+ arguments: (z) => ({
29956
+ ask: z.string().describe("Full ask id (uuid)."),
29957
+ action: z.enum(["follow", "unfollow"]).describe("follow | unfollow")
29958
+ }),
29959
+ strict: true
29960
+ },
29947
29961
  {
29948
29962
  name: "dispatch_comment",
29949
29963
  description: "Add review feedback to an issue or project document quote, or reply to a question asked with dispatch_ask. " + "Do not use it for an exact replacement; use " + `dispatch_suggest instead. A quote anchor is pinned to its block. Body is at most 2,000 characters. ${OWNER_REFERENCE}`,
@@ -31119,6 +31133,9 @@ function askAnswerText(answer) {
31119
31133
  // ../envoy-client/src/delivery.ts
31120
31134
  var KNOWN_SOURCES = EnvelopeSchema.shape.source.enum;
31121
31135
  var FOREIGN_SESSION_ID = /\b01a0[0-9a-f]{4}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}\b/g;
31136
+ var AskAuthorPayloadSchema = object({
31137
+ author: object({ kind: string2(), id: string2() }).passthrough()
31138
+ });
31122
31139
  var InboundSenderSchema = object({
31123
31140
  session_id: string2().optional(),
31124
31141
  machine: string2().optional(),
@@ -31381,6 +31398,7 @@ function renderInbound(raw, sessionID, subject) {
31381
31398
  let rejectedDelivery;
31382
31399
  let malformedDelivery = false;
31383
31400
  let inReplyTo = envelope.in_reply_to;
31401
+ let askAuthor;
31384
31402
  const dispatchRendered = envelope.source === "dispatch" && envelope.payload !== undefined;
31385
31403
  if (envelope.source === "dispatch") {
31386
31404
  if (envelope.payload === undefined) {
@@ -31406,8 +31424,28 @@ function renderInbound(raw, sessionID, subject) {
31406
31424
  envelope
31407
31425
  };
31408
31426
  }
31427
+ if (frame.event.type === "ask.follower_added" || frame.event.type === "ask.follower_removed") {
31428
+ const follower = AskFollowerEventPayloadSchema.safeParse(frame.event.payload);
31429
+ if (!follower.success || follower.data.session_id !== sessionID) {
31430
+ return { skip: true, content: "", envelope };
31431
+ }
31432
+ const who = follower.data.by?.id ?? "someone";
31433
+ const owner = dispatchOwner(frame.event, subject ?? envelope.topic);
31434
+ const ask = follower.data.ask_id ?? "?";
31435
+ return {
31436
+ skip: false,
31437
+ content: frame.event.type === "ask.follower_added" ? `Now following ask ${ask} on ${owner} (added by ${who}): its answer and replies reach you directly; dispatch_follow unfollow to stop.` : `No longer following ask ${ask} on ${owner} (removed by ${who}).`,
31438
+ envelope
31439
+ };
31440
+ }
31409
31441
  const answered = dispatchAskAnswer(frame.event);
31410
31442
  const question = answered?.question ?? dispatchAskQuestion(frame.event);
31443
+ if (frame.event.type.startsWith("ask.")) {
31444
+ const asked = AskAuthorPayloadSchema.safeParse(frame.event.payload);
31445
+ if (asked.success && asked.data.author.kind === "session") {
31446
+ askAuthor = asked.data.author.id;
31447
+ }
31448
+ }
31411
31449
  if (inReplyTo !== undefined) {
31412
31450
  inReplyTo = dispatchReplyRef(frame.event, subject ?? envelope.topic, inReplyTo);
31413
31451
  }
@@ -31468,7 +31506,7 @@ function renderInbound(raw, sessionID, subject) {
31468
31506
  ${envelope.payload ?? ""}`;
31469
31507
  let foreignSession;
31470
31508
  for (const match of body.matchAll(FOREIGN_SESSION_ID)) {
31471
- if (match[0] !== envelope.source_session && match[0] !== sessionID) {
31509
+ if (match[0] !== envelope.source_session && match[0] !== sessionID && match[0] !== askAuthor) {
31472
31510
  foreignSession = match[0];
31473
31511
  break;
31474
31512
  }
@@ -31921,6 +31959,12 @@ class DispatchClient {
31921
31959
  async getAsk(id) {
31922
31960
  return this.#json("GET", ["api", "v1", "asks", id]);
31923
31961
  }
31962
+ async followAsk(id, sessionId, actor) {
31963
+ await this.#json("PUT", ["api", "v1", "asks", id, "followers", sessionId], { actor });
31964
+ }
31965
+ async unfollowAsk(id, sessionId, actor) {
31966
+ await this.#json("DELETE", ["api", "v1", "asks", id, "followers", sessionId], { actor });
31967
+ }
31924
31968
  async getComment(id) {
31925
31969
  return this.#json("GET", ["api", "v1", "comments", id]);
31926
31970
  }
@@ -32154,12 +32198,33 @@ function formatZodIssues(issues, schema) {
32154
32198
  }
32155
32199
 
32156
32200
  // ../envoy-client/src/dispatch-execute.ts
32201
+ function issueTopic(key) {
32202
+ return { label: key, topic: dispatchIssueSubject(key, ">") };
32203
+ }
32204
+ function documentTopic(artifact) {
32205
+ return {
32206
+ label: `${artifact.project}/${artifact.slug}`,
32207
+ topic: dispatchDocumentSubject(artifact.project, artifact.slug, ">")
32208
+ };
32209
+ }
32210
+ function resolvedTopic(resolved) {
32211
+ if (resolved.owner.kind === "project")
32212
+ return documentTopic(resolved.artifact);
32213
+ if (resolved.issue === undefined)
32214
+ throw new Error("issue document is missing its issue");
32215
+ return issueTopic(resolved.issue.key);
32216
+ }
32217
+ function notSubscribed(owner) {
32218
+ return `(not subscribed to ${owner.label}; envoy_subscribe ${owner.topic} for every event on it)`;
32219
+ }
32220
+ function followsAsk(owner) {
32221
+ return `You follow this ask: its answer and replies reach you directly. For every event on ${owner.label}: envoy_subscribe ${owner.topic}`;
32222
+ }
32157
32223
  function documentResultDetails(artifact) {
32158
32224
  return {
32159
32225
  project: artifact.project,
32160
32226
  artifact: artifact.id,
32161
- document: `${artifact.project}/${artifact.slug}`,
32162
- topic: dispatchDocumentSubject(artifact.project, artifact.slug, ">")
32227
+ document: `${artifact.project}/${artifact.slug}`
32163
32228
  };
32164
32229
  }
32165
32230
  function writeResultDetails(resolved, fields) {
@@ -32168,19 +32233,11 @@ function writeResultDetails(resolved, fields) {
32168
32233
  }
32169
32234
  if (resolved.issue === undefined)
32170
32235
  throw new Error("issue document is missing its issue");
32171
- return {
32172
- issue: resolved.issue.key,
32173
- topic: dispatchIssueSubject(resolved.issue.key, ">"),
32174
- ...fields
32175
- };
32236
+ return { issue: resolved.issue.key, ...fields };
32176
32237
  }
32177
- async function askResultDetails(client, ask, resolved) {
32238
+ async function askOwnerDetails(client, ask, resolved) {
32178
32239
  if (ask.issue_key !== null) {
32179
- return {
32180
- issue: ask.issue_key,
32181
- topic: dispatchIssueSubject(ask.issue_key, ">"),
32182
- ask: ask.id
32183
- };
32240
+ return { issue: ask.issue_key, ask: ask.id };
32184
32241
  }
32185
32242
  if (ask.artifact_id === undefined || ask.artifact_id === null) {
32186
32243
  throw new Error("document ask is missing its artifact ID");
@@ -32188,6 +32245,9 @@ async function askResultDetails(client, ask, resolved) {
32188
32245
  const artifact = resolved?.artifact ?? await client.getArtifact(ask.artifact_id);
32189
32246
  return { ...documentResultDetails(artifact), ask: ask.id };
32190
32247
  }
32248
+ async function followedAskDetails(client, ask, resolved) {
32249
+ return { ...await askOwnerDetails(client, ask, resolved), follows: { ask: ask.id } };
32250
+ }
32191
32251
  var nativeIssueKeyPattern = /^[A-Z][A-Z0-9]{1,9}-[0-9]+$/;
32192
32252
  var externalIssueRefPattern = /^([^/\s]+)\/([^/\s#]+)#([1-9][0-9]*)$/;
32193
32253
  var bareIssueNumberPattern = /^[1-9][0-9]*$/;
@@ -32195,6 +32255,7 @@ var issueFreeTools = {
32195
32255
  dispatch_issue: true,
32196
32256
  dispatch_edit_ask: true,
32197
32257
  dispatch_resolve_ask: true,
32258
+ dispatch_follow: true,
32198
32259
  dispatch_search: true,
32199
32260
  dispatch_open_asks: true
32200
32261
  };
@@ -32857,8 +32918,8 @@ async function executeDispatchTool(input) {
32857
32918
  actor
32858
32919
  });
32859
32920
  return {
32860
- text: `Created ${created.key}: ${created.title}`,
32861
- details: { issue: created.key, topic: dispatchIssueSubject(created.key, ">") }
32921
+ text: `Created ${created.key}: ${created.title} ${notSubscribed(issueTopic(created.key))}`,
32922
+ details: { issue: created.key }
32862
32923
  };
32863
32924
  } catch (error) {
32864
32925
  if (!(error instanceof DispatchServiceError) || error.code !== "POSSIBLE_DUPLICATE") {
@@ -32909,7 +32970,7 @@ async function executeDispatchTool(input) {
32909
32970
  throw new Error("resolved ask is missing its resolution");
32910
32971
  return {
32911
32972
  text: `${kind === "retracted" ? "Retracted" : "Resolved"} ask ${ask.id}: ${ask.resolution.reason}`,
32912
- details: await askResultDetails(client, ask)
32973
+ details: await askOwnerDetails(client, ask)
32913
32974
  };
32914
32975
  }
32915
32976
  case "dispatch_ask": {
@@ -32932,9 +32993,11 @@ async function executeDispatchTool(input) {
32932
32993
  actor
32933
32994
  };
32934
32995
  const ask = resolved?.owner.kind === "project" ? await client.artifactAsk(resolved.artifact.id, askInput) : await client.ask(issue(), askInput);
32996
+ const askOwner = ask.issue_key !== null ? issueTopic(ask.issue_key) : resolved === undefined ? issueTopic(issue()) : documentTopic(resolved.artifact);
32935
32997
  return {
32936
- text: `Opened ask ${ask.id}: ${ask.question}`,
32937
- details: await askResultDetails(client, ask, resolved)
32998
+ text: `Asked ${ask.id} on ${askOwner.label} (urgency ${ask.urgency}): ${ask.question}
32999
+ ${followsAsk(askOwner)}`,
33000
+ details: await followedAskDetails(client, ask, resolved)
32938
33001
  };
32939
33002
  }
32940
33003
  case "dispatch_edit_ask": {
@@ -32951,7 +33014,7 @@ async function executeDispatchTool(input) {
32951
33014
  });
32952
33015
  return {
32953
33016
  text: `Ask edited: ${ask.question}`,
32954
- details: await askResultDetails(client, ask)
33017
+ details: await askOwnerDetails(client, ask)
32955
33018
  };
32956
33019
  }
32957
33020
  case "dispatch_comment": {
@@ -32971,18 +33034,23 @@ async function executeDispatchTool(input) {
32971
33034
  actor
32972
33035
  };
32973
33036
  const comment = resolved?.owner.kind === "project" ? await client.artifactComment(resolved.artifact.id, commentInput) : await client.comment(issue(), commentInput);
32974
- const askState = comment.turn === null ? "" : ` (ask now waiting on ${comment.turn})`;
33037
+ const commentOwner = resolved === undefined ? issueTopic(issue()) : resolvedTopic(resolved);
33038
+ const commentDetails = resolved === undefined ? { issue: comment.issue_key, comment: comment.id } : writeResultDetails(resolved, { comment: comment.id });
33039
+ if (replyToAsk !== undefined) {
33040
+ const askState = comment.turn === null ? "" : `; ask now waiting on ${comment.turn}`;
33041
+ return {
33042
+ text: `Replied on ask ${replyToAsk} (comment ${comment.id}${askState}). ${followsAsk(commentOwner)}`,
33043
+ details: {
33044
+ ...commentDetails,
33045
+ ask: replyToAsk,
33046
+ follows: { ask: replyToAsk },
33047
+ ...comment.turn === null ? {} : { ask_waiting_on: comment.turn }
33048
+ }
33049
+ };
33050
+ }
32975
33051
  return {
32976
- text: `Posted comment ${comment.id}${askState}`,
32977
- details: resolved === undefined ? {
32978
- issue: comment.issue_key,
32979
- topic: dispatchIssueSubject(issue(), ">"),
32980
- comment: comment.id,
32981
- ...comment.turn === null ? {} : { ask_waiting_on: comment.turn }
32982
- } : writeResultDetails(resolved, {
32983
- comment: comment.id,
32984
- ...comment.turn === null ? {} : { ask_waiting_on: comment.turn }
32985
- })
33052
+ text: `Posted comment ${comment.id} ${notSubscribed(commentOwner)}`,
33053
+ details: commentDetails
32986
33054
  };
32987
33055
  }
32988
33056
  case "dispatch_suggest": {
@@ -32999,7 +33067,7 @@ async function executeDispatchTool(input) {
32999
33067
  };
33000
33068
  const comment = resolved.owner.kind === "project" ? await client.artifactSuggest(resolved.artifact.id, suggestionInput) : await client.suggest(issue(), suggestionInput);
33001
33069
  return {
33002
- text: `Posted suggestion ${comment.id}`,
33070
+ text: `Posted suggestion ${comment.id} ${notSubscribed(resolvedTopic(resolved))}`,
33003
33071
  details: writeResultDetails(resolved, { comment: comment.id })
33004
33072
  };
33005
33073
  }
@@ -33013,12 +33081,8 @@ async function executeDispatchTool(input) {
33013
33081
  });
33014
33082
  const messageRef = `dispatch://${issueKey}/message/${message.id}`;
33015
33083
  return {
33016
- text: `Posted message ${message.id} (${messageRef})`,
33017
- details: {
33018
- issue: issueKey,
33019
- topic: dispatchIssueSubject(issueKey, ">"),
33020
- message: message.id
33021
- }
33084
+ text: `Posted message ${message.id} (${messageRef}) ${notSubscribed(issueTopic(issueKey))}`,
33085
+ details: { issue: issueKey, message: message.id }
33022
33086
  };
33023
33087
  }
33024
33088
  case "dispatch_doc_edit": {
@@ -33032,8 +33096,9 @@ async function executeDispatchTool(input) {
33032
33096
  });
33033
33097
  const retyped = ops.filter((operation) => operation.op === "retype").length;
33034
33098
  const versionText = edited.version === null ? "no new version" : `version ${edited.version.number}`;
33099
+ const applied = retyped === 0 ? `Applied ${edited.applied} ops (${versionText})` : `Applied ${edited.applied} ops; retyped ${retyped} block${retyped === 1 ? "" : "s"} (${versionText})`;
33035
33100
  return {
33036
- text: retyped === 0 ? `Applied ${edited.applied} ops (${versionText})` : `Applied ${edited.applied} ops; retyped ${retyped} block${retyped === 1 ? "" : "s"} (${versionText})`,
33101
+ text: `${applied} ${notSubscribed(resolvedTopic(resolved))}`,
33037
33102
  details: writeResultDetails(resolved, {
33038
33103
  applied: edited.applied,
33039
33104
  ...edited.version === null ? {} : { version: edited.version.number }
@@ -33076,7 +33141,7 @@ ${trailer.join(`
33076
33141
  }
33077
33142
  };
33078
33143
  }
33079
- const details = await askResultDetails(client, result.ask, resolved);
33144
+ const details = await followedAskDetails(client, result.ask, resolved);
33080
33145
  return {
33081
33146
  text: `Approval requested for ${resolved.artifact.name} (document id ${resolved.artifact.id}) at version ${result.version} (ask ${result.ask.id}). The answer arrives as artifact.approved or artifact.changes_requested; an edit after approval makes it stale, so request again for the new version.`,
33082
33147
  details: { ...details, artifact: resolved.artifact.id, version: result.version }
@@ -33100,19 +33165,36 @@ ${trailer.join(`
33100
33165
  const artifactOwner = documentOwner();
33101
33166
  const result = artifactOwner.kind === "project" ? await client.projectArtifact(artifactOwner.project, artifactInput) : await client.artifact(issue(), artifactInput);
33102
33167
  const artifactRef = artifactOwner.kind === "project" ? `dispatch://${artifactOwner.project}/artifact/${result.artifact.slug}` : `dispatch://${issue()}/artifact/${result.artifact.slug}`;
33168
+ const uploadOwner = artifactOwner.kind === "project" ? documentTopic(result.artifact) : issueTopic(issue());
33103
33169
  return {
33104
- text: `Uploaded ${result.artifact.name} as version ${result.version.number} (artifact slug ${result.artifact.slug}; ${artifactRef})`,
33170
+ text: `Uploaded ${result.artifact.name} as version ${result.version.number} (artifact slug ${result.artifact.slug}; ${artifactRef}) ${notSubscribed(uploadOwner)}`,
33105
33171
  details: artifactOwner.kind === "project" ? {
33106
33172
  ...documentResultDetails(result.artifact),
33107
33173
  version: result.version.number
33108
33174
  } : {
33109
33175
  issue: issue(),
33110
- topic: dispatchIssueSubject(issue(), ">"),
33111
33176
  artifact: result.artifact.id,
33112
33177
  version: result.version.number
33113
33178
  }
33114
33179
  };
33115
33180
  }
33181
+ case "dispatch_follow": {
33182
+ const sessionId = input.sessionId?.trim();
33183
+ if (!sessionId)
33184
+ throw new Error("host session id is required for dispatch_follow");
33185
+ const ask = askId(args);
33186
+ const action = stringArg(args, "action");
33187
+ if (action === "unfollow") {
33188
+ await client.unfollowAsk(ask, sessionId, actor);
33189
+ return { text: `Unfollowed ask ${ask}.`, details: { ask } };
33190
+ }
33191
+ const read = await client.getAsk(ask);
33192
+ await client.followAsk(ask, sessionId, actor);
33193
+ return {
33194
+ text: `Following ask ${ask}: its answer and replies reach this session directly.`,
33195
+ details: await followedAskDetails(client, read.ask)
33196
+ };
33197
+ }
33116
33198
  case "dispatch_read": {
33117
33199
  if (ownerArguments.ref?.kind === "ask") {
33118
33200
  const ref = ownerArguments.ref;
@@ -33202,25 +33284,30 @@ function asObject(value) {
33202
33284
  }
33203
33285
 
33204
33286
  // ../envoy-client/src/dispatch-subscribe.ts
33205
- function dispatchSubscriptionTopic(details) {
33206
- if (typeof details !== "object" || details === null || !("topic" in details))
33287
+ function dispatchFollowNotice(details) {
33288
+ if (typeof details !== "object" || details === null)
33289
+ return null;
33290
+ const { follows, issue, document } = details;
33291
+ if (typeof follows !== "object" || follows === null)
33292
+ return null;
33293
+ const { ask } = follows;
33294
+ if (typeof ask !== "string" || ask === "")
33295
+ return null;
33296
+ let owner;
33297
+ if (typeof issue === "string" && issue !== "") {
33298
+ owner = { label: issue, topic: dispatchIssueSubject(issue, ">") };
33299
+ } else if (typeof document === "string") {
33300
+ const [project, slug] = document.split("/", 2);
33301
+ if (!project || !slug)
33302
+ return null;
33303
+ owner = { label: document, topic: dispatchDocumentSubject(project, slug, ">") };
33304
+ } else {
33207
33305
  return null;
33208
- const { topic } = details;
33209
- return typeof topic === "string" && topic.startsWith("notifications.dispatch.") ? topic : null;
33210
- }
33211
- function dispatchTopicLabel(topic) {
33212
- if (topic.startsWith(DISPATCH_ISSUE_TOPIC_PREFIX)) {
33213
- const [key] = topic.slice(DISPATCH_ISSUE_TOPIC_PREFIX.length).split(".", 2);
33214
- if (key !== undefined && key !== "")
33215
- return key;
33216
- }
33217
- if (topic.startsWith(DISPATCH_DOCUMENT_TOPIC_PREFIX)) {
33218
- const [project, slug] = topic.slice(DISPATCH_DOCUMENT_TOPIC_PREFIX.length).split(".", 3);
33219
- if (project !== undefined && project !== "" && slug !== undefined && slug !== "") {
33220
- return `${project}/${slug}`;
33221
- }
33222
33306
  }
33223
- return topic;
33307
+ return {
33308
+ ask,
33309
+ text: `Following ask ${ask} on ${owner.label}: its answer and replies reach you directly (dispatch_follow unfollow to stop). For every event on ${owner.label}: envoy_subscribe ${owner.topic}.`
33310
+ };
33224
33311
  }
33225
33312
  function subscriptionRemovedTopics(raw, sessionID) {
33226
33313
  let envelope;
@@ -34326,26 +34413,15 @@ ${formatOpenAsksSummary(snapshot, currentDispatchConfig().url)}`,
34326
34413
  };
34327
34414
  }
34328
34415
  });
34416
+ const announcedFollows = new Set;
34329
34417
  pi.on("tool_result", async (event) => {
34330
34418
  if (event.isError)
34331
34419
  return;
34332
- const topic = dispatchSubscriptionTopic(event.details);
34333
- if (topic === null)
34420
+ const notice = dispatchFollowNotice(event.details);
34421
+ if (notice === null || announcedFollows.has(notice.ask))
34334
34422
  return;
34335
- try {
34336
- const isNew = await subscribe(topic);
34337
- if (isNew)
34338
- await registerSession();
34339
- if (isNew) {
34340
- pi.sendMessage({
34341
- customType: "envoy-message",
34342
- content: `Subscribed to ${dispatchTopicLabel(topic)} (every event on this issue reaches you; envoy_unsubscribe ${topic} to stop).`,
34343
- display: true
34344
- }, { deliverAs: "steer", triggerTurn: false });
34345
- }
34346
- } catch (error) {
34347
- activeSessionContext?.ui.notify(`envoy: dispatch reply auto-subscribe failed (${messageFor(error)}); run envoy_subscribe ${topic}`, "warning");
34348
- }
34423
+ announcedFollows.add(notice.ask);
34424
+ pi.sendMessage({ customType: "envoy-message", content: notice.text, display: true }, { deliverAs: "steer", triggerTurn: false });
34349
34425
  });
34350
34426
  async function execute(operation, rawParameters) {
34351
34427
  try {
package/dist/legion.js CHANGED
@@ -29053,6 +29053,11 @@ var SubscriptionRemovedEventPayloadSchema = object({
29053
29053
  pending: boolean2().optional(),
29054
29054
  request_event_id: number2().int().positive().optional()
29055
29055
  });
29056
+ var AskFollowerEventPayloadSchema = object({
29057
+ ask_id: string2().optional(),
29058
+ session_id: string2().optional(),
29059
+ by: object({ kind: string2(), id: string2().optional() }).passthrough().optional()
29060
+ });
29056
29061
  // ../contracts/src/dispatch-snippet.ts
29057
29062
  var HTML_ENTITIES = [
29058
29063
  ["&lt;", "<"],
@@ -29218,6 +29223,15 @@ var dispatchToolSpecs = [
29218
29223
  reason: z.string({ min: 1 }).describe("Why the open ask no longer needs a human answer.")
29219
29224
  })
29220
29225
  },
29226
+ {
29227
+ name: "dispatch_follow",
29228
+ description: "Follow or unfollow an ask. Every session that opens or replies to an ask follows it: its answer, " + "edits, resolution, and replies reach that session directly. Unfollow to stop; follow to rejoin or " + "to hear an ask you never wrote to. Whole-issue subscription is separate: envoy_subscribe " + "notifications.dispatch.issue.<KEY>.>",
29229
+ arguments: (z) => ({
29230
+ ask: z.string().describe("Full ask id (uuid)."),
29231
+ action: z.enum(["follow", "unfollow"]).describe("follow | unfollow")
29232
+ }),
29233
+ strict: true
29234
+ },
29221
29235
  {
29222
29236
  name: "dispatch_comment",
29223
29237
  description: "Add review feedback to an issue or project document quote, or reply to a question asked with dispatch_ask. " + "Do not use it for an exact replacement; use " + `dispatch_suggest instead. A quote anchor is pinned to its block. Body is at most 2,000 characters. ${OWNER_REFERENCE}`,
@@ -97,7 +97,7 @@ Architects create newly tracked child work with:
97
97
  dispatch_issue({ project, title, parent?, external?, spec?, force?, labels?: string[], priority?: 0 | 1 | 2 | 3 })
98
98
  ```
99
99
  `labels` are optional initial labels: Dispatch trims them, preserves their case, and removes case-insensitive duplicates. Set `priority` on creation only when the human's intent makes the bucket clear; otherwise priority remains the human's decision. It returns
100
- `details` `{ issue, topic }`. Use `dispatch_issue` only to create an issue; never use it to park a question. When `spec` is supplied,
100
+ `details` `{ issue }`; creating an issue does not subscribe you to it (see [Following](#following)). Use `dispatch_issue` only to create an issue; never use it to park a question. When `spec` is supplied,
101
101
  follow [Writing a spec](#writing-a-spec).
102
102
 
103
103
  ## Search first
@@ -131,7 +131,7 @@ dispatch_ask({
131
131
  anchor?: { artifact, quote, occurrence? },
132
132
  })
133
133
  ```
134
- It returns `details` `{ issue, topic, ask }` for an issue or `{ project, artifact, document, topic, ask }` for a project document.
134
+ It returns `details` `{ issue, ask, follows: { ask } }` for an issue or `{ project, artifact, document, ask, follows: { ask } }` for a project document: you follow the ask you opened (see [Following](#following)).
135
135
 
136
136
  References belong in the question text; `ref` is sugar that appends its `dispatch://` value to the question as a rendered link.
137
137
 
@@ -248,7 +248,7 @@ It returns live or versioned markdown with open marks. `issue` with an omitted `
248
248
  ```ts
249
249
  dispatch_doc_edit({ issue?, project?, artifact, ops, summary? })
250
250
  ```
251
- It returns issue or project-document owner details plus `applied`, optional `version`, and its write `topic`. `ops` is an array of this
251
+ It returns issue or project-document owner details plus `applied` and optional `version`. `ops` is an array of this
252
252
  exact `EditOp` shape:
253
253
 
254
254
  ```ts
@@ -324,7 +324,8 @@ Add feedback with:
324
324
  dispatch_comment({ issue?, project?, artifact?, ref?, quote?, occurrence?, body, reply_to?, reply_to_ask?, turn? })
325
325
  ```
326
326
 
327
- It returns issue or project-document owner details plus `comment` and, for writes, `topic`.
327
+ It returns issue or project-document owner details plus `comment`; a `reply_to_ask` reply also returns `ask` and `follows: { ask }`,
328
+ because replying to an ask makes you one of its followers (see [Following](#following)).
328
329
  `ref` names the owner (an issue or project-document reference) in place of `issue`/`project`.
329
330
  `quote` requires `artifact`; its anchor is pinned to the containing block while retaining the quote
330
331
  for display. Omit both for a floating issue comment. A reply (`reply_to`/`reply_to_ask`) takes no
@@ -342,7 +343,7 @@ Propose an exact replacement instead of describing it:
342
343
  dispatch_suggest({ issue?, project?, artifact, ref?, quote, replace_with, body?, occurrence? })
343
344
  ```
344
345
 
345
- It returns issue or project-document owner details plus `comment` and its write `topic`. A human accepts or rejects a suggestion.
346
+ It returns issue or project-document owner details plus `comment`. A human accepts or rejects a suggestion.
346
347
  Errors: `TARGET_AMBIGUOUS` (add `occurrence`), `TARGET_NOT_FOUND` (re-read first), `INVALID_ANCHOR`/`ANCHOR_MISSING`/`ANCHOR_ORPHANED`
347
348
  (bad, unwritten, or stale quote), `INVALID_MARKDOWN`/`DOC_SCHEMA` (malformed content), `CAP_EXCEEDED`, `ISSUE_CLOSED`.
348
349
 
@@ -361,8 +362,8 @@ dispatch_artifact({ issue?, project?, name: "load-test-results.md", content: "#
361
362
  ```
362
363
 
363
364
  Exactly one of `issue` and `project` is required. A project upload creates an unlinked project document; it must not include `artifact`.
364
- Exactly one of `path` and `content` is required. It returns issue or project-document owner details plus `artifact`, `version`, and its
365
- write `topic`. Uploading the same `name` creates its next version — so uploading `spec.md` **replaces the issue's own specification**
365
+ Exactly one of `path` and `content` is required. It returns issue or project-document owner details plus `artifact` and `version`.
366
+ Uploading the same `name` creates its next version — so uploading `spec.md` **replaces the issue's own specification**
366
367
  with your text. Never do that: the spec is edited in place with `dispatch_doc_edit` (see [The Spec](#the-spec)). Address an existing
367
368
  artifact by the slug shown in the upload result or by its filename, and a project document by its artifact id, slug, or filename; the
368
369
  slug also arrives on `artifact.created` events.
@@ -396,7 +397,7 @@ pull request is where it is summarised. One message that a human reads beats ten
396
397
  dispatch_message({ issue, body })
397
398
  ```
398
399
 
399
- It returns `details` `{ issue, topic, message }`. `body` is capped at 2,000 characters. A message is not a decision
400
+ It returns `details` `{ issue, message }`. `body` is capped at 2,000 characters. A message is not a decision
400
401
  (`dispatch_ask`) or document feedback (`dispatch_comment`), and it does not wake anyone unless the issue is routed.
401
402
 
402
403
  ### Targeted agent messages
@@ -423,13 +424,31 @@ through Envoy or the hub. A bearer that targets over HTTP names its own session
423
424
  target only a session that advertises the mode you want. Sending to a session with no issue
424
425
  (`POST /api/v1/agents/{session_id}/messages`) stays human-only.
425
426
 
427
+ ## Following
428
+
429
+ An ask has followers: every session that wrote to it — the session that opened it and every session that replied with
430
+ `dispatch_comment({ reply_to_ask })` — plus any session a human adds from the ask card. The ask's answer, edits, resolution, and
431
+ every reply on it reach each follower's own agent topic directly, whether or not the writer was a human and whatever the issue's
432
+ route. The tool result says so (`You follow this ask: its answer and replies reach you directly.`) and carries `details.follows.ask`;
433
+ the host tells you once per ask. Leave a thread you no longer need, or rejoin one, with:
434
+
435
+ ```ts
436
+ dispatch_follow({ ask, action: "follow" | "unfollow" })
437
+ ```
438
+
439
+ `ask` is the full ask id or a `dispatch://KEY/ask/<id>` reference. A human may also remove you from the ask card; either way you are
440
+ told with an `ask.follower_removed` notice, and a human adding you arrives as `ask.follower_added`.
441
+
442
+ No write subscribes you to an issue or document. Following covers your own asks and the threads you joined; everything else on the
443
+ owner — other sessions' asks, comments, messages, status changes — reaches you only if you subscribe to the owner topic yourself.
444
+ Every write result names that line: `envoy_subscribe notifications.dispatch.issue.<KEY>.>` for an issue,
445
+ `envoy_subscribe notifications.dispatch.document.<PROJECT>.<SLUG>.>` for a project document. The owner topic carries every Dispatch
446
+ event; `notify` only controls agent wake and routed delivery. A human may unsubscribe you from the issue or document header; you
447
+ are told with a `subscription.removed` notice when that happens.
448
+
426
449
  ## What comes back
427
450
 
428
- A write result's `details.topic` subscribes the host to its owner, and the first such subscription on an issue or document also tells
429
- you so (an already-subscribed write stays quiet — no repeat notice). Issue writes use `notifications.dispatch.issue.<KEY>.>`;
430
- project-document writes use `notifications.dispatch.document.<PROJECT>.<SLUG>.>`. The owner topic carries every Dispatch event; `notify`
431
- only controls agent wake and routed delivery. A human may unsubscribe you from the issue or document header; you are told with a
432
- `subscription.removed` notice when that happens. After a restart, catch up with:
451
+ After a restart, catch up with:
433
452
 
434
453
  ```ts
435
454
  dispatch_read({ issue?, project?, artifact?, ref? })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion-envoy",
3
- "version": "1.36.0",
3
+ "version": "1.37.0",
4
4
  "type": "module",
5
5
  "omp": {
6
6
  "extensions": [