@sjawhar/opencode-legion-envoy 1.25.0 → 1.27.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
@@ -16,23 +16,28 @@ This package exposes:
16
16
  - `dispatch_ask`
17
17
  - `dispatch_edit_ask`
18
18
  - `dispatch_resolve_ask`
19
+ - `dispatch_follow`
19
20
  - `dispatch_comment`
20
21
  - `dispatch_suggest`
21
22
  - `dispatch_message`
22
23
  - `dispatch_doc_edit`
23
24
  - `dispatch_doc_read`
25
+ - `dispatch_request_approval`
24
26
  - `dispatch_artifact`
25
27
  - `dispatch_read`
26
28
  - `dispatch_search`
29
+ - `dispatch_open_asks`
27
30
 
28
- The twelve native `dispatch_*` tools create and read Dispatch issues, asks, comments,
31
+ The fifteen native `dispatch_*` tools create and read Dispatch issues, asks, comments,
29
32
  documents, and artifacts, or search all of them. They are present when `dispatch.enabled`
30
33
  resolves a server URL and bearer token from envoy.json (`~/.config/opencode/envoy.json`, merged
31
34
  with `<repo>/.opencode/envoy.json`) or the `DISPATCH_URL` and `DISPATCH_TOKEN` environment
32
35
  variables; `dispatch.enabled: true` without `dispatch.serverUrl` targets `http://localhost:8766`.
33
- Each issue-scoped call fills the target issue from the session working directory, stamps it with
34
- the OpenCode session id and title, and stores a successful mutation's `details.topic` as tool
35
- metadata so the host subscribes to that exact Dispatch topic.
36
+ Each issue-scoped call fills the target issue from the session working directory and stamps it with
37
+ the OpenCode session id and title. No write subscribes the session to an issue or document: a
38
+ session follows the asks it opens or replies to (their answers and replies reach it directly),
39
+ `dispatch_follow` leaves or rejoins one, and every write result names the `envoy_subscribe` line
40
+ for the whole owner.
36
41
 
37
42
  `dispatch_artifact` accepts exactly one upload source: a local `path`, or inline `content`.
38
43
  An architect can post a specification directly with
@@ -13682,6 +13682,11 @@ var SubscriptionRemovedEventPayloadSchema = object({
13682
13682
  pending: boolean2().optional(),
13683
13683
  request_event_id: number2().int().positive().optional()
13684
13684
  });
13685
+ var AskFollowerEventPayloadSchema = object({
13686
+ ask_id: string2().optional(),
13687
+ session_id: string2().optional(),
13688
+ by: object({ kind: string2(), id: string2().optional() }).passthrough().optional()
13689
+ });
13685
13690
  // ../contracts/src/dispatch-snippet.ts
13686
13691
  var HTML_ENTITIES = [
13687
13692
  ["&lt;", "<"],
@@ -13806,7 +13811,7 @@ var dispatchToolSpecs = [
13806
13811
  options: z.array(z.object({
13807
13812
  label: z.string().describe("Selectable option label."),
13808
13813
  description: z.string().describe("Optional option context.").optional()
13809
- }), { max: 8 }).describe("Optional choices, at most 8.").optional(),
13814
+ }), { max: 8 }).describe("Up to 8 choices, each an object { label, description? } (never a bare string).").optional(),
13810
13815
  multiple: z.boolean().describe("Whether multiple choices may be selected.").optional(),
13811
13816
  urgency: z.enum(ASK_URGENCIES).describe("Optional decision urgency.").optional(),
13812
13817
  anchor: z.object({
@@ -13847,6 +13852,15 @@ var dispatchToolSpecs = [
13847
13852
  reason: z.string({ min: 1 }).describe("Why the open ask no longer needs a human answer.")
13848
13853
  })
13849
13854
  },
13855
+ {
13856
+ name: "dispatch_follow",
13857
+ 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>.>",
13858
+ arguments: (z) => ({
13859
+ ask: z.string().describe("Full ask id (uuid)."),
13860
+ action: z.enum(["follow", "unfollow"]).describe("follow | unfollow")
13861
+ }),
13862
+ strict: true
13863
+ },
13850
13864
  {
13851
13865
  name: "dispatch_comment",
13852
13866
  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}`,
@@ -13858,7 +13872,7 @@ var dispatchToolSpecs = [
13858
13872
  quote: z.string().describe("Optional exact quoted document text.").optional(),
13859
13873
  occurrence: z.number({ int: true, min: 0 }).describe("Optional zero-based occurrence of quote.").optional(),
13860
13874
  body: z.string({ max: 2000 }).describe("Review comment, at most 2,000 characters."),
13861
- reply_to: z.string().describe("Full id of a comment to reply to; replying to any comment in a thread continues that " + "thread (an ask's clarification thread included).").optional(),
13875
+ reply_to: z.string().describe("A comment id (uuid); replying to any comment in a thread continues that thread (an " + "ask's clarification thread included). To reply to an ask, use reply_to_ask with the " + "ask id instead.").optional(),
13862
13876
  reply_to_ask: z.string().describe("Optional ask id to reply to, threading this comment under that question. Mutually " + "exclusive with reply_to.").optional(),
13863
13877
  turn: z.enum(["agent", "human"]).describe("Only with reply_to_ask: who holds the turn after this reply. agent: a progress note - " + "you keep the turn and the ask stays 'Waiting on agents' for the human; human (default): " + "you need the human to act - the ask returns to 'Waiting on you'.").optional()
13864
13878
  }),
@@ -13890,7 +13904,7 @@ var dispatchToolSpecs = [
13890
13904
  },
13891
13905
  {
13892
13906
  name: "dispatch_doc_edit",
13893
- description: "Apply deterministic document edits, including retyping an identified paragraph into a schema-declared typed block. " + "Do not use it for review feedback or for reading; use dispatch_comment, dispatch_suggest, or dispatch_doc_read instead. " + "For replace, delete, and quote insert anchors, find text exactly as rendered: omit Markdown markers such as backticks or asterisks. " + `The spec (or any document) holds requirements, design, and decisions - never progress, status, or timestamps. ${OWNER_REFERENCE} ${SPEC_WRITING_GUIDANCE}`,
13907
+ description: "Apply deterministic document edits, including retyping an identified paragraph into a schema-declared typed block. " + "Do not use it for review feedback or for reading; use dispatch_comment, dispatch_suggest, or dispatch_doc_read instead. " + 'For replace, delete, and quote insert anchors, find text as rendered: inline Markdown (**bold**, `code`) is tolerated; a leading \'# \' matches a heading. Insert anchors also accept "start", "end", and "heading:<exact heading text>". ' + `The spec (or any document) holds requirements, design, and decisions - never progress, status, or timestamps. ${OWNER_REFERENCE} ${SPEC_WRITING_GUIDANCE}`,
13894
13908
  arguments: (z) => ({
13895
13909
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
13896
13910
  project: z.string().describe("Project key owning the document.").optional(),
@@ -13898,12 +13912,12 @@ var dispatchToolSpecs = [
13898
13912
  ref: z.string().describe("Optional dispatch:// issue or document reference.").optional(),
13899
13913
  ops: z.array(z.object({
13900
13914
  op: z.enum(DOC_EDIT_OPS).describe("Edit operation."),
13901
- find: z.string().describe("Text to find for replace or delete.").optional(),
13915
+ find: z.string().describe("Text of the target block as rendered, for replace or delete; inline markdown (**bold**, `code`) is tolerated; a leading '# ' matches a heading.").optional(),
13902
13916
  with: z.string().describe("Replacement text for replace.").optional(),
13903
13917
  occurrence: z.number({ int: true, min: 0 }).describe("Optional zero-based match occurrence.").optional(),
13904
13918
  markdown: z.string().describe("Markdown to insert.").optional(),
13905
- after: z.string().describe("Anchor after which to insert.").optional(),
13906
- before: z.string().describe("Anchor before which to insert.").optional(),
13919
+ after: z.string().describe(`Insert after this anchor: a quote of the neighbouring block's text, or one of "start", "end", "heading:<exact heading text>".`).optional(),
13920
+ before: z.string().describe(`Insert before this anchor: a quote of the neighbouring block's text, or one of "start", "end", "heading:<exact heading text>".`).optional(),
13907
13921
  block: z.string().describe("Block id to retype.").optional(),
13908
13922
  type: z.string().describe("Typed block name for retype.").optional(),
13909
13923
  attributes: z.unknown().describe("Typed block attributes for retype.").optional()
@@ -14390,6 +14404,9 @@ function canonicalRepo(owner, repo) {
14390
14404
  return `${owner.trim().toLowerCase()}/${repo.trim().toLowerCase().replace(/\.git$/, "")}`;
14391
14405
  }
14392
14406
  // ../contracts/src/tool-schema.ts
14407
+ function overCapMessage(length, max) {
14408
+ return `is ${length - max} characters over the ${max}-character limit (${length}/${max})`;
14409
+ }
14393
14410
  function zodSchemaApi(zod) {
14394
14411
  const api = zod;
14395
14412
  return {
@@ -14397,8 +14414,12 @@ function zodSchemaApi(zod) {
14397
14414
  let schema = api.string();
14398
14415
  if (opts.min !== undefined)
14399
14416
  schema = schema.min(opts.min);
14400
- if (opts.max !== undefined)
14401
- schema = schema.max(opts.max);
14417
+ if (opts.max !== undefined) {
14418
+ const max = opts.max;
14419
+ schema = schema.max(max, {
14420
+ error: (issue) => overCapMessage(typeof issue.input === "string" ? issue.input.length : max + 1, max)
14421
+ });
14422
+ }
14402
14423
  return schema;
14403
14424
  },
14404
14425
  number: (opts = {}) => {
@@ -14597,6 +14618,24 @@ function resolveDispatchConfig(env, options = {}) {
14597
14618
  // ../envoy-client/src/dispatch-execute.ts
14598
14619
  import { resolve as resolvePath } from "path";
14599
14620
 
14621
+ // ../envoy-client/src/ask-answer.ts
14622
+ var HEAD_LENGTH = 120;
14623
+ function textHead(text, limit = HEAD_LENGTH) {
14624
+ const flat = text.replace(/\s+/g, " ").trim();
14625
+ return flat.length > limit ? `${flat.slice(0, limit)}\u2026` : flat;
14626
+ }
14627
+ function askAnswerText(answer) {
14628
+ if (answer === null || answer === undefined)
14629
+ return "";
14630
+ const selected = (answer.selected ?? []).join(", ");
14631
+ const text = answer.text ?? "";
14632
+ if (text === "")
14633
+ return selected;
14634
+ if (selected === "")
14635
+ return text;
14636
+ return `${selected} - ${text}`;
14637
+ }
14638
+
14600
14639
  // ../envoy-client/src/dispatch-cwd.ts
14601
14640
  import { execFile } from "child_process";
14602
14641
  import { promisify } from "util";
@@ -14778,6 +14817,9 @@ class DispatchClient {
14778
14817
  async comment(issue, input) {
14779
14818
  return this.#json("POST", ["api", "v1", "issues", await this.#resolveIssue(issue), "comments"], input);
14780
14819
  }
14820
+ async listIssueAsks(issue, state) {
14821
+ return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue), "asks"], undefined, state === undefined ? undefined : { state });
14822
+ }
14781
14823
  async getArtifactAsks(id, state) {
14782
14824
  return this.#json("GET", ["api", "v1", "artifacts", id, "asks"], undefined, state === undefined ? undefined : { state });
14783
14825
  }
@@ -14854,6 +14896,12 @@ class DispatchClient {
14854
14896
  async getAsk(id) {
14855
14897
  return this.#json("GET", ["api", "v1", "asks", id]);
14856
14898
  }
14899
+ async followAsk(id, sessionId, actor) {
14900
+ await this.#json("PUT", ["api", "v1", "asks", id, "followers", sessionId], { actor });
14901
+ }
14902
+ async unfollowAsk(id, sessionId, actor) {
14903
+ await this.#json("DELETE", ["api", "v1", "asks", id, "followers", sessionId], { actor });
14904
+ }
14857
14905
  async getComment(id) {
14858
14906
  return this.#json("GET", ["api", "v1", "comments", id]);
14859
14907
  }
@@ -14976,13 +15024,144 @@ class DispatchClient {
14976
15024
  }
14977
15025
  }
14978
15026
 
15027
+ // ../envoy-client/src/tool-input-errors.ts
15028
+ class ToolInputError extends Error {
15029
+ tool;
15030
+ problems;
15031
+ constructor(tool, problems) {
15032
+ const count = problems.length;
15033
+ super([
15034
+ `${tool} was not called: ${count} problem${count === 1 ? "" : "s"}`,
15035
+ ...problems.map((problem) => `- ${problem}`)
15036
+ ].join(`
15037
+ `));
15038
+ this.name = "ToolInputError";
15039
+ this.tool = tool;
15040
+ this.problems = problems;
15041
+ }
15042
+ }
15043
+ function unwrap(schema) {
15044
+ let current = schema;
15045
+ while (current !== undefined) {
15046
+ const def = current.def;
15047
+ if (!("innerType" in def) || !(def.type === "optional" || def.type === "nullable" || def.type === "default"))
15048
+ break;
15049
+ current = def.innerType;
15050
+ }
15051
+ return current;
15052
+ }
15053
+ function shapeOf(schema) {
15054
+ const unwrapped = unwrap(schema);
15055
+ if (unwrapped === undefined)
15056
+ return;
15057
+ const def = unwrapped.def;
15058
+ return def.type === "object" && "shape" in def ? def.shape : undefined;
15059
+ }
15060
+ function schemaAt(schema, path) {
15061
+ let current = schema;
15062
+ for (const key of path) {
15063
+ const unwrapped = unwrap(current);
15064
+ if (unwrapped === undefined)
15065
+ return;
15066
+ const def = unwrapped.def;
15067
+ if (def.type === "object" && "shape" in def) {
15068
+ current = shapeOf(unwrapped)?.[String(key)];
15069
+ } else if (def.type === "array" && "element" in def) {
15070
+ current = def.element;
15071
+ } else {
15072
+ return;
15073
+ }
15074
+ }
15075
+ return unwrap(current);
15076
+ }
15077
+ function describeInput(value) {
15078
+ if (value === null)
15079
+ return "null";
15080
+ if (Array.isArray(value))
15081
+ return "an array";
15082
+ switch (typeof value) {
15083
+ case "number":
15084
+ case "boolean":
15085
+ return String(value);
15086
+ case "string":
15087
+ return "a string";
15088
+ case "object":
15089
+ return "an object";
15090
+ default:
15091
+ return typeof value;
15092
+ }
15093
+ }
15094
+ function describeExpected(expected, schema) {
15095
+ switch (expected) {
15096
+ case "int":
15097
+ return "an integer";
15098
+ case "object": {
15099
+ const shape = shapeOf(schema);
15100
+ const keys = shape === undefined ? [] : Object.entries(shape).map(([key, field]) => `${key}${field.def.type === "optional" ? "?" : ""}`);
15101
+ return keys.length === 0 ? "an object" : `an object {${keys.join(", ")}}`;
15102
+ }
15103
+ case "array":
15104
+ return "an array";
15105
+ default:
15106
+ return `a ${expected}`;
15107
+ }
15108
+ }
15109
+ function formatZodIssues(issues, schema) {
15110
+ const allowed = Object.keys(shapeOf(schema) ?? {}).join(", ");
15111
+ return issues.flatMap((issue) => {
15112
+ const path = issue.path.map(String).join(".");
15113
+ switch (issue.code) {
15114
+ case "invalid_type":
15115
+ return issue.input === undefined ? [`${path} is required (${issue.expected})`] : [
15116
+ `${path} must be ${describeExpected(issue.expected, schemaAt(schema, issue.path))}, not ${describeInput(issue.input)}`
15117
+ ];
15118
+ case "unrecognized_keys":
15119
+ return issue.keys.map((key) => `unknown field "${key}"; allowed: ${allowed}`);
15120
+ case "invalid_value":
15121
+ return [
15122
+ `${path} must be one of ${issue.values.map(String).join("|")}; got ${JSON.stringify(issue.input)}`
15123
+ ];
15124
+ case "too_big":
15125
+ if (issue.origin === "array" && Array.isArray(issue.input)) {
15126
+ return [`${path} has ${issue.input.length} items; the limit is ${issue.maximum}`];
15127
+ }
15128
+ return [`${path} ${issue.message}`];
15129
+ case "custom":
15130
+ return [issue.message];
15131
+ default:
15132
+ return [path === "" ? issue.message : `${path}: ${issue.message}`];
15133
+ }
15134
+ });
15135
+ }
15136
+
14979
15137
  // ../envoy-client/src/dispatch-execute.ts
15138
+ function issueTopic(key) {
15139
+ return { label: key, topic: dispatchIssueSubject(key, ">") };
15140
+ }
15141
+ function documentTopic(artifact) {
15142
+ return {
15143
+ label: `${artifact.project}/${artifact.slug}`,
15144
+ topic: dispatchDocumentSubject(artifact.project, artifact.slug, ">")
15145
+ };
15146
+ }
15147
+ function resolvedTopic(resolved) {
15148
+ if (resolved.owner.kind === "project")
15149
+ return documentTopic(resolved.artifact);
15150
+ if (resolved.issue === undefined)
15151
+ throw new Error("issue document is missing its issue");
15152
+ return issueTopic(resolved.issue.key);
15153
+ }
15154
+ function notSubscribed(owner) {
15155
+ return `(not subscribed to ${owner.label}; envoy_subscribe ${owner.topic} for every event on it)`;
15156
+ }
15157
+ function followsAsk(owner) {
15158
+ return `You follow this ask: its answer and replies reach you directly. For every event on ${owner.label}: envoy_subscribe ${owner.topic}`;
15159
+ }
14980
15160
  function documentResultDetails(artifact) {
14981
15161
  return {
14982
15162
  project: artifact.project,
14983
15163
  artifact: artifact.id,
14984
- document: `${artifact.project}/${artifact.slug}`,
14985
- topic: dispatchDocumentSubject(artifact.project, artifact.slug, ">")
15164
+ document: `${artifact.project}/${artifact.slug}`
14986
15165
  };
14987
15166
  }
14988
15167
  function writeResultDetails(resolved, fields) {
@@ -14991,19 +15170,11 @@ function writeResultDetails(resolved, fields) {
14991
15170
  }
14992
15171
  if (resolved.issue === undefined)
14993
15172
  throw new Error("issue document is missing its issue");
14994
- return {
14995
- issue: resolved.issue.key,
14996
- topic: dispatchIssueSubject(resolved.issue.key, ">"),
14997
- ...fields
14998
- };
15173
+ return { issue: resolved.issue.key, ...fields };
14999
15174
  }
15000
- async function askResultDetails(client, ask, resolved) {
15175
+ async function askOwnerDetails(client, ask, resolved) {
15001
15176
  if (ask.issue_key !== null) {
15002
- return {
15003
- issue: ask.issue_key,
15004
- topic: dispatchIssueSubject(ask.issue_key, ">"),
15005
- ask: ask.id
15006
- };
15177
+ return { issue: ask.issue_key, ask: ask.id };
15007
15178
  }
15008
15179
  if (ask.artifact_id === undefined || ask.artifact_id === null) {
15009
15180
  throw new Error("document ask is missing its artifact ID");
@@ -15011,6 +15182,9 @@ async function askResultDetails(client, ask, resolved) {
15011
15182
  const artifact = resolved?.artifact ?? await client.getArtifact(ask.artifact_id);
15012
15183
  return { ...documentResultDetails(artifact), ask: ask.id };
15013
15184
  }
15185
+ async function followedAskDetails(client, ask, resolved) {
15186
+ return { ...await askOwnerDetails(client, ask, resolved), follows: { ask: ask.id } };
15187
+ }
15014
15188
  var nativeIssueKeyPattern = /^[A-Z][A-Z0-9]{1,9}-[0-9]+$/;
15015
15189
  var externalIssueRefPattern = /^([^/\s]+)\/([^/\s#]+)#([1-9][0-9]*)$/;
15016
15190
  var bareIssueNumberPattern = /^[1-9][0-9]*$/;
@@ -15018,6 +15192,7 @@ var issueFreeTools = {
15018
15192
  dispatch_issue: true,
15019
15193
  dispatch_edit_ask: true,
15020
15194
  dispatch_resolve_ask: true,
15195
+ dispatch_follow: true,
15021
15196
  dispatch_search: true,
15022
15197
  dispatch_open_asks: true
15023
15198
  };
@@ -15070,23 +15245,19 @@ function askUrgency(args) {
15070
15245
  return ASK_URGENCIES.find((urgency) => urgency === value);
15071
15246
  }
15072
15247
  function askKind(args) {
15073
- const value = optionalString(args, "kind");
15074
- if (value === undefined || value === "action")
15075
- return value;
15076
- throw new Error("kind must be action");
15248
+ return optionalString(args, "kind") === "action" ? "action" : undefined;
15077
15249
  }
15078
- function askQuestionWithRef(args) {
15079
- const question = stringArg(args, "question");
15080
- const ref = optionalString(args, "ref");
15081
- if (ref === undefined || question.includes(ref))
15082
- return question;
15083
- const withRef = `${question}
15250
+ var maxAskQuestion16 = 800;
15251
+ function questionWithRef(question, ref) {
15252
+ return ref === undefined || question.includes(ref) ? question : `${question}
15084
15253
 
15085
15254
  Ref: ${ref}`;
15086
- if (withRef.length > 800) {
15087
- throw new Error("question plus ref must be at most 800 characters");
15088
- }
15089
- return withRef;
15255
+ }
15256
+ function askQuestionWithRef(args) {
15257
+ return questionWithRef(stringArg(args, "question"), optionalString(args, "ref"));
15258
+ }
15259
+ function askQuestionProblem(withRef) {
15260
+ return withRef.length > maxAskQuestion16 ? `question plus ref ${overCapMessage(withRef.length, maxAskQuestion16)}; shorten the question or drop the ref` : undefined;
15090
15261
  }
15091
15262
  function parseDispatchRef(ref) {
15092
15263
  const projectDocument = ref.match(/^dispatch:\/\/([A-Z][A-Z0-9]{1,9})\/artifact\/([^/@]+)(?:@v(\d+))?(?:\/(ask|comment)\/([^/]+))?$/);
@@ -15108,7 +15279,8 @@ function parseDispatchRef(ref) {
15108
15279
  return {
15109
15280
  owner: { kind: "project", project },
15110
15281
  kind: targetKind,
15111
- id: targetID
15282
+ id: targetID,
15283
+ artifact
15112
15284
  };
15113
15285
  }
15114
15286
  const issueReference = ref.match(/^dispatch:\/\/([A-Z][A-Z0-9]{1,9}-[1-9][0-9]*)(?:\/(spec)|\/(log)|\/(children)|\/artifact\/([^/@]+)(?:@v(\d+))?|\/ask\/([^/]+)|\/comment\/([^/]+)|\/message\/([^/]+))?$/);
@@ -15140,60 +15312,171 @@ function parseDispatchRef(ref) {
15140
15312
  return { owner, kind: "message", id: message };
15141
15313
  return { owner, kind: "issue", id: issue };
15142
15314
  }
15315
+ var uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15316
+ var askIdProblem = "ask must be a bare ask id or a dispatch://.../ask/<id> reference";
15317
+ var messageIdProblem = "in_reply_to must be a full message id (uuid) or a dispatch://KEY/message/<id> reference";
15143
15318
  function askId(args) {
15144
15319
  const ask = stringArg(args, "ask");
15145
- if (!ask.startsWith("dispatch://"))
15146
- return ask;
15147
- const reference = parseDispatchRef(ask);
15148
- if (reference?.kind !== "ask") {
15149
- throw new Error("ask must be a bare ask id or a dispatch://.../ask/<id> reference");
15320
+ return ask.startsWith("dispatch://") ? parseDispatchRef(ask)?.id ?? ask : ask;
15321
+ }
15322
+ function messageIdOf(value) {
15323
+ const id = value.startsWith("dispatch://") ? (() => {
15324
+ const reference = parseDispatchRef(value);
15325
+ return reference?.kind === "message" ? reference.id : undefined;
15326
+ })() : value;
15327
+ return id !== undefined && uuidPattern.test(id) ? id : undefined;
15328
+ }
15329
+ var idPrefixPattern = /^[0-9a-f][0-9a-f-]{7,}$/i;
15330
+ async function resolveIdPrefix(tool, kind, ref, list) {
15331
+ if (uuidPattern.test(ref.id))
15332
+ return ref.id;
15333
+ const ownerName = ref.owner.kind === "issue" ? ref.owner.issue : `${ref.owner.project}/${ref.artifact}`;
15334
+ if (!idPrefixPattern.test(ref.id)) {
15335
+ throw new ToolInputError(tool, [
15336
+ `${kind} id ${ref.id} must be a full uuid or a prefix of at least 8 hex characters`
15337
+ ]);
15150
15338
  }
15151
- return reference.id;
15339
+ const prefix = ref.id.toLowerCase();
15340
+ const matches = (await list()).filter((item) => item.id.toLowerCase().startsWith(prefix));
15341
+ if (matches.length === 1 && matches[0] !== undefined)
15342
+ return matches[0].id;
15343
+ throw new ToolInputError(tool, [
15344
+ matches.length === 0 ? `${kind} id ${ref.id} matches none of the ${kind}s on ${ownerName}; use the full id` : `${kind} id ${ref.id} matches ${matches.length} ${kind}s on ${ownerName}; use the full id`
15345
+ ]);
15152
15346
  }
15153
15347
  function messageInReplyTo(args) {
15154
15348
  const inReplyTo = optionalString(args, "in_reply_to");
15155
- if (inReplyTo === undefined || !inReplyTo.startsWith("dispatch://"))
15156
- return inReplyTo;
15157
- const reference = parseDispatchRef(inReplyTo);
15158
- if (reference?.kind !== "message") {
15159
- throw new Error("in_reply_to must be a bare message id or a dispatch://.../message/<id> reference");
15349
+ return inReplyTo === undefined ? undefined : messageIdOf(inReplyTo);
15350
+ }
15351
+ function argumentProblems(tool, args) {
15352
+ const problems = [];
15353
+ switch (tool) {
15354
+ case "dispatch_ask": {
15355
+ const question = optionalString(args, "question");
15356
+ const ref = optionalString(args, "ref");
15357
+ if (question !== undefined && ref !== undefined && question.length <= maxAskQuestion16) {
15358
+ const problem = askQuestionProblem(questionWithRef(question, ref));
15359
+ if (problem !== undefined)
15360
+ problems.push(problem);
15361
+ }
15362
+ break;
15363
+ }
15364
+ case "dispatch_edit_ask":
15365
+ case "dispatch_resolve_ask": {
15366
+ const ask = optionalString(args, "ask");
15367
+ if (ask?.startsWith("dispatch://") && parseDispatchRef(ask)?.kind !== "ask") {
15368
+ problems.push(askIdProblem);
15369
+ }
15370
+ break;
15371
+ }
15372
+ case "dispatch_comment": {
15373
+ if (optionalString(args, "quote") !== undefined && optionalString(args, "artifact") === undefined) {
15374
+ problems.push("artifact is required when quote is supplied");
15375
+ }
15376
+ if (optionalString(args, "reply_to") !== undefined && optionalString(args, "reply_to_ask") !== undefined) {
15377
+ problems.push("reply_to and reply_to_ask cannot both be set");
15378
+ }
15379
+ break;
15380
+ }
15381
+ case "dispatch_message": {
15382
+ const inReplyTo = optionalString(args, "in_reply_to");
15383
+ if (inReplyTo !== undefined && messageIdOf(inReplyTo) === undefined) {
15384
+ problems.push(messageIdProblem);
15385
+ }
15386
+ break;
15387
+ }
15160
15388
  }
15161
- return reference.id;
15389
+ return problems;
15162
15390
  }
15391
+ function dispatchRefFromUrl(value, serverUrl) {
15392
+ let url;
15393
+ let origin;
15394
+ try {
15395
+ url = new URL(value);
15396
+ origin = new URL(serverUrl).origin;
15397
+ } catch {
15398
+ return;
15399
+ }
15400
+ if (url.origin !== origin)
15401
+ return;
15402
+ const versionOf = (name) => {
15403
+ const value = url.searchParams.get(name);
15404
+ return value !== null && /^[1-9][0-9]*$/.test(value) ? `@v${value}` : "";
15405
+ };
15406
+ const issuePage = url.pathname.match(/^\/issues\/([A-Z][A-Z0-9]{1,9}-[1-9][0-9]*)(?:\/(spec|log|conversation|children)|\/artifacts\/([^/]+)|\/asks\/([^/]+)|\/comments\/([^/]+)|\/messages\/([^/]+))?\/?$/);
15407
+ if (issuePage) {
15408
+ const [, key, page, artifact, ask, comment, message] = issuePage;
15409
+ const version = versionOf("v");
15410
+ if (page === "spec" && version !== "")
15411
+ return `dispatch://${key}/artifact/spec${version}`;
15412
+ if (page !== undefined)
15413
+ return `dispatch://${key}/${page === "conversation" ? "log" : page}`;
15414
+ if (artifact !== undefined) {
15415
+ return `dispatch://${key}/artifact/${decodeURIComponent(artifact)}${version}`;
15416
+ }
15417
+ if (ask !== undefined)
15418
+ return `dispatch://${key}/ask/${ask}`;
15419
+ if (comment !== undefined)
15420
+ return `dispatch://${key}/comment/${comment}`;
15421
+ if (message !== undefined)
15422
+ return `dispatch://${key}/message/${message}`;
15423
+ return `dispatch://${key}`;
15424
+ }
15425
+ const documentPage = url.pathname.match(/^\/projects\/([A-Z][A-Z0-9]{1,9})\/documents\/([^/]+)\/?$/);
15426
+ if (!documentPage)
15427
+ return;
15428
+ const [, project, slug] = documentPage;
15429
+ const document = `dispatch://${project}/artifact/${decodeURIComponent(slug ?? "")}${versionOf("version")}`;
15430
+ const ask = url.searchParams.get("ask");
15431
+ if (ask !== null)
15432
+ return `${document}/ask/${ask}`;
15433
+ const comment = url.searchParams.get("comment");
15434
+ if (comment !== null)
15435
+ return `${document}/comment/${comment}`;
15436
+ return document;
15437
+ }
15438
+ var refGrammarProblem = "ref must be a valid dispatch:// reference such as dispatch://KEY-1, " + "dispatch://KEY-1/ask/<uuid>, dispatch://KEY-1/comment/<uuid>, " + "dispatch://KEY-1/message/<uuid>, dispatch://KEY-1/artifact/<slug>, or " + "dispatch://PROJECT/artifact/<document-ref> (an artifact id, slug, or filename); " + "a dashboard URL on this Dispatch server is accepted too";
15439
+ var ownerRequiredProblem = "issue is required; supply issue or set LEGION_ISSUE";
15163
15440
  function toolSchema(tool) {
15164
15441
  const spec = dispatchToolSpecs.find((candidate) => candidate.name === tool);
15165
15442
  if (!spec)
15166
15443
  throw new Error(`Unknown Dispatch tool: ${tool}`);
15167
15444
  return dispatchToolSchema(spec, zodSchemaApi(exports_external), { strict: true });
15168
15445
  }
15169
- async function resolveOwnerArguments(tool, args, cwd, env, exec) {
15446
+ async function resolveOwnerArguments(tool, input, cwd, env, exec, serverUrl, problems) {
15170
15447
  if (issueFreeTools[tool] === true)
15171
- return { args, ref: null, owner: null };
15172
- const refArgument = args.ref;
15173
- if (tool === "dispatch_ask" && typeof refArgument === "string" && !refArgument.startsWith("dispatch://")) {
15174
- throw new Error("ref must be a dispatch:// reference");
15175
- }
15176
- const ref = typeof refArgument === "string" ? parseDispatchRef(refArgument) ?? (() => {
15177
- throw new Error("ref must be a valid dispatch:// reference such as dispatch://KEY-1, " + "dispatch://KEY-1/ask/<uuid>, dispatch://KEY-1/comment/<uuid>, " + "dispatch://KEY-1/message/<uuid>, dispatch://KEY-1/artifact/<slug>, or " + "dispatch://PROJECT/artifact/<document-ref> (an artifact id, slug, or filename)");
15178
- })() : null;
15448
+ return { args: input, ref: null, owner: null };
15449
+ const refArgument = input.ref;
15450
+ let ref = null;
15451
+ let args = input;
15452
+ if (typeof refArgument === "string") {
15453
+ const refText = dispatchRefFromUrl(refArgument, serverUrl) ?? refArgument;
15454
+ if (refText !== refArgument)
15455
+ args = { ...input, ref: refText };
15456
+ ref = parseDispatchRef(refText);
15457
+ if (ref === null) {
15458
+ problems.push(tool === "dispatch_ask" && !refText.startsWith("dispatch://") ? "ref must be a dispatch:// reference" : refGrammarProblem);
15459
+ }
15460
+ }
15179
15461
  const issueArgument = args.issue;
15180
15462
  const projectArgument = args.project;
15181
15463
  const artifactArgument = args.artifact;
15182
15464
  const versionArgument = args.version;
15183
15465
  if (issueArgument !== undefined && projectArgument !== undefined) {
15184
- throw new Error("exactly one of issue and project is required");
15466
+ problems.push("exactly one of issue and project is required");
15185
15467
  }
15186
15468
  if (typeof projectArgument === "string") {
15187
15469
  if (!/^[A-Z][A-Z0-9]{1,9}$/.test(projectArgument)) {
15188
- throw new Error("project must be a project key such as CORE");
15470
+ problems.push("project must be a project key such as CORE");
15189
15471
  }
15190
- if (ref?.owner.kind === "project" && (ref.owner.project !== projectArgument || artifactArgument !== undefined && artifactArgument !== ref.id)) {
15191
- throw new Error("project and ref must name the same document");
15472
+ const refDocument = ref?.owner.kind === "project" ? ref.artifact ?? ref.id : undefined;
15473
+ if (ref?.owner.kind === "project" && (ref.owner.project !== projectArgument || artifactArgument !== undefined && artifactArgument !== refDocument)) {
15474
+ problems.push("project and ref must name the same document");
15192
15475
  }
15193
15476
  return {
15194
15477
  args: {
15195
15478
  ...args,
15196
- ...artifactArgument === undefined && ref?.owner.kind === "project" ? { artifact: ref.id } : {},
15479
+ ...artifactArgument === undefined && refDocument !== undefined ? { artifact: refDocument } : {},
15197
15480
  ...versionArgument === undefined && ref?.version !== undefined ? { version: ref.version } : {}
15198
15481
  },
15199
15482
  ref,
@@ -15205,7 +15488,7 @@ async function resolveOwnerArguments(tool, args, cwd, env, exec) {
15205
15488
  args: {
15206
15489
  ...args,
15207
15490
  project: ref.owner.project,
15208
- ...artifactArgument === undefined ? { artifact: ref.id } : {},
15491
+ ...artifactArgument === undefined ? { artifact: ref.artifact ?? ref.id } : {},
15209
15492
  ...versionArgument === undefined && ref.version !== undefined ? { version: ref.version } : {}
15210
15493
  },
15211
15494
  ref,
@@ -15226,20 +15509,25 @@ async function resolveOwnerArguments(tool, args, cwd, env, exec) {
15226
15509
  };
15227
15510
  }
15228
15511
  const legionIssue = env.LEGION_ISSUE;
15229
- if (!legionIssue)
15230
- throw new Error("issue is required; supply issue or set LEGION_ISSUE");
15512
+ if (!legionIssue) {
15513
+ problems.push(ownerRequiredProblem);
15514
+ return { args, ref, owner: null };
15515
+ }
15231
15516
  if (nativeIssueKeyPattern.test(legionIssue) || externalIssueRefPattern.test(legionIssue)) {
15232
15517
  const issue = canonicalExternalIssueRef(legionIssue);
15233
- return { args: { ...args, issue }, ref: null, owner: { kind: "issue", issue } };
15518
+ return { args: { ...args, issue }, ref, owner: { kind: "issue", issue } };
15234
15519
  }
15235
15520
  if (!bareIssueNumberPattern.test(legionIssue)) {
15236
- throw new Error("LEGION_ISSUE must be a native issue key (e.g. LEGION-3), an external owner/repo#n reference, or a bare positive issue number");
15521
+ problems.push("LEGION_ISSUE must be a native issue key (e.g. LEGION-3), an external owner/repo#n reference, or a bare positive issue number");
15522
+ return { args, ref, owner: null };
15237
15523
  }
15238
15524
  const repo = await resolveCwdRepo(cwd, exec);
15239
- if (!repo)
15240
- throw new Error("issue is required; LEGION_ISSUE needs a GitHub repository in cwd");
15525
+ if (!repo) {
15526
+ problems.push("issue is required; LEGION_ISSUE needs a GitHub repository in cwd");
15527
+ return { args, ref, owner: null };
15528
+ }
15241
15529
  const issue = `${repo}#${legionIssue}`;
15242
- return { args: { ...args, issue }, ref: null, owner: { kind: "issue", issue } };
15530
+ return { args: { ...args, issue }, ref, owner: { kind: "issue", issue } };
15243
15531
  }
15244
15532
  async function resolveArtifact(client, owner, artifactReference) {
15245
15533
  if (owner.kind === "project") {
@@ -15270,7 +15558,8 @@ async function resolveArtifact(client, owner, artifactReference) {
15270
15558
  const issue = await client.getIssue(owner.issue);
15271
15559
  const artifact = artifactReference === undefined || artifactReference === "spec" ? issue.artifacts.find((candidate) => candidate.primary || candidate.id === issue.primary_artifact_id) : issue.artifacts.find((candidate) => candidate.id === artifactReference || candidate.slug === artifactReference || candidate.name === artifactReference);
15272
15560
  if (!artifact) {
15273
- throw new Error(`artifact ${artifactReference ?? "spec"} was not found on issue ${issue.key}`);
15561
+ const slugs = issue.artifacts.map((candidate) => `${candidate.slug}${candidate.primary || candidate.id === issue.primary_artifact_id ? " (primary)" : ""}`);
15562
+ throw new Error(`artifact "${artifactReference ?? "spec"}" was not found on issue ${issue.key}; artifacts: ${slugs.length === 0 ? "none" : slugs.join(", ")}`);
15274
15563
  }
15275
15564
  return { owner, issue, artifact };
15276
15565
  }
@@ -15327,7 +15616,7 @@ function issueSummary(issue, events, references) {
15327
15616
  ...typeof references === "string" ? [`- ${references}`] : references.members.length === 0 ? ["- none"] : references.members.map(({ artifact, depth, via }) => `- ${artifact.project}/${artifact.slug} \xB7 depth ${depth} via ${via.kind} ${via.id}`),
15328
15617
  ...typeof references === "string" || !references.truncated ? [] : ["- more references beyond 8 hops"],
15329
15618
  "Events:",
15330
- ...events.length === 0 ? ["- none"] : events.map((event) => `- #${event.seq} ${event.type} \xB7 ${event.actor.kind} ${event.actor.id} \xB7 ${event.created_at}`)
15619
+ ...events.length === 0 ? ["- none"] : events.map(eventLine)
15331
15620
  ].join(`
15332
15621
  `);
15333
15622
  }
@@ -15335,10 +15624,41 @@ function logSummary(issue, events) {
15335
15624
  return [
15336
15625
  `Key: ${issue.key}`,
15337
15626
  "Events:",
15338
- ...events.length === 0 ? ["- none"] : events.map((event) => `- #${event.seq} ${event.type} \xB7 ${event.actor.kind} ${event.actor.id} \xB7 ${event.created_at}`)
15627
+ ...events.length === 0 ? ["- none"] : events.map(eventLine)
15339
15628
  ].join(`
15340
15629
  `);
15341
15630
  }
15631
+ function eventHead(event) {
15632
+ switch (event.type) {
15633
+ case "ask.opened":
15634
+ case "ask.edited":
15635
+ case "ask.resolved":
15636
+ return textHead(event.payload.question);
15637
+ case "ask.answered":
15638
+ return `${textHead(event.payload.question)} -> ${textHead(askAnswerText(event.payload.answer))}`;
15639
+ case "comment.created":
15640
+ case "comment.edited":
15641
+ case "comment.resolved":
15642
+ case "comment.reopened":
15643
+ case "suggestion.accepted":
15644
+ case "suggestion.rejected":
15645
+ case "message.created":
15646
+ case "message.answered":
15647
+ return textHead(event.payload.body);
15648
+ case "artifact.version":
15649
+ return textHead(`${event.payload.name} v${event.payload.version.number}${event.payload.version.summary ? `: ${event.payload.version.summary}` : ""}`);
15650
+ case "issue.created":
15651
+ case "issue.updated":
15652
+ case "issue.closed":
15653
+ return `status ${event.payload.status}`;
15654
+ default:
15655
+ return;
15656
+ }
15657
+ }
15658
+ function eventLine(event) {
15659
+ const head = eventHead(event);
15660
+ return `- #${event.seq} ${event.type} \xB7 ${event.actor.kind} ${event.actor.id} \xB7 ${event.created_at}${head === undefined || head === "" ? "" : ` \xB7 ${head}`}`;
15661
+ }
15342
15662
  function childrenSummary(issue) {
15343
15663
  return [
15344
15664
  `Key: ${issue.key}`,
@@ -15473,19 +15793,29 @@ async function executeDispatchTool(input) {
15473
15793
  throw error;
15474
15794
  }
15475
15795
  }, { preconnect: baseFetch.preconnect });
15796
+ const env = input.env ?? process.env;
15797
+ const exec = input.exec ?? defaultExec;
15798
+ const problems = [];
15799
+ const ownerArguments = await resolveOwnerArguments(input.tool, input.args, input.cwd, env, exec, configUrl, problems);
15800
+ const ownerMissing = issueFreeTools[input.tool] !== true && ownerArguments.owner === null;
15801
+ const schema = toolSchema(input.tool);
15802
+ const parsed = schema.safeParse(ownerArguments.args, { reportInput: true });
15803
+ if (!parsed.success) {
15804
+ const issues = parsed.error.issues.filter((issue) => !ownerMissing || !(issue.code === "invalid_type" && issue.path.length === 1 && issue.path[0] === "issue" && issue.input === undefined || issue.code === "custom" && issue.path.length === 0 && issue.message.startsWith("Exactly one of issue and project is required")));
15805
+ problems.push(...formatZodIssues(issues, schema));
15806
+ }
15807
+ problems.push(...argumentProblems(input.tool, ownerArguments.args));
15808
+ if (problems.length > 0)
15809
+ throw new ToolInputError(input.tool, problems);
15476
15810
  if (input.tool === "dispatch_open_asks") {
15477
15811
  const sessionId = input.sessionId?.trim();
15478
15812
  if (!sessionId)
15479
15813
  throw new Error("host session id is required for dispatch_open_asks");
15480
- toolSchema(input.tool).parse(input.args);
15481
15814
  const client = new DispatchClient(configUrl, configToken, fetchImpl, input.signal);
15482
15815
  const response = await client.openAsks(sessionId);
15483
15816
  return { text: formatOpenAsksSummary(response, configUrl), details: { ...response } };
15484
15817
  }
15485
- const env = input.env ?? process.env;
15486
- const exec = input.exec ?? defaultExec;
15487
- const ownerArguments = await resolveOwnerArguments(input.tool, input.args, input.cwd, env, exec);
15488
- const args = toolSchema(input.tool).parse(ownerArguments.args);
15818
+ const args = parsed.success ? parsed.data : ownerArguments.args;
15489
15819
  const actor = toolActor(await resolveOrigin(env, exec, input.cwd), input);
15490
15820
  const client = new DispatchClient(configUrl, configToken, fetchImpl, input.signal);
15491
15821
  const owner = ownerArguments.owner?.kind === "issue" ? {
@@ -15525,8 +15855,8 @@ async function executeDispatchTool(input) {
15525
15855
  actor
15526
15856
  });
15527
15857
  return {
15528
- text: `Created ${created.key}: ${created.title}`,
15529
- details: { issue: created.key, topic: dispatchIssueSubject(created.key, ">") }
15858
+ text: `Created ${created.key}: ${created.title} ${notSubscribed(issueTopic(created.key))}`,
15859
+ details: { issue: created.key }
15530
15860
  };
15531
15861
  } catch (error) {
15532
15862
  if (!(error instanceof DispatchServiceError) || error.code !== "POSSIBLE_DUPLICATE") {
@@ -15568,8 +15898,6 @@ async function executeDispatchTool(input) {
15568
15898
  }
15569
15899
  case "dispatch_resolve_ask": {
15570
15900
  const kind = stringArg(args, "kind");
15571
- if (kind !== "retracted" && kind !== "resolved")
15572
- throw new Error("kind must be retracted or resolved");
15573
15901
  const ask = await client.resolveAsk(stringArg(args, "ask"), {
15574
15902
  kind,
15575
15903
  reason: stringArg(args, "reason"),
@@ -15579,7 +15907,7 @@ async function executeDispatchTool(input) {
15579
15907
  throw new Error("resolved ask is missing its resolution");
15580
15908
  return {
15581
15909
  text: `${kind === "retracted" ? "Retracted" : "Resolved"} ask ${ask.id}: ${ask.resolution.reason}`,
15582
- details: await askResultDetails(client, ask)
15910
+ details: await askOwnerDetails(client, ask)
15583
15911
  };
15584
15912
  }
15585
15913
  case "dispatch_ask": {
@@ -15602,9 +15930,11 @@ async function executeDispatchTool(input) {
15602
15930
  actor
15603
15931
  };
15604
15932
  const ask = resolved?.owner.kind === "project" ? await client.artifactAsk(resolved.artifact.id, askInput) : await client.ask(issue(), askInput);
15933
+ const askOwner = ask.issue_key !== null ? issueTopic(ask.issue_key) : resolved === undefined ? issueTopic(issue()) : documentTopic(resolved.artifact);
15605
15934
  return {
15606
- text: `Opened ask ${ask.id}: ${ask.question}`,
15607
- details: await askResultDetails(client, ask, resolved)
15935
+ text: `Asked ${ask.id} on ${askOwner.label} (urgency ${ask.urgency}): ${ask.question}
15936
+ ${followsAsk(askOwner)}`,
15937
+ details: await followedAskDetails(client, ask, resolved)
15608
15938
  };
15609
15939
  }
15610
15940
  case "dispatch_edit_ask": {
@@ -15621,29 +15951,17 @@ async function executeDispatchTool(input) {
15621
15951
  });
15622
15952
  return {
15623
15953
  text: `Ask edited: ${ask.question}`,
15624
- details: await askResultDetails(client, ask)
15954
+ details: await askOwnerDetails(client, ask)
15625
15955
  };
15626
15956
  }
15627
15957
  case "dispatch_comment": {
15628
15958
  const artifactReference = optionalString(args, "artifact");
15629
- if (optionalString(args, "quote") !== undefined && artifactReference === undefined) {
15630
- throw new Error("artifact is required when quote is supplied");
15631
- }
15632
15959
  const owner = documentOwner();
15633
15960
  const resolved = owner.kind === "project" || artifactReference === undefined ? owner.kind === "project" ? await resolveArtifact(client, owner, artifactReference) : undefined : await resolveArtifact(client, owner, artifactReference);
15634
15961
  const anchored = resolved ? anchor(resolved.artifact, args) : undefined;
15635
15962
  const replyTo = optionalString(args, "reply_to");
15636
15963
  const replyToAsk = optionalString(args, "reply_to_ask");
15637
- if (replyTo !== undefined && replyToAsk !== undefined) {
15638
- throw new Error("reply_to and reply_to_ask cannot both be set");
15639
- }
15640
15964
  const requestedTurn = optionalString(args, "turn");
15641
- if (requestedTurn !== undefined && replyToAsk === undefined) {
15642
- throw new Error("turn requires reply_to_ask");
15643
- }
15644
- if (requestedTurn !== undefined && requestedTurn !== "agent" && requestedTurn !== "human") {
15645
- throw new Error("turn must be agent or human");
15646
- }
15647
15965
  const commentInput = {
15648
15966
  body: stringArg(args, "body"),
15649
15967
  ...anchored === undefined ? {} : { anchor: anchored },
@@ -15653,18 +15971,23 @@ async function executeDispatchTool(input) {
15653
15971
  actor
15654
15972
  };
15655
15973
  const comment = resolved?.owner.kind === "project" ? await client.artifactComment(resolved.artifact.id, commentInput) : await client.comment(issue(), commentInput);
15656
- const askState = comment.turn === null ? "" : ` (ask now waiting on ${comment.turn})`;
15974
+ const commentOwner = resolved === undefined ? issueTopic(issue()) : resolvedTopic(resolved);
15975
+ const commentDetails = resolved === undefined ? { issue: comment.issue_key, comment: comment.id } : writeResultDetails(resolved, { comment: comment.id });
15976
+ if (replyToAsk !== undefined) {
15977
+ const askState = comment.turn === null ? "" : `; ask now waiting on ${comment.turn}`;
15978
+ return {
15979
+ text: `Replied on ask ${replyToAsk} (comment ${comment.id}${askState}). ${followsAsk(commentOwner)}`,
15980
+ details: {
15981
+ ...commentDetails,
15982
+ ask: replyToAsk,
15983
+ follows: { ask: replyToAsk },
15984
+ ...comment.turn === null ? {} : { ask_waiting_on: comment.turn }
15985
+ }
15986
+ };
15987
+ }
15657
15988
  return {
15658
- text: `Posted comment ${comment.id}${askState}`,
15659
- details: resolved === undefined ? {
15660
- issue: comment.issue_key,
15661
- topic: dispatchIssueSubject(issue(), ">"),
15662
- comment: comment.id,
15663
- ...comment.turn === null ? {} : { ask_waiting_on: comment.turn }
15664
- } : writeResultDetails(resolved, {
15665
- comment: comment.id,
15666
- ...comment.turn === null ? {} : { ask_waiting_on: comment.turn }
15667
- })
15989
+ text: `Posted comment ${comment.id} ${notSubscribed(commentOwner)}`,
15990
+ details: commentDetails
15668
15991
  };
15669
15992
  }
15670
15993
  case "dispatch_suggest": {
@@ -15681,7 +16004,7 @@ async function executeDispatchTool(input) {
15681
16004
  };
15682
16005
  const comment = resolved.owner.kind === "project" ? await client.artifactSuggest(resolved.artifact.id, suggestionInput) : await client.suggest(issue(), suggestionInput);
15683
16006
  return {
15684
- text: `Posted suggestion ${comment.id}`,
16007
+ text: `Posted suggestion ${comment.id} ${notSubscribed(resolvedTopic(resolved))}`,
15685
16008
  details: writeResultDetails(resolved, { comment: comment.id })
15686
16009
  };
15687
16010
  }
@@ -15695,12 +16018,8 @@ async function executeDispatchTool(input) {
15695
16018
  });
15696
16019
  const messageRef = `dispatch://${issueKey}/message/${message.id}`;
15697
16020
  return {
15698
- text: `Posted message ${message.id} (${messageRef})`,
15699
- details: {
15700
- issue: issueKey,
15701
- topic: dispatchIssueSubject(issueKey, ">"),
15702
- message: message.id
15703
- }
16021
+ text: `Posted message ${message.id} (${messageRef}) ${notSubscribed(issueTopic(issueKey))}`,
16022
+ details: { issue: issueKey, message: message.id }
15704
16023
  };
15705
16024
  }
15706
16025
  case "dispatch_doc_edit": {
@@ -15714,8 +16033,9 @@ async function executeDispatchTool(input) {
15714
16033
  });
15715
16034
  const retyped = ops.filter((operation) => operation.op === "retype").length;
15716
16035
  const versionText = edited.version === null ? "no new version" : `version ${edited.version.number}`;
16036
+ const applied = retyped === 0 ? `Applied ${edited.applied} ops (${versionText})` : `Applied ${edited.applied} ops; retyped ${retyped} block${retyped === 1 ? "" : "s"} (${versionText})`;
15717
16037
  return {
15718
- text: retyped === 0 ? `Applied ${edited.applied} ops (${versionText})` : `Applied ${edited.applied} ops; retyped ${retyped} block${retyped === 1 ? "" : "s"} (${versionText})`,
16038
+ text: `${applied} ${notSubscribed(resolvedTopic(resolved))}`,
15719
16039
  details: writeResultDetails(resolved, {
15720
16040
  applied: edited.applied,
15721
16041
  ...edited.version === null ? {} : { version: edited.version.number }
@@ -15758,7 +16078,7 @@ ${trailer.join(`
15758
16078
  }
15759
16079
  };
15760
16080
  }
15761
- const details = await askResultDetails(client, result.ask, resolved);
16081
+ const details = await followedAskDetails(client, result.ask, resolved);
15762
16082
  return {
15763
16083
  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.`,
15764
16084
  details: { ...details, artifact: resolved.artifact.id, version: result.version }
@@ -15782,32 +16102,53 @@ ${trailer.join(`
15782
16102
  const artifactOwner = documentOwner();
15783
16103
  const result = artifactOwner.kind === "project" ? await client.projectArtifact(artifactOwner.project, artifactInput) : await client.artifact(issue(), artifactInput);
15784
16104
  const artifactRef = artifactOwner.kind === "project" ? `dispatch://${artifactOwner.project}/artifact/${result.artifact.slug}` : `dispatch://${issue()}/artifact/${result.artifact.slug}`;
16105
+ const uploadOwner = artifactOwner.kind === "project" ? documentTopic(result.artifact) : issueTopic(issue());
15785
16106
  return {
15786
- text: `Uploaded ${result.artifact.name} as version ${result.version.number} (artifact slug ${result.artifact.slug}; ${artifactRef})`,
16107
+ text: `Uploaded ${result.artifact.name} as version ${result.version.number} (artifact slug ${result.artifact.slug}; ${artifactRef}) ${notSubscribed(uploadOwner)}`,
15787
16108
  details: artifactOwner.kind === "project" ? {
15788
16109
  ...documentResultDetails(result.artifact),
15789
16110
  version: result.version.number
15790
16111
  } : {
15791
16112
  issue: issue(),
15792
- topic: dispatchIssueSubject(issue(), ">"),
15793
16113
  artifact: result.artifact.id,
15794
16114
  version: result.version.number
15795
16115
  }
15796
16116
  };
15797
16117
  }
16118
+ case "dispatch_follow": {
16119
+ const sessionId = input.sessionId?.trim();
16120
+ if (!sessionId)
16121
+ throw new Error("host session id is required for dispatch_follow");
16122
+ const ask = askId(args);
16123
+ const action = stringArg(args, "action");
16124
+ if (action === "unfollow") {
16125
+ await client.unfollowAsk(ask, sessionId, actor);
16126
+ return { text: `Unfollowed ask ${ask}.`, details: { ask } };
16127
+ }
16128
+ const read = await client.getAsk(ask);
16129
+ await client.followAsk(ask, sessionId, actor);
16130
+ return {
16131
+ text: `Following ask ${ask}: its answer and replies reach this session directly.`,
16132
+ details: await followedAskDetails(client, read.ask)
16133
+ };
16134
+ }
15798
16135
  case "dispatch_read": {
15799
16136
  if (ownerArguments.ref?.kind === "ask") {
15800
- const askRead = await client.getAsk(ownerArguments.ref.id);
16137
+ const ref = ownerArguments.ref;
16138
+ const id = await resolveIdPrefix(input.tool, "ask", ref, async () => ref.owner.kind === "issue" ? client.listIssueAsks(ref.owner.issue) : client.getArtifactAsks((await resolveArtifact(client, ref.owner, ref.artifact)).artifact.id, "all"));
16139
+ const askRead = await client.getAsk(id);
15801
16140
  return {
15802
16141
  text: askSummary(askRead),
15803
- details: ownerArguments.ref.owner.kind === "project" ? { project: ownerArguments.ref.owner.project } : { issue: ownerArguments.ref.owner.issue }
16142
+ details: ref.owner.kind === "project" ? { project: ref.owner.project } : { issue: ref.owner.issue }
15804
16143
  };
15805
16144
  }
15806
16145
  if (ownerArguments.ref?.kind === "comment") {
15807
- const comment = await client.getComment(ownerArguments.ref.id);
16146
+ const ref = ownerArguments.ref;
16147
+ const id = await resolveIdPrefix(input.tool, "comment", ref, async () => ref.owner.kind === "issue" ? client.getComments(ref.owner.issue) : client.getArtifactComments((await resolveArtifact(client, ref.owner, ref.artifact)).artifact.id));
16148
+ const comment = await client.getComment(id);
15808
16149
  return {
15809
16150
  text: commentSummary(comment),
15810
- details: ownerArguments.ref.owner.kind === "project" ? { project: ownerArguments.ref.owner.project } : { issue: comment.comment.issue_key }
16151
+ details: ref.owner.kind === "project" ? { project: ref.owner.project } : { issue: comment.comment.issue_key }
15811
16152
  };
15812
16153
  }
15813
16154
  if (ownerArguments.ref?.kind === "message") {
@@ -15879,14 +16220,6 @@ function asObject(value) {
15879
16220
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
15880
16221
  }
15881
16222
 
15882
- // ../envoy-client/src/dispatch-subscribe.ts
15883
- function dispatchSubscriptionTopic(details) {
15884
- if (typeof details !== "object" || details === null || !("topic" in details))
15885
- return null;
15886
- const { topic } = details;
15887
- return typeof topic === "string" && topic.startsWith("notifications.dispatch.") ? topic : null;
15888
- }
15889
-
15890
16223
  // ../envoy-client/src/tool-contract.ts
15891
16224
  var DELIVERY_CONTRACT = "Delivery is at-least-once, possibly out of order across topics; use id for dedupe and at for freshness.";
15892
16225
  var TOPIC_GUIDE = "Topic guide (all are under notifications.): agent.<session_id> (subscribe: own inbox); role.<role> " + "(publish-to; holders claim via envoy_role_set). > matches one or more trailing tokens and does not " + "match the base subject. Envoy registers the concrete base when you subscribe to <subject>.>, so " + "the recommended default remains github.<owner>.<repo>.pr.<n>.>. Default PR subscription: " + "github.<owner>.<repo>.pr.<n>.> (it receives the quiet PR family): pr.<n> (lifecycle: " + "opened/synchronize/closed; closed carries merged, merge_commit_sha, merged_by, head_sha), " + "pr.<n>.comment, pr.<n>.review, pr.<n>.mention, pr.<n>.checks (one head-checks settlement event: " + 'passed/failed/cancelled/skipped with failing names and URLs; re-fires with superseded_settlement: "true" ' + "when new runs appear for the same head). Other GitHub: issue.<n>, issue.<n>.comment, " + "issue.<n>.mention, mention, push.branch.<name>, push.tag.<name>, workflow.<file>.<action> (only " + "runs without an associated PR); slack.<team>.<channel>.message|mention and " + "slack.<team>.<channel>.thread.<ts>.message|mention; ghostwispr.<session>.<kind>; " + "whatsapp.<phone>.<jid>.<kind>; envoy.exceptions.<original-topic>.";
@@ -15896,8 +16229,8 @@ function messageMetadataShape(schema) {
15896
16229
  return {
15897
16230
  in_reply_to: schema.string().optional(),
15898
16231
  supersedes: schema.string().optional(),
15899
- urgency: schema.enum(URGENCY_VALUES).optional(),
15900
- expects_reply: schema.enum(EXPECTS_REPLY_VALUES).optional(),
16232
+ urgency: schema.enum(URGENCY_VALUES).describe("low | med | high | blocking").optional(),
16233
+ expects_reply: schema.enum(EXPECTS_REPLY_VALUES).describe("none | optional | required").optional(),
15901
16234
  expires_at: schema.number({ int: true }).optional()
15902
16235
  };
15903
16236
  }
@@ -16500,24 +16833,6 @@ var server_default = async (input) => {
16500
16833
  }
16501
16834
  }
16502
16835
  },
16503
- "tool.execute.after": async (input, output) => {
16504
- const topic = dispatchSubscriptionTopic(output.metadata);
16505
- if (!topic)
16506
- return;
16507
- try {
16508
- await envoy.subscribe({
16509
- sessionID: input.sessionID,
16510
- directory: cwd,
16511
- topics: [topic],
16512
- port: currentPort() ?? 0,
16513
- title: activeSessionTitle ?? "",
16514
- driving: true
16515
- });
16516
- } catch (err) {
16517
- const message = err instanceof Error ? err.message : String(err);
16518
- logger.warn(`[envoy-plugin] dispatch auto-subscribe failed: ${message}`);
16519
- }
16520
- },
16521
16836
  dispose: () => {
16522
16837
  clearInterval(timer);
16523
16838
  clearInterval(heartbeatInterval);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "1.25.0",
3
+ "version": "1.27.0",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -10,7 +10,9 @@ decide, never a log of your work. The transcript is your scratch pad; progress a
10
10
  goes through a `dispatch_*` tool.
11
11
 
12
12
  The server enforces high signal: an ask question is at most 800 characters with at most eight options; comment and message bodies are at
13
- most 2,000 characters; an artifact is at most 25 MiB. It refuses over-limit input; it never truncates it. GitHub threads and markers no
13
+ most 2,000 characters; an artifact is at most 25 MiB. It refuses over-limit input with the number to trim (`question is 50 characters over
14
+ the 800-character limit (850/800)`); it never truncates it. A tool call with several problems is refused once, every problem listed
15
+ (`<tool> was not called: N problems`), so one corrected call lands. GitHub threads and markers no
14
16
  longer exist.
15
17
 
16
18
  ## Writing for the human
@@ -95,7 +97,7 @@ Architects create newly tracked child work with:
95
97
  dispatch_issue({ project, title, parent?, external?, spec?, force?, labels?: string[], priority?: 0 | 1 | 2 | 3 })
96
98
  ```
97
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
98
- `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,
99
101
  follow [Writing a spec](#writing-a-spec).
100
102
 
101
103
  ## Search first
@@ -129,7 +131,7 @@ dispatch_ask({
129
131
  anchor?: { artifact, quote, occurrence? },
130
132
  })
131
133
  ```
132
- 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)).
133
135
 
134
136
  References belong in the question text; `ref` is sugar that appends its `dispatch://` value to the question as a rendered link.
135
137
 
@@ -246,7 +248,7 @@ It returns live or versioned markdown with open marks. `issue` with an omitted `
246
248
  ```ts
247
249
  dispatch_doc_edit({ issue?, project?, artifact, ops, summary? })
248
250
  ```
249
- 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
250
252
  exact `EditOp` shape:
251
253
 
252
254
  ```ts
@@ -273,7 +275,8 @@ within one textblock; split changes that span separate blocks into separate oper
273
275
  insert anchor is a quote, `"start"`, `"end"`, or `"heading:Title"`. Ordinary inserts create a sibling block before or after the quote or
274
276
  heading's enclosing document block; `"start"` and `"end"` select the document edges. At a table-cell quote, a body-row fragment (no
275
277
  header or delimiter rows) extends that table before or after the matched row instead; short rows are padded, wider rows are rejected,
276
- and deleting a cell's quoted text removes only that text.
278
+ and deleting a cell's quoted text removes only that text. A `find` or quote anchor tolerates inline Markdown (`**bold**`, `` `code` ``)
279
+ and a leading `# ` selects a heading by its text; a miss names the three nearest blocks so the next quote lands.
277
280
 
278
281
  Use `replace` for inline continuation. Use zero-based `occurrence` for a repeated target; re-read a missing or ambiguous target before
279
282
  retrying. Pass `summary` to name the version when recording a decision.
@@ -321,7 +324,8 @@ Add feedback with:
321
324
  dispatch_comment({ issue?, project?, artifact?, ref?, quote?, occurrence?, body, reply_to?, reply_to_ask?, turn? })
322
325
  ```
323
326
 
324
- 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)).
325
329
  `ref` names the owner (an issue or project-document reference) in place of `issue`/`project`.
326
330
  `quote` requires `artifact`; its anchor is pinned to the containing block while retaining the quote
327
331
  for display. Omit both for a floating issue comment. A reply (`reply_to`/`reply_to_ask`) takes no
@@ -339,7 +343,7 @@ Propose an exact replacement instead of describing it:
339
343
  dispatch_suggest({ issue?, project?, artifact, ref?, quote, replace_with, body?, occurrence? })
340
344
  ```
341
345
 
342
- 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.
343
347
  Errors: `TARGET_AMBIGUOUS` (add `occurrence`), `TARGET_NOT_FOUND` (re-read first), `INVALID_ANCHOR`/`ANCHOR_MISSING`/`ANCHOR_ORPHANED`
344
348
  (bad, unwritten, or stale quote), `INVALID_MARKDOWN`/`DOC_SCHEMA` (malformed content), `CAP_EXCEEDED`, `ISSUE_CLOSED`.
345
349
 
@@ -358,8 +362,8 @@ dispatch_artifact({ issue?, project?, name: "load-test-results.md", content: "#
358
362
  ```
359
363
 
360
364
  Exactly one of `issue` and `project` is required. A project upload creates an unlinked project document; it must not include `artifact`.
361
- Exactly one of `path` and `content` is required. It returns issue or project-document owner details plus `artifact`, `version`, and its
362
- 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**
363
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
364
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
365
369
  slug also arrives on `artifact.created` events.
@@ -393,14 +397,14 @@ pull request is where it is summarised. One message that a human reads beats ten
393
397
  dispatch_message({ issue, body })
394
398
  ```
395
399
 
396
- 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
397
401
  (`dispatch_ask`) or document feedback (`dispatch_comment`), and it does not wake anyone unless the issue is routed.
398
402
 
399
403
  ### Targeted agent messages
400
404
 
401
405
  A human — or any bearer caller over HTTP, such as a test rig — can target the issue message at a
402
406
  live Envoy session or role as **BTW**, **Aside**, or **Steer**. The incoming Dispatch frame names
403
- the issue and includes a `reply_with` instruction; reply on the same open issue with the existing
407
+ the issue and includes a `reply_with` hint (`{ tool, args }`, ready to issue on any host); reply on the same open issue with the existing
404
408
  tool, never a new targeted send:
405
409
 
406
410
  ```ts
@@ -420,13 +424,31 @@ through Envoy or the hub. A bearer that targets over HTTP names its own session
420
424
  target only a session that advertises the mode you want. Sending to a session with no issue
421
425
  (`POST /api/v1/agents/{session_id}/messages`) stays human-only.
422
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
+
423
449
  ## What comes back
424
450
 
425
- A write result's `details.topic` subscribes the host to its owner, and the first such subscription on an issue or document also tells
426
- you so (an already-subscribed write stays quiet — no repeat notice). Issue writes use `notifications.dispatch.issue.<KEY>.>`;
427
- project-document writes use `notifications.dispatch.document.<PROJECT>.<SLUG>.>`. The owner topic carries every Dispatch event; `notify`
428
- only controls agent wake and routed delivery. A human may unsubscribe you from the issue or document header; you are told with a
429
- `subscription.removed` notice when that happens. After a restart, catch up with:
451
+ After a restart, catch up with:
430
452
 
431
453
  ```ts
432
454
  dispatch_read({ issue?, project?, artifact?, ref? })
@@ -458,7 +480,11 @@ dispatch://PROJECT/artifact/<document-ref>/ask/<id>
458
480
  dispatch://PROJECT/artifact/<document-ref>/comment/<id>
459
481
  ```
460
482
 
461
- A bare UUID or `KEY#seq` is not a reference; the `dispatch://` form is what Dispatch links and records.
483
+ A bare UUID or `KEY#seq` is not a reference; the `dispatch://` form is what Dispatch links and records. `dispatch_read` also
484
+ accepts the dashboard URL of an issue, spec, artifact, ask, comment, or project document on the configured server (it maps to the
485
+ `dispatch://` form above), and an ask or comment id may be a unique prefix of at least 8 hex characters; a message id is always the
486
+ full uuid. A non-uuid id on `GET /asks/{id}`, `/comments/{id}`, or `/issues/{key}/messages/{id}` is a 400 `ASK_ID_INPUT` /
487
+ `COMMENT_ID_INPUT` / `MESSAGE_ID_INPUT`, never a 500.
462
488
 
463
489
  ## Before / after
464
490
 
@@ -47,8 +47,17 @@ envoy:
47
47
  expects_reply: required
48
48
  re: agent-message-1
49
49
  supersedes: agent-message-0
50
- reply_with: "envoy_send(session_id=\"01a0bbbb-cccc-7ddd-eeee-0123456789ab\", message=\"...\")"
51
- reply_role: "envoy_publish(topic=\"notifications.role.legion-reviewer\", message=\"...\")"
50
+ reply_with:
51
+ tool: envoy_send
52
+ args:
53
+ session_id: 01a0bbbb-cccc-7ddd-eeee-0123456789ab
54
+ in_reply_to: agent-message-2
55
+ message: ...
56
+ reply_role:
57
+ tool: envoy_publish
58
+ args:
59
+ topic: notifications.role.legion-reviewer
60
+ message: ...
52
61
  summary: Deployment needs confirmation.
53
62
  message: "Confirm the listener health check passed.\n\nThen publish the release."
54
63
  note: body names session 01a0cccc-dddd-7eee-ffff-0123456789ab; the sender is 01a0bbbb-cccc-7ddd-eeee-0123456789ab
@@ -62,7 +71,10 @@ envoy:
62
71
  - `by` is the expiry deadline, when the sender supplied one.
63
72
  - `urgency` is the sender's priority classification.
64
73
  - `expects_reply` states whether a reply is `none`, `optional`, or `required`.
65
- - `re` names the delivery this message replies to.
74
+ - `re` names the delivery this message replies to. A Dispatch frame names it as a `dispatch://`
75
+ ref — the ask (`dispatch://KEY/ask/<id>`, or `dispatch://PROJECT/artifact/<slug>/ask/<id>` for a
76
+ document ask) or the message (`dispatch://KEY/message/<id>`) — never the quoted text; the
77
+ question head, when there is one, is `dispatch.question`.
66
78
  - `supersedes` names an earlier delivery this one replaces.
67
79
  - `reply_with` is the direct-reply call for the sender.
68
80
  - `reply_role` is the role-publish reply call when the sender has a role.
package/src/server.ts CHANGED
@@ -5,7 +5,6 @@ import { agentSubject, dispatchToolSpecs, zodSchemaApi } from "@legion/contracts
5
5
  import { envoyDefaultsFromEnvironment } from "@legion/envoy-client/defaults";
6
6
  import { resolveDispatchConfig } from "@legion/envoy-client/dispatch-config";
7
7
  import { executeDispatchTool } from "@legion/envoy-client/dispatch-execute";
8
- import { dispatchSubscriptionTopic } from "@legion/envoy-client/dispatch-subscribe";
9
8
  import { machineID } from "@legion/envoy-client/machine";
10
9
  import {
11
10
  envoyToolSpecs,
@@ -269,29 +268,6 @@ export default async (input: { serverUrl: URL }) => {
269
268
  }
270
269
  }
271
270
  },
272
- "tool.execute.after": async (
273
- input: { tool: string; sessionID: string; callID: string; args: unknown },
274
- output: { title: string; output: string; metadata: unknown }
275
- ) => {
276
- // Native Dispatch tools return `DispatchToolResult.details` in OpenCode
277
- // output metadata, so only mutations with a Dispatch topic are followed.
278
- // Best-effort — a subscribe failure must never surface to the model.
279
- const topic = dispatchSubscriptionTopic(output.metadata);
280
- if (!topic) return;
281
- try {
282
- await envoy.subscribe({
283
- sessionID: input.sessionID,
284
- directory: cwd,
285
- topics: [topic],
286
- port: currentPort() ?? 0,
287
- title: activeSessionTitle ?? "",
288
- driving: true,
289
- });
290
- } catch (err) {
291
- const message = err instanceof Error ? err.message : String(err);
292
- logger.warn(`[envoy-plugin] dispatch auto-subscribe failed: ${message}`);
293
- }
294
- },
295
271
  // Cleanup hook (used by tests; production relies on process 'exit').
296
272
  dispose: () => {
297
273
  clearInterval(timer);