@sjawhar/opencode-legion-envoy 0.16.0 → 0.17.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.
@@ -19,6 +19,122 @@ import { existsSync } from "fs";
19
19
  import path3 from "path";
20
20
  import { fileURLToPath } from "url";
21
21
 
22
+ // ../contracts/src/dispatch-tools.ts
23
+ var ISSUE_REFERENCE = "An issue is a native KEY or external owner/repo#n reference; an external reference creates its native issue on first use when its repository is mapped in DISPATCH_REPO_PROJECTS.";
24
+ var ASK_URGENCIES = ["low", "med", "high", "blocking"];
25
+ var DOC_EDIT_OPS = ["replace", "delete", "insert"];
26
+ var dispatchToolSpecs = [
27
+ {
28
+ name: "dispatch_issue",
29
+ description: "Create a native Dispatch issue for newly tracked work. Do not use it when an existing issue " + `already covers the work; read or update that issue instead. ${ISSUE_REFERENCE}`,
30
+ arguments: (z) => ({
31
+ project: z.string().describe("Project key for the new issue."),
32
+ title: z.string().describe("Concise issue title."),
33
+ parent: z.string().describe("Optional parent issue.").optional(),
34
+ external: z.string().describe("Optional external issue reference.").optional(),
35
+ spec: z.string().describe("Optional initial primary-document markdown.").optional()
36
+ })
37
+ },
38
+ {
39
+ name: "dispatch_ask",
40
+ description: "Open a durable, answerable decision on an issue. Do not use it for a status update or discussion; " + `use dispatch_message instead. Question is at most 800 characters and has at most 8 options. ${ISSUE_REFERENCE}`,
41
+ arguments: (z) => ({
42
+ issue: z.string().describe(ISSUE_REFERENCE),
43
+ question: z.string({ max: 800 }).describe("Decision question, at most 800 characters."),
44
+ options: z.array(z.object({
45
+ label: z.string().describe("Selectable option label."),
46
+ description: z.string().describe("Optional option context.").optional()
47
+ }), { max: 8 }).describe("Optional choices, at most 8.").optional(),
48
+ multiple: z.boolean().describe("Whether multiple choices may be selected.").optional(),
49
+ custom: z.boolean().describe("Whether a free-text answer is allowed.").optional(),
50
+ urgency: z.enum(ASK_URGENCIES).describe("Optional decision urgency.").optional(),
51
+ anchor: z.object({
52
+ artifact: z.string().describe("Artifact slug or id containing the quoted text."),
53
+ quote: z.string().describe("Exact text the decision concerns."),
54
+ occurrence: z.number({ int: true, min: 0 }).describe("Zero-based occurrence of the quote.").optional()
55
+ }).describe("Optional document location for the question.").optional()
56
+ })
57
+ },
58
+ {
59
+ name: "dispatch_comment",
60
+ description: "Add review feedback to an issue or document quote. Do not use it for an exact replacement; use " + `dispatch_suggest instead. Body is at most 2,000 characters. ${ISSUE_REFERENCE}`,
61
+ arguments: (z) => ({
62
+ issue: z.string().describe(ISSUE_REFERENCE),
63
+ artifact: z.string().describe("Artifact slug or id required when quote is given.").optional(),
64
+ quote: z.string().describe("Optional exact quoted document text.").optional(),
65
+ occurrence: z.number({ int: true, min: 0 }).describe("Optional zero-based occurrence of quote.").optional(),
66
+ body: z.string({ max: 2000 }).describe("Review comment, at most 2,000 characters."),
67
+ reply_to: z.string().describe("Optional comment id to reply to.").optional()
68
+ })
69
+ },
70
+ {
71
+ name: "dispatch_suggest",
72
+ description: "Propose an exact replacement for quoted document text. Do not use it for general feedback; use " + `dispatch_comment instead. Optional explanation is at most 2,000 characters. ${ISSUE_REFERENCE}`,
73
+ arguments: (z) => ({
74
+ issue: z.string().describe(ISSUE_REFERENCE),
75
+ artifact: z.string().describe("Artifact slug or id containing the quoted text."),
76
+ quote: z.string().describe("Exact document text to replace."),
77
+ replace_with: z.string().describe("Replacement text."),
78
+ body: z.string({ max: 2000 }).describe("Optional rationale, at most 2,000 characters.").optional(),
79
+ occurrence: z.number({ int: true, min: 0 }).describe("Optional zero-based occurrence of quote.").optional()
80
+ })
81
+ },
82
+ {
83
+ name: "dispatch_message",
84
+ description: "Post a plain issue update. Do not use it for a decision or line-specific review; use dispatch_ask " + `or dispatch_comment instead. Body is at most 2,000 characters. ${ISSUE_REFERENCE}`,
85
+ arguments: (z) => ({
86
+ issue: z.string().describe(ISSUE_REFERENCE),
87
+ body: z.string({ max: 2000 }).describe("Update text, at most 2,000 characters.")
88
+ })
89
+ },
90
+ {
91
+ name: "dispatch_doc_edit",
92
+ description: "Apply deterministic text edits to a document. Do not use it for review feedback or for reading; use " + `dispatch_comment, dispatch_suggest, or dispatch_doc_read instead. ${ISSUE_REFERENCE}`,
93
+ arguments: (z) => ({
94
+ issue: z.string().describe(ISSUE_REFERENCE),
95
+ artifact: z.string().describe("Artifact slug or id for the document."),
96
+ ops: z.array(z.object({
97
+ op: z.enum(DOC_EDIT_OPS).describe("Edit operation."),
98
+ find: z.string().describe("Text to find for replace or delete.").optional(),
99
+ with: z.string().describe("Replacement text for replace.").optional(),
100
+ occurrence: z.number({ int: true, min: 0 }).describe("Optional zero-based match occurrence.").optional(),
101
+ markdown: z.string().describe("Markdown to insert.").optional(),
102
+ after: z.string().describe("Anchor after which to insert.").optional(),
103
+ before: z.string().describe("Anchor before which to insert.").optional()
104
+ })).describe("Flat tagged edits; the server validates fields required for each operation."),
105
+ summary: z.string().describe("Optional named-version summary.").optional()
106
+ })
107
+ },
108
+ {
109
+ name: "dispatch_doc_read",
110
+ description: "Read a live document or a named document version. Do not use it for issue status, asks, or events; " + "use dispatch_read instead. Supply ref or issue; issue plus an omitted artifact reads the primary document. " + `${ISSUE_REFERENCE}`,
111
+ arguments: (z) => ({
112
+ issue: z.string().describe(ISSUE_REFERENCE).optional(),
113
+ artifact: z.string().describe("Optional artifact slug or id; primary document by default.").optional(),
114
+ version: z.number({ int: true, min: 1 }).describe("Optional version number.").optional(),
115
+ ref: z.string().describe("Optional dispatch:// document reference.").optional()
116
+ })
117
+ },
118
+ {
119
+ name: "dispatch_artifact",
120
+ description: "Upload a local file as an issue artifact. Do not use it to edit a live document; use dispatch_doc_edit " + `instead. Files are limited to 25 MiB. ${ISSUE_REFERENCE}`,
121
+ arguments: (z) => ({
122
+ issue: z.string().describe(ISSUE_REFERENCE),
123
+ name: z.string().describe("Artifact filename shown in Dispatch."),
124
+ path: z.string().describe("Local path to the file to upload."),
125
+ primary: z.boolean().describe("Make this document the issue primary artifact.").optional(),
126
+ summary: z.string().describe("Optional version summary.").optional()
127
+ })
128
+ },
129
+ {
130
+ name: "dispatch_read",
131
+ description: "Read an issue summary, targeted ask, or targeted comment reply chain. Do not use it for document " + "contents; use dispatch_doc_read instead. Supply issue or ref. " + ISSUE_REFERENCE,
132
+ arguments: (z) => ({
133
+ issue: z.string().describe(ISSUE_REFERENCE).optional(),
134
+ ref: z.string().describe("Optional dispatch:// issue reference.").optional()
135
+ })
136
+ }
137
+ ];
22
138
  // ../../node_modules/.bun/zod@4.3.6/node_modules/zod/v4/classic/external.js
23
139
  var exports_external = {};
24
140
  __export(exports_external, {
@@ -13551,25 +13667,20 @@ function date4(params) {
13551
13667
 
13552
13668
  // ../../node_modules/.bun/zod@4.3.6/node_modules/zod/v4/classic/external.js
13553
13669
  config(en_default());
13554
- // ../contracts/src/dispatch-question.ts
13555
- var DispatchQuestionOptionSchema = strictObject({
13556
- label: string2().min(1),
13557
- description: string2().optional()
13558
- });
13559
- var DispatchQuestionSchema = strictObject({
13560
- askId: string2().optional(),
13561
- question: string2().min(1),
13562
- header: string2().optional(),
13563
- options: array(DispatchQuestionOptionSchema).optional(),
13564
- multiple: boolean2().optional(),
13565
- custom: boolean2().optional()
13566
- });
13567
- var DispatchQuestionInputSchema = DispatchQuestionSchema.omit({ askId: true });
13568
13670
  // ../contracts/src/envelope.ts
13569
13671
  var isSubject = (value) => typeof value === "string" && value.length > 0;
13570
13672
  var EnvelopeSchema = object({
13571
13673
  event_id: string2().min(1),
13572
- source: _enum2(["agent", "human", "envoy", "github", "slack", "whatsapp", "ghostwispr"]),
13674
+ source: _enum2([
13675
+ "agent",
13676
+ "human",
13677
+ "envoy",
13678
+ "github",
13679
+ "slack",
13680
+ "whatsapp",
13681
+ "ghostwispr",
13682
+ "dispatch"
13683
+ ]),
13573
13684
  source_event_id: string2().min(1),
13574
13685
  source_session: string2().optional(),
13575
13686
  topic: custom(isSubject, { message: "topic must be a non-empty subject" }),
@@ -13901,6 +14012,41 @@ var LegionDaemonApi = {
13901
14012
  response: object({ token: nonEmptyString, appLogin: string2().endsWith("[bot]") })
13902
14013
  }
13903
14014
  };
14015
+ // ../contracts/src/tool-schema.ts
14016
+ function zodSchemaApi(zod) {
14017
+ const api = zod;
14018
+ return {
14019
+ string: (opts = {}) => {
14020
+ let schema = api.string();
14021
+ if (opts.min !== undefined)
14022
+ schema = schema.min(opts.min);
14023
+ if (opts.max !== undefined)
14024
+ schema = schema.max(opts.max);
14025
+ return schema;
14026
+ },
14027
+ number: (opts = {}) => {
14028
+ let schema = api.number();
14029
+ if (opts.int)
14030
+ schema = schema.int();
14031
+ if (opts.min !== undefined)
14032
+ schema = schema.min(opts.min);
14033
+ if (opts.max !== undefined)
14034
+ schema = schema.max(opts.max);
14035
+ return schema;
14036
+ },
14037
+ boolean: () => api.boolean(),
14038
+ enum: (values) => api.enum(values),
14039
+ array: (item, opts = {}) => {
14040
+ let schema = api.array(item);
14041
+ if (opts.min !== undefined)
14042
+ schema = schema.min(opts.min);
14043
+ if (opts.max !== undefined)
14044
+ schema = schema.max(opts.max);
14045
+ return schema;
14046
+ },
14047
+ object: (shape) => api.object(shape)
14048
+ };
14049
+ }
13904
14050
  // ../envoy-client/src/defaults.ts
13905
14051
  var DEFAULT_ENVOY_URL = "http://127.0.0.1:9020";
13906
14052
  var DEFAULT_HEARTBEAT_MS = 120000;
@@ -13919,81 +14065,109 @@ function normalizeEnvoyUrl(value) {
13919
14065
  return value.replace(/\/+$/, "");
13920
14066
  }
13921
14067
 
13922
- // ../envoy-client/src/dispatch-contract.ts
13923
- var DISPATCH_TOOL_NAME = "dispatch";
13924
- var DISPATCH_CONTEXT_MAX = 1200;
13925
- var DISPATCH_QUESTION_MAX = 800;
13926
- var DISPATCH_URGENCIES = ["low", "med", "high", "blocking"];
13927
- var DISPATCH_TOOL_DESCRIPTION = "Raise a durable question to the human as a Dispatch thread (a GitHub issue shown on the dashboard), or continue an existing thread with a follow-up question. The reader has NOT seen your transcript. Open a thread with `subject`; continue one with `thread`. The reply arrives in this session as a steer.";
13928
- var DISPATCH_ARGUMENTS = {
13929
- subject: "Open a new thread: one line naming the decision needed (the issue title). Omit when continuing a thread with `thread`.",
13930
- thread: 'Continue an existing thread: "<n>" (an open issue in the repo; a plain work issue is adopted as a thread) or "owner/name#<n>". When set, omit subject, urgency, repo, and parent.',
13931
- context: `What you are doing, what you found, why you are stuck \u2014 at most ${DISPATCH_CONTEXT_MAX} characters, at most three short paragraphs or a bullet list. The reader has NOT seen your transcript: no nouns you coined this session, no internal identifiers unless the question is about them. GitHub references (#N, owner/repo#N, URLs) may be bare; the dashboard unfurls them.`,
13932
- question: `The ask, at most ${DISPATCH_QUESTION_MAX} characters, as a list: current state \u2192 desired state \u2192 your recommendation and why; options go in \`ask\`.`,
13933
- ask: "Structured questions rendered as buttons on the dashboard. Each: { question, header?, options: [{ label, description? }], multiple?, custom? }. Use this whenever the answer is one of N choices.",
13934
- urgency: "low | med | high | blocking (default med). Opening a thread only.",
13935
- repo: "owner/name. Opening a thread only; defaults to the working directory's GitHub repo.",
13936
- parent: "<n> | owner/name#<n>[#<commentId>]. Opening a thread only: link the thread as a sub-issue of an existing issue and append a breadcrumb to the comment."
13937
- };
13938
- class DispatchArgumentError extends Error {
13939
- name = "DispatchArgumentError";
13940
- }
13941
- var prose = {
13942
- context: string2(),
13943
- question: string2(),
13944
- ask: array(DispatchQuestionInputSchema).optional()
13945
- };
13946
- var OpenThreadCallSchema = strictObject({
13947
- subject: string2(),
13948
- ...prose,
13949
- urgency: _enum2(DISPATCH_URGENCIES).optional(),
13950
- repo: string2().optional(),
13951
- parent: string2().optional()
13952
- });
13953
- var ContinueThreadCallSchema = strictObject({ thread: string2(), ...prose });
13954
- var dispatchToolShape = {
13955
- subject: string2().describe(DISPATCH_ARGUMENTS.subject).optional(),
13956
- thread: string2().describe(DISPATCH_ARGUMENTS.thread).optional(),
13957
- context: string2().describe(DISPATCH_ARGUMENTS.context),
13958
- question: string2().describe(DISPATCH_ARGUMENTS.question),
13959
- ask: array(DispatchQuestionInputSchema).describe(DISPATCH_ARGUMENTS.ask).optional(),
13960
- urgency: _enum2(DISPATCH_URGENCIES).describe(DISPATCH_ARGUMENTS.urgency).optional(),
13961
- repo: string2().describe(DISPATCH_ARGUMENTS.repo).optional(),
13962
- parent: string2().describe(DISPATCH_ARGUMENTS.parent).optional()
13963
- };
13964
- var DISPATCH_TOOL_JSON_SCHEMA = toJSONSchema(object(dispatchToolShape));
13965
- function isContinueCall(call) {
13966
- return "thread" in call;
14068
+ // ../envoy-client/src/dispatch-config.ts
14069
+ import { readFileSync } from "fs";
14070
+ import { homedir } from "os";
14071
+ import * as path from "path";
14072
+
14073
+ // ../envoy-client/src/errors.ts
14074
+ function messageFor(error) {
14075
+ return error instanceof Error ? error.message : String(error);
13967
14076
  }
13968
- function present(raw) {
13969
- return Object.fromEntries(Object.entries(raw).filter(([, value]) => value !== undefined));
14077
+
14078
+ // ../envoy-client/src/dispatch-config.ts
14079
+ var DEFAULT_SERVER_URL = "http://localhost:8766";
14080
+ function normalizeDispatchUrl(url2) {
14081
+ return url2.replace(/\/+$/, "");
13970
14082
  }
13971
- function describeIssues(error) {
13972
- return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
14083
+ function deprecatedMcpUrl(url2) {
14084
+ return normalizeDispatchUrl(url2).replace(/\/mcp$/, "");
13973
14085
  }
13974
- function parseDispatchCall(raw) {
13975
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
13976
- throw new DispatchArgumentError("dispatch: invalid arguments \u2014 expected an object");
14086
+ function parsedDispatchUrl(value, source) {
14087
+ const url2 = normalizeDispatchUrl(value);
14088
+ try {
14089
+ new URL(url2);
14090
+ return { url: url2, error: null };
14091
+ } catch {
14092
+ return { url: null, error: `${source} must be a valid URL` };
13977
14093
  }
13978
- const args = present(raw);
13979
- const hasSubject = "subject" in args;
13980
- const hasThread = "thread" in args;
13981
- if (hasSubject && hasThread) {
13982
- throw new DispatchArgumentError("dispatch: pass either subject (open a thread) or thread (continue one), not both");
14094
+ }
14095
+ var EnvoyFileSchema = looseObject({
14096
+ $schema: string2().optional(),
14097
+ natsUrls: array(string2()).optional(),
14098
+ dispatch: strictObject({
14099
+ enabled: boolean2().optional(),
14100
+ serverUrl: url().optional(),
14101
+ token: string2().optional()
14102
+ }).optional()
14103
+ });
14104
+ function describeSchemaIssue(filePath, error) {
14105
+ const issue = error.issues[0];
14106
+ if (!issue)
14107
+ return `${filePath}: invalid dispatch config`;
14108
+ if (issue.code === "unrecognized_keys") {
14109
+ const keys = issue.keys.map((key) => `dispatch.${key}`).join(", ");
14110
+ return `${filePath}: unrecognized dispatch key(s): ${keys}`;
13983
14111
  }
13984
- if (!hasSubject && !hasThread) {
13985
- throw new DispatchArgumentError("dispatch: subject or thread is required");
14112
+ return `${filePath}: ${issue.path.join(".")}: ${issue.message}`;
14113
+ }
14114
+ function readEnvoyFile(filePath) {
14115
+ let raw;
14116
+ try {
14117
+ raw = readFileSync(filePath, "utf-8");
14118
+ } catch {
14119
+ return { kind: "absent" };
13986
14120
  }
13987
- if (hasThread && (("urgency" in args) || ("repo" in args) || ("parent" in args))) {
13988
- throw new DispatchArgumentError("dispatch: thread cannot be combined with urgency, repo, or parent");
14121
+ let parsedJson;
14122
+ try {
14123
+ parsedJson = JSON.parse(raw);
14124
+ } catch (err) {
14125
+ return { kind: "invalid", reason: `${filePath}: invalid JSON (${messageFor(err)})` };
13989
14126
  }
13990
- const parsed = hasThread ? ContinueThreadCallSchema.safeParse(args) : OpenThreadCallSchema.safeParse(args);
14127
+ const parsed = EnvoyFileSchema.safeParse(parsedJson);
13991
14128
  if (!parsed.success) {
13992
- throw new DispatchArgumentError(`dispatch: invalid arguments \u2014 ${describeIssues(parsed.error)}`);
14129
+ return { kind: "invalid", reason: describeSchemaIssue(filePath, parsed.error) };
14130
+ }
14131
+ return { kind: "valid", settings: parsed.data.dispatch ?? null };
14132
+ }
14133
+ function resolveDispatchConfig(env, options = {}) {
14134
+ const explicitUrl = env.DISPATCH_URL;
14135
+ const deprecatedUrl = explicitUrl ? undefined : env.DISPATCH_MCP_URL;
14136
+ const home = options.home ?? env.HOME ?? homedir();
14137
+ const cwd = options.cwd ?? process.cwd();
14138
+ const userFile = readEnvoyFile(path.join(home, ".config", "opencode", "envoy.json"));
14139
+ const repoFile = readEnvoyFile(path.join(cwd, ".opencode", "envoy.json"));
14140
+ for (const file of [userFile, repoFile]) {
14141
+ if (file.kind === "invalid") {
14142
+ return { enabled: false, url: null, token: null, error: file.reason };
14143
+ }
14144
+ }
14145
+ const merged = {
14146
+ ...userFile.kind === "valid" ? userFile.settings : null,
14147
+ ...repoFile.kind === "valid" ? repoFile.settings : null
14148
+ };
14149
+ const rawUrl = explicitUrl !== undefined ? { value: explicitUrl, source: "DISPATCH_URL" } : deprecatedUrl !== undefined ? { value: deprecatedMcpUrl(deprecatedUrl), source: "DISPATCH_MCP_URL" } : merged.enabled === true ? { value: merged.serverUrl ?? DEFAULT_SERVER_URL, source: "dispatch.serverUrl" } : null;
14150
+ const url2 = rawUrl ? parsedDispatchUrl(rawUrl.value, rawUrl.source) : { url: null, error: null };
14151
+ const token = env.DISPATCH_TOKEN ?? merged.token ?? null;
14152
+ const tokenSource = env.DISPATCH_TOKEN === undefined ? "dispatch.token" : "DISPATCH_TOKEN";
14153
+ if (url2.error !== null)
14154
+ return { enabled: false, url: null, token, error: url2.error };
14155
+ if (url2.url === null)
14156
+ return { enabled: false, url: null, token, error: null };
14157
+ if (!token) {
14158
+ return {
14159
+ enabled: false,
14160
+ url: url2.url,
14161
+ token,
14162
+ error: `${tokenSource} must be a non-empty bearer token`
14163
+ };
13993
14164
  }
13994
- return parsed.data;
14165
+ return { enabled: true, url: url2.url, token, error: null };
13995
14166
  }
13996
14167
 
14168
+ // ../envoy-client/src/dispatch-execute.ts
14169
+ import { resolve as resolvePath } from "path";
14170
+
13997
14171
  // ../envoy-client/src/dispatch-cwd.ts
13998
14172
  import { execFile } from "child_process";
13999
14173
  import { promisify } from "util";
@@ -14076,229 +14250,592 @@ async function resolveOrigin(env, exec, cwd) {
14076
14250
  return origin;
14077
14251
  }
14078
14252
 
14079
- // ../envoy-client/src/errors.ts
14080
- function messageFor(error) {
14081
- return error instanceof Error ? error.message : String(error);
14082
- }
14083
-
14084
- // ../envoy-client/src/dispatch-client.ts
14085
- function ghTokenGetter(cwd, exec = defaultExec) {
14086
- return async () => {
14087
- try {
14088
- const { stdout } = await exec("gh", ["auth", "token"], { cwd });
14089
- const value = stdout.trim();
14090
- return value.length > 0 ? value : null;
14091
- } catch {
14092
- return null;
14093
- }
14094
- };
14095
- }
14096
-
14253
+ // ../envoy-client/src/dispatch-http.ts
14097
14254
  class DispatchServiceError extends Error {
14098
- kind;
14255
+ code;
14256
+ status;
14257
+ candidates;
14099
14258
  name = "DispatchServiceError";
14100
- constructor(kind, message) {
14259
+ constructor(code, status, message, candidates) {
14101
14260
  super(message);
14102
- this.kind = kind;
14261
+ this.code = code;
14262
+ this.status = status;
14263
+ this.candidates = candidates;
14103
14264
  }
14104
14265
  }
14105
- function parseResponseBody(body, contentType) {
14106
- if (!contentType.includes("text/event-stream"))
14107
- return JSON.parse(body);
14108
- for (const line of body.split(`
14109
- `)) {
14110
- const payload = line.match(/^data:\s*(.+)$/)?.[1];
14111
- if (payload !== undefined)
14112
- return JSON.parse(payload);
14113
- }
14114
- return null;
14266
+ function asErrorShape(value) {
14267
+ return typeof value === "object" && value !== null ? value : {};
14115
14268
  }
14116
- function isDispatchServiceResult(value) {
14117
- return typeof value === "object" && value !== null && "thread" in value && typeof value.thread === "number" && "url" in value && typeof value.url === "string";
14269
+ function isJson(response) {
14270
+ return response.headers.get("content-type")?.includes("application/json") ?? false;
14118
14271
  }
14119
- async function callDispatch(options, args) {
14120
- const token = await options.getToken();
14121
- if (!token) {
14122
- throw new DispatchServiceError("auth", `dispatch: gh auth token returned empty in ${args.origin.cwd} \u2014 check your gh-app setup`);
14123
- }
14124
- const fetchImpl = options.fetchImpl ?? fetch;
14125
- let response;
14126
- try {
14127
- response = await fetchImpl(options.serviceUrl, {
14128
- method: "POST",
14129
- headers: {
14130
- Authorization: `Bearer ${token}`,
14131
- "Content-Type": "application/json",
14132
- Accept: "application/json, text/event-stream"
14133
- },
14134
- body: JSON.stringify({
14135
- jsonrpc: "2.0",
14136
- id: 1,
14137
- method: "tools/call",
14138
- params: { name: DISPATCH_TOOL_NAME, arguments: args }
14139
- })
14272
+
14273
+ class DispatchClient {
14274
+ token;
14275
+ fetchImpl;
14276
+ #baseUrl;
14277
+ #resolvedIssues = new Map;
14278
+ #creatingIssues = new Map;
14279
+ constructor(baseUrl, token, fetchImpl = fetch) {
14280
+ this.token = token;
14281
+ this.fetchImpl = fetchImpl;
14282
+ this.#baseUrl = baseUrl.replace(/\/+$/, "");
14283
+ }
14284
+ async issue(input) {
14285
+ return this.#json("POST", ["api", "v1", "issues"], input);
14286
+ }
14287
+ async getIssue(issue) {
14288
+ return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue)]);
14289
+ }
14290
+ async getIssueEvents(issue, after = 0, limit = 200) {
14291
+ return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue), "events"], undefined, { after, limit });
14292
+ }
14293
+ async read(issueReference) {
14294
+ const issueKey = await this.#resolveIssue(issueReference);
14295
+ const issue = await this.getIssue(issueKey);
14296
+ const events = await this.getIssueEvents(issueKey, Math.max(0, issue.last_seq - 10), 10);
14297
+ return { issue, events };
14298
+ }
14299
+ async ask(issue, input) {
14300
+ return this.#json("POST", ["api", "v1", "issues", await this.#resolveIssue(issue), "asks"], input);
14301
+ }
14302
+ async comment(issue, input) {
14303
+ return this.#json("POST", ["api", "v1", "issues", await this.#resolveIssue(issue), "comments"], input);
14304
+ }
14305
+ async suggest(issue, input) {
14306
+ const { replace_with, ...comment } = input;
14307
+ return this.#json("POST", ["api", "v1", "issues", await this.#resolveIssue(issue), "comments"], {
14308
+ ...comment,
14309
+ suggestion: { replace_with }
14140
14310
  });
14141
- } catch (error) {
14142
- throw new DispatchServiceError("transport", `dispatch service unreachable at ${options.serviceUrl}: ${messageFor(error)}`);
14143
14311
  }
14144
- const body = await response.text();
14145
- if (response.status === 401) {
14146
- throw new DispatchServiceError("auth", `dispatch service rejected the GitHub token (401): ${body.slice(0, 200)}`);
14312
+ async message(issue, input) {
14313
+ return this.#json("POST", ["api", "v1", "issues", await this.#resolveIssue(issue), "messages"], input);
14147
14314
  }
14148
- if (!response.ok) {
14149
- throw new DispatchServiceError("transport", `dispatch service returned ${response.status} ${response.statusText}: ${body.slice(0, 200)}`);
14315
+ async artifact(issue, input) {
14316
+ const form = new FormData;
14317
+ form.set("name", input.name);
14318
+ if (input.primary !== undefined)
14319
+ form.set("primary", String(input.primary));
14320
+ if (input.summary !== undefined)
14321
+ form.set("summary", input.summary);
14322
+ form.set("actor", JSON.stringify(input.actor));
14323
+ form.set("file", input.file, input.name);
14324
+ return this.#form("POST", ["api", "v1", "issues", await this.#resolveIssue(issue), "artifacts"], form);
14150
14325
  }
14151
- let parsed;
14152
- try {
14153
- parsed = parseResponseBody(body, response.headers.get("content-type") ?? "");
14154
- } catch (error) {
14155
- throw new DispatchServiceError("transport", `dispatch service sent an unreadable response: ${messageFor(error)}`);
14326
+ async getArtifact(id) {
14327
+ return this.#json("GET", ["api", "v1", "artifacts", id]);
14156
14328
  }
14157
- const rpc = parsed ?? {};
14158
- if (rpc.error) {
14159
- throw new DispatchServiceError("tool", rpc.error.message ?? "dispatch service returned an error");
14329
+ async docRead(id, version) {
14330
+ return version === undefined ? this.#json("GET", ["api", "v1", "artifacts", id, "text"]) : this.#json("GET", ["api", "v1", "artifacts", id, "versions", String(version)]);
14160
14331
  }
14161
- const text = (rpc.result?.content ?? []).map((item) => item.text ?? "").filter(Boolean).join(`
14162
- `);
14163
- if (rpc.result?.isError)
14164
- throw new DispatchServiceError("tool", text || "dispatch failed");
14165
- let result;
14166
- try {
14167
- result = JSON.parse(text);
14168
- } catch {
14169
- throw new DispatchServiceError("transport", `dispatch service returned a non-JSON result: ${text.slice(0, 200)}`);
14332
+ async docEdit(id, input) {
14333
+ return this.#json("POST", ["api", "v1", "artifacts", id, "edits"], input);
14170
14334
  }
14171
- if (!isDispatchServiceResult(result)) {
14172
- throw new DispatchServiceError("transport", `dispatch service result lacks thread/url: ${text.slice(0, 200)}`);
14335
+ async nameArtifactVersion(id, input) {
14336
+ return this.#json("POST", ["api", "v1", "artifacts", id, "versions"], input);
14337
+ }
14338
+ async getAsk(id) {
14339
+ return this.#json("GET", ["api", "v1", "asks", id]);
14340
+ }
14341
+ async getComment(id) {
14342
+ return this.#json("GET", ["api", "v1", "comments", id]);
14343
+ }
14344
+ async getComments(issue, artifact) {
14345
+ return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue), "comments"], undefined, artifact ? { artifact } : undefined);
14346
+ }
14347
+ async ensureIssue(issueReference, actor) {
14348
+ if (!issueReference.includes("#"))
14349
+ return issueReference;
14350
+ try {
14351
+ return await this.#resolveIssue(issueReference);
14352
+ } catch (error) {
14353
+ if (!(error instanceof DispatchServiceError) || error.status !== 404)
14354
+ throw error;
14355
+ }
14356
+ let creating = this.#creatingIssues.get(issueReference);
14357
+ if (!creating) {
14358
+ creating = this.#createExternalIssue(issueReference, actor);
14359
+ this.#creatingIssues.set(issueReference, creating);
14360
+ }
14361
+ try {
14362
+ return await creating;
14363
+ } finally {
14364
+ if (this.#creatingIssues.get(issueReference) === creating) {
14365
+ this.#creatingIssues.delete(issueReference);
14366
+ }
14367
+ }
14368
+ }
14369
+ async#createExternalIssue(issueReference, actor) {
14370
+ try {
14371
+ const created = await this.#json("POST", ["api", "v1", "issues"], {
14372
+ external: issueReference,
14373
+ actor
14374
+ });
14375
+ this.#resolvedIssues.set(issueReference, Promise.resolve(created.key));
14376
+ return created.key;
14377
+ } catch (error) {
14378
+ if (error instanceof DispatchServiceError && (error.status === 409 || error.status === 500)) {
14379
+ return this.#resolveIssue(issueReference);
14380
+ }
14381
+ throw error;
14382
+ }
14383
+ }
14384
+ async#resolveIssue(issueReference) {
14385
+ if (!issueReference.includes("#"))
14386
+ return issueReference;
14387
+ let resolved = this.#resolvedIssues.get(issueReference);
14388
+ if (!resolved) {
14389
+ resolved = this.#json("GET", ["api", "v1", "issues", "resolve"], undefined, {
14390
+ ref: issueReference
14391
+ }).then((resolution) => resolution.key);
14392
+ this.#resolvedIssues.set(issueReference, resolved);
14393
+ }
14394
+ try {
14395
+ return await resolved;
14396
+ } catch (error) {
14397
+ this.#resolvedIssues.delete(issueReference);
14398
+ throw error;
14399
+ }
14400
+ }
14401
+ async#json(method, path, body, query) {
14402
+ const headers = {
14403
+ Accept: "application/json",
14404
+ Authorization: `Bearer ${this.token}`
14405
+ };
14406
+ if (body !== undefined)
14407
+ headers["Content-Type"] = "application/json";
14408
+ const response = await this.fetchImpl(this.#url(path, query), {
14409
+ method,
14410
+ headers,
14411
+ ...body === undefined ? {} : { body: JSON.stringify(body) }
14412
+ });
14413
+ return this.#response(response);
14414
+ }
14415
+ async#form(method, path, body) {
14416
+ const response = await this.fetchImpl(this.#url(path), {
14417
+ method,
14418
+ headers: { Accept: "application/json", Authorization: `Bearer ${this.token}` },
14419
+ body
14420
+ });
14421
+ return this.#response(response);
14422
+ }
14423
+ #url(path, query) {
14424
+ const url = new URL(`${path.map((segment) => encodeURIComponent(segment)).join("/")}`, `${this.#baseUrl}/`);
14425
+ if (query) {
14426
+ for (const [name, value] of Object.entries(query))
14427
+ url.searchParams.set(name, String(value));
14428
+ }
14429
+ return url.toString();
14430
+ }
14431
+ async#response(response) {
14432
+ const text = await response.text();
14433
+ let payload = text;
14434
+ if (text && isJson(response)) {
14435
+ try {
14436
+ payload = JSON.parse(text);
14437
+ } catch {
14438
+ payload = text;
14439
+ }
14440
+ }
14441
+ if (!response.ok) {
14442
+ const error = asErrorShape(payload);
14443
+ throw new DispatchServiceError(error.code ?? `HTTP_${response.status}`, response.status, error.error ?? (typeof payload === "string" && payload ? payload : response.statusText), error.candidates);
14444
+ }
14445
+ return payload;
14173
14446
  }
14174
- return result;
14175
14447
  }
14176
14448
 
14177
- // ../envoy-client/src/dispatch-call.ts
14178
- var QUALIFIED_REF = /^[^/\s#]+\/[^/\s#]+#\d+/;
14179
- async function prepareDispatchCall(input) {
14180
- const { call, cwd } = input;
14181
- const continuing = isContinueCall(call);
14182
- const needsRepo = continuing ? !QUALIFIED_REF.test(call.thread) : call.repo === undefined && !QUALIFIED_REF.test(call.parent ?? "");
14183
- let repo;
14184
- if (needsRepo) {
14185
- const resolved = await resolveCwdRepo(cwd, input.exec);
14186
- if (resolved === null) {
14187
- throw new DispatchArgumentError(continuing ? `dispatch: ${cwd} has no GitHub remote; pass thread=owner/name#<n>` : `dispatch: ${cwd} has no GitHub remote; pass repo=owner/name`);
14188
- }
14189
- repo = resolved;
14190
- }
14191
- const resolvedOrigin = await resolveOrigin(input.env, input.exec, cwd);
14192
- const origin = {
14193
- ...resolvedOrigin,
14194
- host: input.host,
14195
- ...input.sessionId ? { sessionId: input.sessionId } : {},
14196
- ...input.sessionTitle ? { sessionTitle: input.sessionTitle } : {}
14197
- };
14198
- return { ...call, ...repo === undefined ? {} : { repo }, origin };
14449
+ // ../envoy-client/src/dispatch-execute.ts
14450
+ var nativeIssueKeyPattern = /^[A-Z][A-Z0-9]{1,9}-[0-9]+$/;
14451
+ var externalIssueRefPattern = /^([^/\s]+)\/([^/\s#]+)#([1-9][0-9]*)$/;
14452
+ var bareIssueNumberPattern = /^[1-9][0-9]*$/;
14453
+ function dispatchTopic(issue) {
14454
+ return `notifications.dispatch.issue.${issue}.>`;
14455
+ }
14456
+ function stringArg(args, name) {
14457
+ const value = args[name];
14458
+ if (typeof value !== "string")
14459
+ throw new Error(`${name} is required`);
14460
+ return value;
14199
14461
  }
14200
- async function executeDispatch(input) {
14201
- const exec = input.exec ?? defaultExec;
14202
- const prepared = await prepareDispatchCall({
14203
- call: input.call,
14204
- cwd: input.cwd,
14205
- host: input.host,
14206
- ...input.sessionId === undefined ? {} : { sessionId: input.sessionId },
14207
- ...input.sessionTitle === undefined ? {} : { sessionTitle: input.sessionTitle },
14208
- env: input.env ?? process.env,
14209
- exec
14210
- });
14211
- return callDispatch({
14212
- serviceUrl: input.serviceUrl,
14213
- getToken: input.getToken ?? ghTokenGetter(input.cwd, exec),
14214
- ...input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}
14215
- }, prepared);
14462
+ function optionalString(args, name) {
14463
+ const value = args[name];
14464
+ return typeof value === "string" ? value : undefined;
14216
14465
  }
14217
-
14218
- // ../envoy-client/src/dispatch-config.ts
14219
- import { readFileSync } from "fs";
14220
- import { homedir } from "os";
14221
- import * as path from "path";
14222
- var DEFAULT_SERVER_URL = "http://localhost:8766";
14223
- var EnvoyFileSchema = looseObject({
14224
- $schema: string2().optional(),
14225
- natsUrls: array(string2()).optional(),
14226
- dispatch: strictObject({
14227
- enabled: boolean2().optional(),
14228
- serverUrl: url().optional()
14229
- }).optional()
14230
- });
14231
- function describeSchemaIssue(filePath, error) {
14232
- const issue = error.issues[0];
14233
- if (!issue)
14234
- return `${filePath}: invalid dispatch config`;
14235
- if (issue.code === "unrecognized_keys") {
14236
- const keys = issue.keys.map((key) => `dispatch.${key}`).join(", ");
14237
- return `${filePath}: unrecognized dispatch key(s): ${keys}`;
14466
+ function optionalBoolean(args, name) {
14467
+ const value = args[name];
14468
+ return typeof value === "boolean" ? value : undefined;
14469
+ }
14470
+ function optionalNumber(args, name) {
14471
+ const value = args[name];
14472
+ return typeof value === "number" ? value : undefined;
14473
+ }
14474
+ function askUrgency(args) {
14475
+ const value = args.urgency;
14476
+ return ASK_URGENCIES.find((urgency) => urgency === value);
14477
+ }
14478
+ function parseDispatchRef(ref) {
14479
+ const match = ref.match(/^dispatch:\/\/([A-Z][A-Z0-9]{1,9}-[1-9][0-9]*)(?:\/(spec)|\/artifact\/([^/@]+)(?:@v(\d+))?|\/ask\/([^/]+)|\/comment\/([^/]+))?$/);
14480
+ if (!match)
14481
+ return null;
14482
+ const [, issue, spec, artifact, version, ask, comment] = match;
14483
+ if (!issue || version !== undefined && Number(version) < 1)
14484
+ return null;
14485
+ if (spec)
14486
+ return { issue, kind: "spec", id: spec };
14487
+ if (artifact) {
14488
+ return {
14489
+ issue,
14490
+ kind: "artifact",
14491
+ id: artifact,
14492
+ ...version === undefined ? {} : { version: Number(version) }
14493
+ };
14238
14494
  }
14239
- return `${filePath}: ${issue.path.join(".")}: ${issue.message}`;
14495
+ if (ask)
14496
+ return { issue, kind: "ask", id: ask };
14497
+ if (comment)
14498
+ return { issue, kind: "comment", id: comment };
14499
+ return { issue, kind: "issue", id: issue };
14500
+ }
14501
+ function toolSchema(tool) {
14502
+ const spec = dispatchToolSpecs.find((candidate) => candidate.name === tool);
14503
+ if (!spec)
14504
+ throw new Error(`Unknown Dispatch tool: ${tool}`);
14505
+ return object(spec.arguments(zodSchemaApi(exports_external))).strict();
14506
+ }
14507
+ async function resolveIssueArguments(tool, args, cwd, env, exec) {
14508
+ if (tool === "dispatch_issue")
14509
+ return { args, ref: null };
14510
+ const refArgument = args.ref;
14511
+ const ref = typeof refArgument === "string" ? parseDispatchRef(refArgument) ?? (() => {
14512
+ throw new Error("ref must be a valid dispatch:// reference");
14513
+ })() : null;
14514
+ const issueArgument = args.issue;
14515
+ const artifactArgument = args.artifact;
14516
+ const versionArgument = args.version;
14517
+ if (issueArgument !== undefined || ref !== null) {
14518
+ return {
14519
+ args: {
14520
+ ...args,
14521
+ ...issueArgument === undefined && ref?.issue !== undefined ? { issue: ref.issue } : {},
14522
+ ...artifactArgument === undefined && (ref?.kind === "spec" || ref?.kind === "artifact") ? { artifact: ref.id } : {},
14523
+ ...versionArgument === undefined && ref?.version !== undefined ? { version: ref.version } : {}
14524
+ },
14525
+ ref
14526
+ };
14527
+ }
14528
+ const legionIssue = env.LEGION_ISSUE;
14529
+ if (!legionIssue)
14530
+ throw new Error("issue is required; supply issue or set LEGION_ISSUE");
14531
+ if (nativeIssueKeyPattern.test(legionIssue) || externalIssueRefPattern.test(legionIssue)) {
14532
+ return { args: { ...args, issue: legionIssue }, ref: null };
14533
+ }
14534
+ if (!bareIssueNumberPattern.test(legionIssue)) {
14535
+ 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");
14536
+ }
14537
+ const repo = await resolveCwdRepo(cwd, exec);
14538
+ if (!repo)
14539
+ throw new Error("issue is required; LEGION_ISSUE needs a GitHub repository in cwd");
14540
+ return { args: { ...args, issue: `${repo}#${legionIssue}` }, ref: null };
14240
14541
  }
14241
- function readEnvoyFile(filePath) {
14242
- let raw;
14243
- try {
14244
- raw = readFileSync(filePath, "utf-8");
14245
- } catch {
14246
- return { kind: "absent" };
14542
+ async function resolveArtifact(client, issueReference, artifactReference) {
14543
+ const issue = await client.getIssue(issueReference);
14544
+ 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);
14545
+ if (!artifact) {
14546
+ throw new Error(`artifact ${artifactReference ?? "spec"} was not found on issue ${issue.key}`);
14247
14547
  }
14248
- let parsedJson;
14548
+ return { issue, artifact };
14549
+ }
14550
+ function anchor(artifact, args) {
14551
+ const quote = optionalString(args, "quote");
14552
+ if (quote === undefined)
14553
+ return;
14554
+ const occurrence = optionalNumber(args, "occurrence");
14555
+ return { artifact: artifact.id, quote, ...occurrence === undefined ? {} : { occurrence } };
14556
+ }
14557
+ function toolActor(origin, input) {
14558
+ return {
14559
+ kind: "session",
14560
+ id: input.sessionId ?? "unknown",
14561
+ origin: {
14562
+ ...origin,
14563
+ host: input.host,
14564
+ ...input.sessionTitle === undefined ? {} : { session_title: input.sessionTitle }
14565
+ }
14566
+ };
14567
+ }
14568
+ function issueSummary(issue, events) {
14569
+ const asks = issue.open_asks;
14570
+ return [
14571
+ `Title: ${issue.title}`,
14572
+ `Key: ${issue.key}`,
14573
+ `Status: ${issue.status}`,
14574
+ `Route: ${issue.route ?? "none"}`,
14575
+ "Open asks:",
14576
+ ...asks.length === 0 ? ["- none"] : asks.map((ask) => `- ${ask.id}: ${ask.question}`),
14577
+ "Events:",
14578
+ ...events.length === 0 ? ["- none"] : events.map((event) => `- #${event.seq} ${event.type} \xB7 ${event.actor.kind} ${event.actor.id} \xB7 ${event.created_at}`)
14579
+ ].join(`
14580
+ `);
14581
+ }
14582
+ function askSummary(ask) {
14583
+ const answer = ask.answer;
14584
+ return [
14585
+ `Question: ${ask.question}`,
14586
+ "Options:",
14587
+ ...ask.options.length === 0 ? ["- none"] : ask.options.map((option) => `- ${option.label}${option.description ? ` \u2014 ${option.description}` : ""}`),
14588
+ `State: ${ask.state}`,
14589
+ "Answer:",
14590
+ ...answer === null ? ["- none"] : [
14591
+ `- By: ${answer.user}`,
14592
+ `- Selected: ${answer.selected.length === 0 ? "none" : answer.selected.join(", ")}`,
14593
+ ...answer.text === null ? [] : [`- Text: ${answer.text}`]
14594
+ ]
14595
+ ].join(`
14596
+ `);
14597
+ }
14598
+ function commentSummary({ comment, replies }) {
14599
+ const root = [
14600
+ `${comment.id} \xB7 ${comment.author.kind} ${comment.author.id}`,
14601
+ ...comment.anchor?.quote === undefined ? [] : [`> ${comment.anchor.quote}`],
14602
+ `Body: ${comment.body}`
14603
+ ];
14604
+ const chain = replies.flatMap((reply) => [
14605
+ `${reply.id} \xB7 ${reply.author.kind} ${reply.author.id}`,
14606
+ ...reply.anchor?.quote === undefined ? [] : [`> ${reply.anchor.quote}`],
14607
+ `Body: ${reply.body}`
14608
+ ]);
14609
+ return ["Comment:", ...root, "Reply chain:", ...chain.length === 0 ? ["- none"] : chain].join(`
14610
+ `);
14611
+ }
14612
+ async function openArtifactMarks(client, resolved) {
14613
+ const marks = resolved.issue.open_asks.filter((ask) => ask.state === "open" && ask.anchor?.artifact_id === resolved.artifact.id).map((ask) => `ask ${ask.id}`);
14614
+ let comments;
14249
14615
  try {
14250
- parsedJson = JSON.parse(raw);
14251
- } catch (err) {
14252
- return { kind: "invalid", reason: `${filePath}: invalid JSON (${messageFor(err)})` };
14616
+ comments = await client.getComments(resolved.issue.key, resolved.artifact.id);
14617
+ } catch (error) {
14618
+ if (error instanceof DispatchServiceError && error.status === 404)
14619
+ return marks;
14620
+ throw error;
14253
14621
  }
14254
- const parsed = EnvoyFileSchema.safeParse(parsedJson);
14255
- if (!parsed.success) {
14256
- return { kind: "invalid", reason: describeSchemaIssue(filePath, parsed.error) };
14622
+ return [
14623
+ ...marks,
14624
+ ...comments.filter((comment) => !comment.resolved && comment.anchor?.artifact_id === resolved.artifact.id).map((comment) => `comment ${comment.id}`)
14625
+ ];
14626
+ }
14627
+ async function executeDispatchTool(input) {
14628
+ if (!input.config.enabled || !input.config.url || !input.config.token) {
14629
+ throw new Error("Dispatch is disabled; resolve both DISPATCH_URL and DISPATCH_TOKEN");
14630
+ }
14631
+ const env = input.env ?? process.env;
14632
+ const exec = input.exec ?? defaultExec;
14633
+ const issueArguments = await resolveIssueArguments(input.tool, input.args, input.cwd, env, exec);
14634
+ const args = toolSchema(input.tool).parse(issueArguments.args);
14635
+ const actor = toolActor(await resolveOrigin(env, exec, input.cwd), input);
14636
+ const client = new DispatchClient(input.config.url, input.config.token, input.fetchImpl);
14637
+ const issueKey = input.tool === "dispatch_issue" ? null : await ensureIssue(client, stringArg(args, "issue"), actor);
14638
+ const issue = () => {
14639
+ if (issueKey === null)
14640
+ throw new Error("issue is required");
14641
+ return issueKey;
14642
+ };
14643
+ switch (input.tool) {
14644
+ case "dispatch_issue": {
14645
+ const parent = optionalString(args, "parent");
14646
+ const external = optionalString(args, "external");
14647
+ const spec = optionalString(args, "spec");
14648
+ const created = await client.issue({
14649
+ project: stringArg(args, "project"),
14650
+ title: stringArg(args, "title"),
14651
+ ...parent === undefined ? {} : { parent },
14652
+ ...external === undefined ? {} : { external },
14653
+ ...spec === undefined ? {} : { spec },
14654
+ actor
14655
+ });
14656
+ return {
14657
+ text: `Created ${created.key}: ${created.title}`,
14658
+ details: { issue: created.key, topic: dispatchTopic(created.key) }
14659
+ };
14660
+ }
14661
+ case "dispatch_ask": {
14662
+ const anchorArgs = asObject(args.anchor);
14663
+ const resolved = anchorArgs ? await resolveArtifact(client, issue(), stringArg(anchorArgs, "artifact")) : undefined;
14664
+ const options = args.options;
14665
+ const multiple = optionalBoolean(args, "multiple");
14666
+ const custom = optionalBoolean(args, "custom");
14667
+ const urgency = askUrgency(args);
14668
+ const anchored = anchorArgs && resolved ? anchor(resolved.artifact, anchorArgs) : undefined;
14669
+ const ask = await client.ask(issue(), {
14670
+ question: stringArg(args, "question"),
14671
+ ...Array.isArray(options) ? { options } : {},
14672
+ ...multiple === undefined ? {} : { multiple },
14673
+ ...custom === undefined ? {} : { custom },
14674
+ ...urgency === undefined ? {} : { urgency },
14675
+ ...anchored === undefined ? {} : { anchor: anchored },
14676
+ actor
14677
+ });
14678
+ return {
14679
+ text: `Opened ask ${ask.id}: ${ask.question}`,
14680
+ details: { issue: ask.issue_key, topic: dispatchTopic(ask.issue_key), ask: ask.id }
14681
+ };
14682
+ }
14683
+ case "dispatch_comment": {
14684
+ const artifactReference = optionalString(args, "artifact");
14685
+ if (optionalString(args, "quote") !== undefined && artifactReference === undefined) {
14686
+ throw new Error("artifact is required when quote is supplied");
14687
+ }
14688
+ const resolved = artifactReference ? await resolveArtifact(client, issue(), artifactReference) : undefined;
14689
+ const anchored = resolved ? anchor(resolved.artifact, args) : undefined;
14690
+ const replyTo = optionalString(args, "reply_to");
14691
+ const comment = await client.comment(issue(), {
14692
+ body: stringArg(args, "body"),
14693
+ ...anchored === undefined ? {} : { anchor: anchored },
14694
+ ...replyTo === undefined ? {} : { reply_to: replyTo },
14695
+ actor
14696
+ });
14697
+ return {
14698
+ text: `Posted comment ${comment.id}`,
14699
+ details: {
14700
+ issue: comment.issue_key,
14701
+ topic: dispatchTopic(comment.issue_key),
14702
+ comment: comment.id
14703
+ }
14704
+ };
14705
+ }
14706
+ case "dispatch_suggest": {
14707
+ const resolved = await resolveArtifact(client, issue(), stringArg(args, "artifact"));
14708
+ const anchored = anchor(resolved.artifact, args);
14709
+ if (anchored === undefined)
14710
+ throw new Error("quote is required");
14711
+ const body = optionalString(args, "body");
14712
+ const comment = await client.suggest(issue(), {
14713
+ ...body === undefined ? {} : { body },
14714
+ anchor: anchored,
14715
+ replace_with: stringArg(args, "replace_with"),
14716
+ actor
14717
+ });
14718
+ return {
14719
+ text: `Posted suggestion ${comment.id}`,
14720
+ details: {
14721
+ issue: comment.issue_key,
14722
+ topic: dispatchTopic(comment.issue_key),
14723
+ comment: comment.id
14724
+ }
14725
+ };
14726
+ }
14727
+ case "dispatch_message": {
14728
+ const message = await client.message(issue(), { body: stringArg(args, "body"), actor });
14729
+ return {
14730
+ text: `Posted message ${message.id}`,
14731
+ details: {
14732
+ issue: message.issue_key,
14733
+ topic: dispatchTopic(message.issue_key),
14734
+ message: message.id
14735
+ }
14736
+ };
14737
+ }
14738
+ case "dispatch_doc_edit": {
14739
+ const resolved = await resolveArtifact(client, issue(), stringArg(args, "artifact"));
14740
+ const ops = args.ops;
14741
+ const summary = optionalString(args, "summary");
14742
+ const edited = await client.docEdit(resolved.artifact.id, {
14743
+ ops,
14744
+ ...summary === undefined ? {} : { summary },
14745
+ actor
14746
+ });
14747
+ return {
14748
+ text: edited.version === null ? `Applied ${edited.applied} ops (no new version)` : `Applied ${edited.applied} ops (version ${edited.version.number})`,
14749
+ details: {
14750
+ issue: resolved.issue.key,
14751
+ topic: dispatchTopic(resolved.issue.key),
14752
+ applied: edited.applied,
14753
+ ...edited.version === null ? {} : { version: edited.version.number }
14754
+ }
14755
+ };
14756
+ }
14757
+ case "dispatch_doc_read": {
14758
+ const artifactReference = optionalString(args, "artifact") ?? (issueArguments.ref?.kind === "spec" || issueArguments.ref?.kind === "artifact" ? issueArguments.ref.id : undefined);
14759
+ const resolved = await resolveArtifact(client, issue(), artifactReference);
14760
+ const version = optionalNumber(args, "version") ?? issueArguments.ref?.version;
14761
+ const document = await client.docRead(resolved.artifact.id, version);
14762
+ const marks = await openArtifactMarks(client, resolved);
14763
+ return {
14764
+ text: marks.length === 0 ? document.markdown : `${document.markdown}
14765
+
14766
+ Open anchored asks/comments: ${marks.join(", ")}`,
14767
+ details: { issue: resolved.issue.key, topic: dispatchTopic(resolved.issue.key) }
14768
+ };
14769
+ }
14770
+ case "dispatch_artifact": {
14771
+ const primary = optionalBoolean(args, "primary");
14772
+ const summary = optionalString(args, "summary");
14773
+ const result = await client.artifact(issue(), {
14774
+ name: stringArg(args, "name"),
14775
+ file: Bun.file(resolvePath(input.cwd, stringArg(args, "path"))),
14776
+ ...primary === undefined ? {} : { primary },
14777
+ ...summary === undefined ? {} : { summary },
14778
+ actor
14779
+ });
14780
+ return {
14781
+ text: `Uploaded ${result.artifact.name} as version ${result.version.number}`,
14782
+ details: {
14783
+ issue: result.artifact.issue_key,
14784
+ topic: dispatchTopic(result.artifact.issue_key),
14785
+ artifact: result.artifact.id,
14786
+ version: result.version.number
14787
+ }
14788
+ };
14789
+ }
14790
+ case "dispatch_read": {
14791
+ if (issueArguments.ref?.kind === "ask") {
14792
+ const ask = await client.getAsk(issueArguments.ref.id);
14793
+ return {
14794
+ text: askSummary(ask),
14795
+ details: { issue: ask.issue_key, topic: dispatchTopic(ask.issue_key) }
14796
+ };
14797
+ }
14798
+ if (issueArguments.ref?.kind === "comment") {
14799
+ const comment = await client.getComment(issueArguments.ref.id);
14800
+ return {
14801
+ text: commentSummary(comment),
14802
+ details: {
14803
+ issue: comment.comment.issue_key,
14804
+ topic: dispatchTopic(comment.comment.issue_key)
14805
+ }
14806
+ };
14807
+ }
14808
+ const read = await client.read(issue());
14809
+ return {
14810
+ text: issueSummary(read.issue, read.events),
14811
+ details: { issue: read.issue.key, topic: dispatchTopic(read.issue.key) }
14812
+ };
14813
+ }
14814
+ default:
14815
+ throw new Error(`Unknown Dispatch tool: ${input.tool}`);
14257
14816
  }
14258
- return { kind: "valid", settings: parsed.data.dispatch ?? null };
14259
14817
  }
14260
- function resolveDispatchConfig(env, options = {}) {
14261
- const explicit = env.DISPATCH_MCP_URL;
14262
- if (explicit)
14263
- return { url: explicit, error: null };
14264
- const home = options.home ?? env.HOME ?? homedir();
14265
- const cwd = options.cwd ?? process.cwd();
14266
- const userFile = readEnvoyFile(path.join(home, ".config", "opencode", "envoy.json"));
14267
- const repoFile = readEnvoyFile(path.join(cwd, ".opencode", "envoy.json"));
14268
- for (const file of [userFile, repoFile]) {
14269
- if (file.kind === "invalid")
14270
- return { url: null, error: file.reason };
14818
+ async function ensureIssue(client, issueReference, actor) {
14819
+ try {
14820
+ return await client.ensureIssue(issueReference, actor);
14821
+ } catch (error) {
14822
+ if (error instanceof DispatchServiceError && error.code === "PROJECT_UNMAPPED") {
14823
+ const repository = issueReference.slice(0, issueReference.lastIndexOf("#"));
14824
+ throw new Error(`repository ${repository} is not mapped in DISPATCH_REPO_PROJECTS`);
14825
+ }
14826
+ throw error;
14271
14827
  }
14272
- const merged = {
14273
- ...userFile.kind === "valid" ? userFile.settings : null,
14274
- ...repoFile.kind === "valid" ? repoFile.settings : null
14275
- };
14276
- if (merged.enabled !== true)
14277
- return { url: null, error: null };
14278
- const baseUrl = (merged.serverUrl ?? DEFAULT_SERVER_URL).replace(/\/+$/, "");
14279
- return { url: `${baseUrl}/mcp`, error: null };
14828
+ }
14829
+ function asObject(value) {
14830
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
14280
14831
  }
14281
14832
 
14282
14833
  // ../envoy-client/src/dispatch-subscribe.ts
14283
- var ISSUE_URL_RE = /https?:\/\/github\.com\/([^/\s"]+)\/([^/\s"]+)\/issues\/(\d+)/i;
14284
- function isDispatchTool(tool) {
14285
- return tool === DISPATCH_TOOL_NAME;
14286
- }
14287
- function dispatchThreadTopic(owner, repo, thread) {
14288
- return `notifications.github.${owner}.${repo}.issue.${thread}.>`;
14289
- }
14290
- function dispatchSubscriptionTopic(tool, output) {
14291
- if (!isDispatchTool(tool))
14292
- return null;
14293
- const match = ISSUE_URL_RE.exec(output);
14294
- if (!match)
14834
+ function dispatchSubscriptionTopic(details) {
14835
+ if (typeof details !== "object" || details === null || !("topic" in details))
14295
14836
  return null;
14296
- const owner = match[1];
14297
- const repo = match[2];
14298
- const thread = Number(match[3]);
14299
- if (!Number.isInteger(thread) || thread <= 0)
14300
- return null;
14301
- return dispatchThreadTopic(owner, repo, thread);
14837
+ const { topic } = details;
14838
+ return typeof topic === "string" && topic.startsWith("notifications.dispatch.") ? topic : null;
14302
14839
  }
14303
14840
 
14304
14841
  // ../envoy-client/src/tool-contract.ts
@@ -14312,10 +14849,19 @@ function messageMetadataShape(schema) {
14312
14849
  supersedes: schema.string().optional(),
14313
14850
  urgency: schema.enum(URGENCY_VALUES).optional(),
14314
14851
  expects_reply: schema.enum(EXPECTS_REPLY_VALUES).optional(),
14315
- expires_at: schema.number().int().optional()
14852
+ expires_at: schema.number({ int: true }).optional()
14853
+ };
14854
+ }
14855
+ var MessageMetadataSchema = object(messageMetadataShape(zodSchemaApi(exports_external)));
14856
+ function toMessageMetadata(args) {
14857
+ return {
14858
+ ...args.in_reply_to === undefined ? {} : { inReplyTo: args.in_reply_to },
14859
+ ...args.supersedes === undefined ? {} : { supersedes: args.supersedes },
14860
+ ...args.urgency === undefined ? {} : { urgency: args.urgency },
14861
+ ...args.expects_reply === undefined ? {} : { expectsReply: args.expects_reply },
14862
+ ...args.expires_at === undefined ? {} : { expiresAt: args.expires_at }
14316
14863
  };
14317
14864
  }
14318
- var MessageMetadataSchema = object(messageMetadataShape(exports_external));
14319
14865
  function messageArguments(schema) {
14320
14866
  return { message: schema.string(), ...messageMetadataShape(schema) };
14321
14867
  }
@@ -14705,33 +15251,12 @@ var publishSpec = toolSpec("envoy_publish");
14705
15251
  var roleSetSpec = toolSpec("envoy_role_set");
14706
15252
  var whoamiSpec = toolSpec("envoy_whoami");
14707
15253
  var sessionsSpec = toolSpec("envoy_sessions");
14708
- var dispatchQuestionOption = tool.schema.strictObject({
14709
- label: tool.schema.string().min(1),
14710
- description: tool.schema.string().optional()
14711
- });
14712
- var dispatchQuestion = tool.schema.strictObject({
14713
- question: tool.schema.string().min(1),
14714
- header: tool.schema.string().optional(),
14715
- options: tool.schema.array(dispatchQuestionOption).optional(),
14716
- multiple: tool.schema.boolean().optional(),
14717
- custom: tool.schema.boolean().optional()
14718
- });
14719
- var dispatchArgs = {
14720
- subject: tool.schema.string().describe(DISPATCH_ARGUMENTS.subject).optional(),
14721
- thread: tool.schema.string().describe(DISPATCH_ARGUMENTS.thread).optional(),
14722
- context: tool.schema.string().describe(DISPATCH_ARGUMENTS.context),
14723
- question: tool.schema.string().describe(DISPATCH_ARGUMENTS.question),
14724
- ask: tool.schema.array(dispatchQuestion).describe(DISPATCH_ARGUMENTS.ask).optional(),
14725
- urgency: tool.schema.enum(DISPATCH_URGENCIES).describe(DISPATCH_ARGUMENTS.urgency).optional(),
14726
- repo: tool.schema.string().describe(DISPATCH_ARGUMENTS.repo).optional(),
14727
- parent: tool.schema.string().describe(DISPATCH_ARGUMENTS.parent).optional()
14728
- };
14729
15254
  var server_default = async (input) => {
14730
15255
  const cwd = process.cwd();
14731
15256
  const dispatchConfig = resolveDispatchConfig(process.env, { cwd });
14732
- if (dispatchConfig.error !== null)
14733
- throw new Error(`[envoy-plugin] ${dispatchConfig.error}`);
14734
- const dispatchServiceUrl = dispatchConfig.url;
15257
+ if (!dispatchConfig.enabled) {
15258
+ logger.warn(`envoy: dispatch tools disabled \u2014 ${dispatchConfig.error ?? "no Dispatch URL configured"}`);
15259
+ }
14735
15260
  const envoyDefaults = envoyDefaultsFromEnvironment(process.env);
14736
15261
  const envoy = createEnvoyClient({ baseUrl: envoyDefaults.envoyUrl, fetch: globalThis.fetch });
14737
15262
  let activeSessionID = null;
@@ -14802,25 +15327,29 @@ var server_default = async (input) => {
14802
15327
  clearInterval(timer);
14803
15328
  clearInterval(heartbeatInterval);
14804
15329
  });
14805
- const dispatchTool = dispatchServiceUrl === null ? {} : {
14806
- [DISPATCH_TOOL_NAME]: tool({
14807
- description: DISPATCH_TOOL_DESCRIPTION,
14808
- args: dispatchArgs,
14809
- async execute(args, ctx) {
14810
- ctx.metadata({ title: "Dispatch" });
14811
- const call = parseDispatchCall(args);
14812
- const result = await executeDispatch({
14813
- call,
14814
- cwd: ctx.directory,
14815
- host: "opencode",
14816
- sessionId: ctx.sessionID,
14817
- sessionTitle: await fetchTitle(ctx.sessionID) ?? undefined,
14818
- serviceUrl: dispatchServiceUrl
14819
- });
14820
- return JSON.stringify(result);
14821
- }
14822
- })
14823
- };
15330
+ const dispatchTools = {};
15331
+ if (dispatchConfig.enabled) {
15332
+ for (const spec of dispatchToolSpecs) {
15333
+ dispatchTools[spec.name] = tool({
15334
+ description: spec.description,
15335
+ args: spec.arguments(zodSchemaApi(tool.schema)),
15336
+ async execute(args, ctx) {
15337
+ ctx.metadata({ title: "Dispatch" });
15338
+ const result = await executeDispatchTool({
15339
+ tool: spec.name,
15340
+ args,
15341
+ cwd: ctx.directory,
15342
+ host: "opencode",
15343
+ sessionId: ctx.sessionID,
15344
+ sessionTitle: await fetchTitle(ctx.sessionID) ?? undefined,
15345
+ config: dispatchConfig,
15346
+ env: process.env
15347
+ });
15348
+ return { title: "Dispatch", output: result.text, metadata: result.details };
15349
+ }
15350
+ });
15351
+ }
15352
+ }
14824
15353
  return {
14825
15354
  config: (cfg) => {
14826
15355
  if (skillsDirectory) {
@@ -14881,7 +15410,7 @@ var server_default = async (input) => {
14881
15410
  }
14882
15411
  },
14883
15412
  "tool.execute.after": async (input, output) => {
14884
- const topic = dispatchSubscriptionTopic(input.tool, output.output);
15413
+ const topic = dispatchSubscriptionTopic(output.metadata);
14885
15414
  if (!topic)
14886
15415
  return;
14887
15416
  try {
@@ -14903,10 +15432,10 @@ var server_default = async (input) => {
14903
15432
  clearInterval(heartbeatInterval);
14904
15433
  },
14905
15434
  tool: {
14906
- ...dispatchTool,
15435
+ ...dispatchTools,
14907
15436
  envoy_subscribe: tool({
14908
15437
  description: subscribeSpec.description,
14909
- args: { topics: tool.schema.array(tool.schema.string()) },
15438
+ args: subscribeSpec.arguments(zodSchemaApi(tool.schema)),
14910
15439
  async execute(args, ctx) {
14911
15440
  ctx.metadata({ title: "Envoy subscribe" });
14912
15441
  return JSON.stringify(await envoy.subscribe({
@@ -14921,16 +15450,19 @@ var server_default = async (input) => {
14921
15450
  }),
14922
15451
  envoy_unsubscribe: tool({
14923
15452
  description: unsubscribeSpec.description,
14924
- args: { topics: tool.schema.array(tool.schema.string()).optional() },
15453
+ args: unsubscribeSpec.arguments(zodSchemaApi(tool.schema)),
14925
15454
  async execute(args, ctx) {
14926
15455
  ctx.metadata({ title: "Envoy unsubscribe" });
14927
- await envoy.unsubscribe({ sessionID: ctx.sessionID, topics: args.topics ?? [] });
15456
+ await envoy.unsubscribe({
15457
+ sessionID: ctx.sessionID,
15458
+ topics: args.topics ?? []
15459
+ });
14928
15460
  return "ok";
14929
15461
  }
14930
15462
  }),
14931
15463
  envoy_list: tool({
14932
15464
  description: listSpec.description,
14933
- args: {},
15465
+ args: listSpec.arguments(zodSchemaApi(tool.schema)),
14934
15466
  async execute(_args, ctx) {
14935
15467
  ctx.metadata({ title: "Envoy list" });
14936
15468
  return JSON.stringify(await envoy.getInterest(ctx.sessionID));
@@ -14938,31 +15470,33 @@ var server_default = async (input) => {
14938
15470
  }),
14939
15471
  envoy_send: tool({
14940
15472
  description: sendSpec.description,
14941
- args: { session_id: tool.schema.string(), message: tool.schema.string() },
15473
+ args: sendSpec.arguments(zodSchemaApi(tool.schema)),
14942
15474
  async execute(args, ctx) {
14943
15475
  ctx.metadata({ title: "Envoy send" });
14944
15476
  return JSON.stringify(await envoy.send({
14945
15477
  sourceSessionID: ctx.sessionID,
14946
15478
  targetSessionID: args.session_id,
14947
- message: args.message
15479
+ message: args.message,
15480
+ ...toMessageMetadata(args)
14948
15481
  }));
14949
15482
  }
14950
15483
  }),
14951
15484
  envoy_publish: tool({
14952
15485
  description: publishSpec.description,
14953
- args: { topic: tool.schema.string(), message: tool.schema.string() },
15486
+ args: publishSpec.arguments(zodSchemaApi(tool.schema)),
14954
15487
  async execute(args, ctx) {
14955
15488
  ctx.metadata({ title: "Envoy publish" });
14956
15489
  return JSON.stringify(await envoy.publish({
14957
15490
  sourceSessionID: ctx.sessionID,
14958
15491
  topic: args.topic,
14959
- message: args.message
15492
+ message: args.message,
15493
+ ...toMessageMetadata(args)
14960
15494
  }));
14961
15495
  }
14962
15496
  }),
14963
15497
  envoy_role_set: tool({
14964
15498
  description: roleSetSpec.description,
14965
- args: { role: tool.schema.string() },
15499
+ args: roleSetSpec.arguments(zodSchemaApi(tool.schema)),
14966
15500
  async execute(args, ctx) {
14967
15501
  ctx.metadata({ title: "Set Envoy role" });
14968
15502
  return JSON.stringify(await envoy.setRole({ sessionID: ctx.sessionID, role: args.role }));
@@ -14970,7 +15504,7 @@ var server_default = async (input) => {
14970
15504
  }),
14971
15505
  envoy_whoami: tool({
14972
15506
  description: whoamiSpec.description,
14973
- args: {},
15507
+ args: whoamiSpec.arguments(zodSchemaApi(tool.schema)),
14974
15508
  async execute(_args, ctx) {
14975
15509
  ctx.metadata({ title: "Envoy whoami" });
14976
15510
  const sessionID = ctx.sessionID;
@@ -14985,11 +15519,15 @@ var server_default = async (input) => {
14985
15519
  }),
14986
15520
  envoy_sessions: tool({
14987
15521
  description: sessionsSpec.description,
14988
- args: { machine: tool.schema.string().optional() },
15522
+ args: sessionsSpec.arguments(zodSchemaApi(tool.schema)),
14989
15523
  async execute(args, ctx) {
14990
15524
  ctx.metadata({ title: "Envoy sessions" });
14991
- const sessions = await envoy.listSessions();
14992
- return JSON.stringify(args.machine ? sessions.filter((session) => session.machine_id === args.machine) : sessions, null, 2);
15525
+ const sessions = await envoy.listSessions({
15526
+ ...args.dir === undefined ? {} : { directory: args.dir },
15527
+ ...args.title === undefined ? {} : { title: args.title }
15528
+ });
15529
+ const machine = args.machine;
15530
+ return JSON.stringify(machine ? sessions.filter((session) => session.machine_id === machine) : sessions, null, 2);
14993
15531
  }
14994
15532
  })
14995
15533
  }