@sjawhar/opencode-legion-envoy 0.15.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" }),
@@ -13831,6 +13942,13 @@ var LegionDaemonApi = {
13831
13942
  }),
13832
13943
  response: object({})
13833
13944
  },
13945
+ PhaseComplete: {
13946
+ request: strictObject({
13947
+ grantId: nonEmptyString,
13948
+ summary: nonEmptyString
13949
+ }),
13950
+ response: object({})
13951
+ },
13834
13952
  SpawnWorker: {
13835
13953
  request: architectCapability.extend({
13836
13954
  issue: nonEmptyString,
@@ -13894,6 +14012,41 @@ var LegionDaemonApi = {
13894
14012
  response: object({ token: nonEmptyString, appLogin: string2().endsWith("[bot]") })
13895
14013
  }
13896
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
+ }
13897
14050
  // ../envoy-client/src/defaults.ts
13898
14051
  var DEFAULT_ENVOY_URL = "http://127.0.0.1:9020";
13899
14052
  var DEFAULT_HEARTBEAT_MS = 120000;
@@ -13912,81 +14065,109 @@ function normalizeEnvoyUrl(value) {
13912
14065
  return value.replace(/\/+$/, "");
13913
14066
  }
13914
14067
 
13915
- // ../envoy-client/src/dispatch-contract.ts
13916
- var DISPATCH_TOOL_NAME = "dispatch";
13917
- var DISPATCH_CONTEXT_MAX = 1200;
13918
- var DISPATCH_QUESTION_MAX = 800;
13919
- var DISPATCH_URGENCIES = ["low", "med", "high", "blocking"];
13920
- 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.";
13921
- var DISPATCH_ARGUMENTS = {
13922
- subject: "Open a new thread: one line naming the decision needed (the issue title). Omit when continuing a thread with `thread`.",
13923
- 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.',
13924
- 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.`,
13925
- 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\`.`,
13926
- 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.",
13927
- urgency: "low | med | high | blocking (default med). Opening a thread only.",
13928
- repo: "owner/name. Opening a thread only; defaults to the working directory's GitHub repo.",
13929
- 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."
13930
- };
13931
- class DispatchArgumentError extends Error {
13932
- name = "DispatchArgumentError";
13933
- }
13934
- var prose = {
13935
- context: string2(),
13936
- question: string2(),
13937
- ask: array(DispatchQuestionInputSchema).optional()
13938
- };
13939
- var OpenThreadCallSchema = strictObject({
13940
- subject: string2(),
13941
- ...prose,
13942
- urgency: _enum2(DISPATCH_URGENCIES).optional(),
13943
- repo: string2().optional(),
13944
- parent: string2().optional()
13945
- });
13946
- var ContinueThreadCallSchema = strictObject({ thread: string2(), ...prose });
13947
- var dispatchToolShape = {
13948
- subject: string2().describe(DISPATCH_ARGUMENTS.subject).optional(),
13949
- thread: string2().describe(DISPATCH_ARGUMENTS.thread).optional(),
13950
- context: string2().describe(DISPATCH_ARGUMENTS.context),
13951
- question: string2().describe(DISPATCH_ARGUMENTS.question),
13952
- ask: array(DispatchQuestionInputSchema).describe(DISPATCH_ARGUMENTS.ask).optional(),
13953
- urgency: _enum2(DISPATCH_URGENCIES).describe(DISPATCH_ARGUMENTS.urgency).optional(),
13954
- repo: string2().describe(DISPATCH_ARGUMENTS.repo).optional(),
13955
- parent: string2().describe(DISPATCH_ARGUMENTS.parent).optional()
13956
- };
13957
- var DISPATCH_TOOL_JSON_SCHEMA = toJSONSchema(object(dispatchToolShape));
13958
- function isContinueCall(call) {
13959
- 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);
13960
14076
  }
13961
- function present(raw) {
13962
- 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(/\/+$/, "");
13963
14082
  }
13964
- function describeIssues(error) {
13965
- return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
14083
+ function deprecatedMcpUrl(url2) {
14084
+ return normalizeDispatchUrl(url2).replace(/\/mcp$/, "");
13966
14085
  }
13967
- function parseDispatchCall(raw) {
13968
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
13969
- 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` };
13970
14093
  }
13971
- const args = present(raw);
13972
- const hasSubject = "subject" in args;
13973
- const hasThread = "thread" in args;
13974
- if (hasSubject && hasThread) {
13975
- 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}`;
13976
14111
  }
13977
- if (!hasSubject && !hasThread) {
13978
- 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" };
13979
14120
  }
13980
- if (hasThread && (("urgency" in args) || ("repo" in args) || ("parent" in args))) {
13981
- 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)})` };
13982
14126
  }
13983
- const parsed = hasThread ? ContinueThreadCallSchema.safeParse(args) : OpenThreadCallSchema.safeParse(args);
14127
+ const parsed = EnvoyFileSchema.safeParse(parsedJson);
13984
14128
  if (!parsed.success) {
13985
- 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
+ };
13986
14164
  }
13987
- return parsed.data;
14165
+ return { enabled: true, url: url2.url, token, error: null };
13988
14166
  }
13989
14167
 
14168
+ // ../envoy-client/src/dispatch-execute.ts
14169
+ import { resolve as resolvePath } from "path";
14170
+
13990
14171
  // ../envoy-client/src/dispatch-cwd.ts
13991
14172
  import { execFile } from "child_process";
13992
14173
  import { promisify } from "util";
@@ -14069,229 +14250,592 @@ async function resolveOrigin(env, exec, cwd) {
14069
14250
  return origin;
14070
14251
  }
14071
14252
 
14072
- // ../envoy-client/src/errors.ts
14073
- function messageFor(error) {
14074
- return error instanceof Error ? error.message : String(error);
14075
- }
14076
-
14077
- // ../envoy-client/src/dispatch-client.ts
14078
- function ghTokenGetter(cwd, exec = defaultExec) {
14079
- return async () => {
14080
- try {
14081
- const { stdout } = await exec("gh", ["auth", "token"], { cwd });
14082
- const value = stdout.trim();
14083
- return value.length > 0 ? value : null;
14084
- } catch {
14085
- return null;
14086
- }
14087
- };
14088
- }
14089
-
14253
+ // ../envoy-client/src/dispatch-http.ts
14090
14254
  class DispatchServiceError extends Error {
14091
- kind;
14255
+ code;
14256
+ status;
14257
+ candidates;
14092
14258
  name = "DispatchServiceError";
14093
- constructor(kind, message) {
14259
+ constructor(code, status, message, candidates) {
14094
14260
  super(message);
14095
- this.kind = kind;
14261
+ this.code = code;
14262
+ this.status = status;
14263
+ this.candidates = candidates;
14096
14264
  }
14097
14265
  }
14098
- function parseResponseBody(body, contentType) {
14099
- if (!contentType.includes("text/event-stream"))
14100
- return JSON.parse(body);
14101
- for (const line of body.split(`
14102
- `)) {
14103
- const payload = line.match(/^data:\s*(.+)$/)?.[1];
14104
- if (payload !== undefined)
14105
- return JSON.parse(payload);
14106
- }
14107
- return null;
14266
+ function asErrorShape(value) {
14267
+ return typeof value === "object" && value !== null ? value : {};
14108
14268
  }
14109
- function isDispatchServiceResult(value) {
14110
- 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;
14111
14271
  }
14112
- async function callDispatch(options, args) {
14113
- const token = await options.getToken();
14114
- if (!token) {
14115
- throw new DispatchServiceError("auth", `dispatch: gh auth token returned empty in ${args.origin.cwd} \u2014 check your gh-app setup`);
14116
- }
14117
- const fetchImpl = options.fetchImpl ?? fetch;
14118
- let response;
14119
- try {
14120
- response = await fetchImpl(options.serviceUrl, {
14121
- method: "POST",
14122
- headers: {
14123
- Authorization: `Bearer ${token}`,
14124
- "Content-Type": "application/json",
14125
- Accept: "application/json, text/event-stream"
14126
- },
14127
- body: JSON.stringify({
14128
- jsonrpc: "2.0",
14129
- id: 1,
14130
- method: "tools/call",
14131
- params: { name: DISPATCH_TOOL_NAME, arguments: args }
14132
- })
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 }
14133
14310
  });
14134
- } catch (error) {
14135
- throw new DispatchServiceError("transport", `dispatch service unreachable at ${options.serviceUrl}: ${messageFor(error)}`);
14136
14311
  }
14137
- const body = await response.text();
14138
- if (response.status === 401) {
14139
- 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);
14140
14314
  }
14141
- if (!response.ok) {
14142
- 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);
14143
14325
  }
14144
- let parsed;
14145
- try {
14146
- parsed = parseResponseBody(body, response.headers.get("content-type") ?? "");
14147
- } catch (error) {
14148
- 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]);
14149
14328
  }
14150
- const rpc = parsed ?? {};
14151
- if (rpc.error) {
14152
- 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)]);
14153
14331
  }
14154
- const text = (rpc.result?.content ?? []).map((item) => item.text ?? "").filter(Boolean).join(`
14155
- `);
14156
- if (rpc.result?.isError)
14157
- throw new DispatchServiceError("tool", text || "dispatch failed");
14158
- let result;
14159
- try {
14160
- result = JSON.parse(text);
14161
- } catch {
14162
- 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);
14163
14334
  }
14164
- if (!isDispatchServiceResult(result)) {
14165
- 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;
14166
14446
  }
14167
- return result;
14168
14447
  }
14169
14448
 
14170
- // ../envoy-client/src/dispatch-call.ts
14171
- var QUALIFIED_REF = /^[^/\s#]+\/[^/\s#]+#\d+/;
14172
- async function prepareDispatchCall(input) {
14173
- const { call, cwd } = input;
14174
- const continuing = isContinueCall(call);
14175
- const needsRepo = continuing ? !QUALIFIED_REF.test(call.thread) : call.repo === undefined && !QUALIFIED_REF.test(call.parent ?? "");
14176
- let repo;
14177
- if (needsRepo) {
14178
- const resolved = await resolveCwdRepo(cwd, input.exec);
14179
- if (resolved === null) {
14180
- 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`);
14181
- }
14182
- repo = resolved;
14183
- }
14184
- const resolvedOrigin = await resolveOrigin(input.env, input.exec, cwd);
14185
- const origin = {
14186
- ...resolvedOrigin,
14187
- host: input.host,
14188
- ...input.sessionId ? { sessionId: input.sessionId } : {},
14189
- ...input.sessionTitle ? { sessionTitle: input.sessionTitle } : {}
14190
- };
14191
- 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;
14192
14461
  }
14193
- async function executeDispatch(input) {
14194
- const exec = input.exec ?? defaultExec;
14195
- const prepared = await prepareDispatchCall({
14196
- call: input.call,
14197
- cwd: input.cwd,
14198
- host: input.host,
14199
- ...input.sessionId === undefined ? {} : { sessionId: input.sessionId },
14200
- ...input.sessionTitle === undefined ? {} : { sessionTitle: input.sessionTitle },
14201
- env: input.env ?? process.env,
14202
- exec
14203
- });
14204
- return callDispatch({
14205
- serviceUrl: input.serviceUrl,
14206
- getToken: input.getToken ?? ghTokenGetter(input.cwd, exec),
14207
- ...input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}
14208
- }, prepared);
14462
+ function optionalString(args, name) {
14463
+ const value = args[name];
14464
+ return typeof value === "string" ? value : undefined;
14209
14465
  }
14210
-
14211
- // ../envoy-client/src/dispatch-config.ts
14212
- import { readFileSync } from "fs";
14213
- import { homedir } from "os";
14214
- import * as path from "path";
14215
- var DEFAULT_SERVER_URL = "http://localhost:8766";
14216
- var EnvoyFileSchema = looseObject({
14217
- $schema: string2().optional(),
14218
- natsUrls: array(string2()).optional(),
14219
- dispatch: strictObject({
14220
- enabled: boolean2().optional(),
14221
- serverUrl: url().optional()
14222
- }).optional()
14223
- });
14224
- function describeSchemaIssue(filePath, error) {
14225
- const issue = error.issues[0];
14226
- if (!issue)
14227
- return `${filePath}: invalid dispatch config`;
14228
- if (issue.code === "unrecognized_keys") {
14229
- const keys = issue.keys.map((key) => `dispatch.${key}`).join(", ");
14230
- 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
+ };
14231
14494
  }
14232
- 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 };
14233
14541
  }
14234
- function readEnvoyFile(filePath) {
14235
- let raw;
14236
- try {
14237
- raw = readFileSync(filePath, "utf-8");
14238
- } catch {
14239
- 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}`);
14240
14547
  }
14241
- 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;
14242
14615
  try {
14243
- parsedJson = JSON.parse(raw);
14244
- } catch (err) {
14245
- 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;
14246
14621
  }
14247
- const parsed = EnvoyFileSchema.safeParse(parsedJson);
14248
- if (!parsed.success) {
14249
- 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}`);
14250
14816
  }
14251
- return { kind: "valid", settings: parsed.data.dispatch ?? null };
14252
14817
  }
14253
- function resolveDispatchConfig(env, options = {}) {
14254
- const explicit = env.DISPATCH_MCP_URL;
14255
- if (explicit)
14256
- return { url: explicit, error: null };
14257
- const home = options.home ?? env.HOME ?? homedir();
14258
- const cwd = options.cwd ?? process.cwd();
14259
- const userFile = readEnvoyFile(path.join(home, ".config", "opencode", "envoy.json"));
14260
- const repoFile = readEnvoyFile(path.join(cwd, ".opencode", "envoy.json"));
14261
- for (const file of [userFile, repoFile]) {
14262
- if (file.kind === "invalid")
14263
- 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;
14264
14827
  }
14265
- const merged = {
14266
- ...userFile.kind === "valid" ? userFile.settings : null,
14267
- ...repoFile.kind === "valid" ? repoFile.settings : null
14268
- };
14269
- if (merged.enabled !== true)
14270
- return { url: null, error: null };
14271
- const baseUrl = (merged.serverUrl ?? DEFAULT_SERVER_URL).replace(/\/+$/, "");
14272
- return { url: `${baseUrl}/mcp`, error: null };
14828
+ }
14829
+ function asObject(value) {
14830
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
14273
14831
  }
14274
14832
 
14275
14833
  // ../envoy-client/src/dispatch-subscribe.ts
14276
- var ISSUE_URL_RE = /https?:\/\/github\.com\/([^/\s"]+)\/([^/\s"]+)\/issues\/(\d+)/i;
14277
- function isDispatchTool(tool) {
14278
- return tool === DISPATCH_TOOL_NAME;
14279
- }
14280
- function dispatchThreadTopic(owner, repo, thread) {
14281
- return `notifications.github.${owner}.${repo}.issue.${thread}.>`;
14282
- }
14283
- function dispatchSubscriptionTopic(tool, output) {
14284
- if (!isDispatchTool(tool))
14285
- return null;
14286
- const match = ISSUE_URL_RE.exec(output);
14287
- if (!match)
14288
- return null;
14289
- const owner = match[1];
14290
- const repo = match[2];
14291
- const thread = Number(match[3]);
14292
- if (!Number.isInteger(thread) || thread <= 0)
14834
+ function dispatchSubscriptionTopic(details) {
14835
+ if (typeof details !== "object" || details === null || !("topic" in details))
14293
14836
  return null;
14294
- return dispatchThreadTopic(owner, repo, thread);
14837
+ const { topic } = details;
14838
+ return typeof topic === "string" && topic.startsWith("notifications.dispatch.") ? topic : null;
14295
14839
  }
14296
14840
 
14297
14841
  // ../envoy-client/src/tool-contract.ts
@@ -14305,10 +14849,19 @@ function messageMetadataShape(schema) {
14305
14849
  supersedes: schema.string().optional(),
14306
14850
  urgency: schema.enum(URGENCY_VALUES).optional(),
14307
14851
  expects_reply: schema.enum(EXPECTS_REPLY_VALUES).optional(),
14308
- 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 }
14309
14863
  };
14310
14864
  }
14311
- var MessageMetadataSchema = object(messageMetadataShape(exports_external));
14312
14865
  function messageArguments(schema) {
14313
14866
  return { message: schema.string(), ...messageMetadataShape(schema) };
14314
14867
  }
@@ -14698,33 +15251,12 @@ var publishSpec = toolSpec("envoy_publish");
14698
15251
  var roleSetSpec = toolSpec("envoy_role_set");
14699
15252
  var whoamiSpec = toolSpec("envoy_whoami");
14700
15253
  var sessionsSpec = toolSpec("envoy_sessions");
14701
- var dispatchQuestionOption = tool.schema.strictObject({
14702
- label: tool.schema.string().min(1),
14703
- description: tool.schema.string().optional()
14704
- });
14705
- var dispatchQuestion = tool.schema.strictObject({
14706
- question: tool.schema.string().min(1),
14707
- header: tool.schema.string().optional(),
14708
- options: tool.schema.array(dispatchQuestionOption).optional(),
14709
- multiple: tool.schema.boolean().optional(),
14710
- custom: tool.schema.boolean().optional()
14711
- });
14712
- var dispatchArgs = {
14713
- subject: tool.schema.string().describe(DISPATCH_ARGUMENTS.subject).optional(),
14714
- thread: tool.schema.string().describe(DISPATCH_ARGUMENTS.thread).optional(),
14715
- context: tool.schema.string().describe(DISPATCH_ARGUMENTS.context),
14716
- question: tool.schema.string().describe(DISPATCH_ARGUMENTS.question),
14717
- ask: tool.schema.array(dispatchQuestion).describe(DISPATCH_ARGUMENTS.ask).optional(),
14718
- urgency: tool.schema.enum(DISPATCH_URGENCIES).describe(DISPATCH_ARGUMENTS.urgency).optional(),
14719
- repo: tool.schema.string().describe(DISPATCH_ARGUMENTS.repo).optional(),
14720
- parent: tool.schema.string().describe(DISPATCH_ARGUMENTS.parent).optional()
14721
- };
14722
15254
  var server_default = async (input) => {
14723
15255
  const cwd = process.cwd();
14724
15256
  const dispatchConfig = resolveDispatchConfig(process.env, { cwd });
14725
- if (dispatchConfig.error !== null)
14726
- throw new Error(`[envoy-plugin] ${dispatchConfig.error}`);
14727
- const dispatchServiceUrl = dispatchConfig.url;
15257
+ if (!dispatchConfig.enabled) {
15258
+ logger.warn(`envoy: dispatch tools disabled \u2014 ${dispatchConfig.error ?? "no Dispatch URL configured"}`);
15259
+ }
14728
15260
  const envoyDefaults = envoyDefaultsFromEnvironment(process.env);
14729
15261
  const envoy = createEnvoyClient({ baseUrl: envoyDefaults.envoyUrl, fetch: globalThis.fetch });
14730
15262
  let activeSessionID = null;
@@ -14795,25 +15327,29 @@ var server_default = async (input) => {
14795
15327
  clearInterval(timer);
14796
15328
  clearInterval(heartbeatInterval);
14797
15329
  });
14798
- const dispatchTool = dispatchServiceUrl === null ? {} : {
14799
- [DISPATCH_TOOL_NAME]: tool({
14800
- description: DISPATCH_TOOL_DESCRIPTION,
14801
- args: dispatchArgs,
14802
- async execute(args, ctx) {
14803
- ctx.metadata({ title: "Dispatch" });
14804
- const call = parseDispatchCall(args);
14805
- const result = await executeDispatch({
14806
- call,
14807
- cwd: ctx.directory,
14808
- host: "opencode",
14809
- sessionId: ctx.sessionID,
14810
- sessionTitle: await fetchTitle(ctx.sessionID) ?? undefined,
14811
- serviceUrl: dispatchServiceUrl
14812
- });
14813
- return JSON.stringify(result);
14814
- }
14815
- })
14816
- };
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
+ }
14817
15353
  return {
14818
15354
  config: (cfg) => {
14819
15355
  if (skillsDirectory) {
@@ -14874,7 +15410,7 @@ var server_default = async (input) => {
14874
15410
  }
14875
15411
  },
14876
15412
  "tool.execute.after": async (input, output) => {
14877
- const topic = dispatchSubscriptionTopic(input.tool, output.output);
15413
+ const topic = dispatchSubscriptionTopic(output.metadata);
14878
15414
  if (!topic)
14879
15415
  return;
14880
15416
  try {
@@ -14896,10 +15432,10 @@ var server_default = async (input) => {
14896
15432
  clearInterval(heartbeatInterval);
14897
15433
  },
14898
15434
  tool: {
14899
- ...dispatchTool,
15435
+ ...dispatchTools,
14900
15436
  envoy_subscribe: tool({
14901
15437
  description: subscribeSpec.description,
14902
- args: { topics: tool.schema.array(tool.schema.string()) },
15438
+ args: subscribeSpec.arguments(zodSchemaApi(tool.schema)),
14903
15439
  async execute(args, ctx) {
14904
15440
  ctx.metadata({ title: "Envoy subscribe" });
14905
15441
  return JSON.stringify(await envoy.subscribe({
@@ -14914,16 +15450,19 @@ var server_default = async (input) => {
14914
15450
  }),
14915
15451
  envoy_unsubscribe: tool({
14916
15452
  description: unsubscribeSpec.description,
14917
- args: { topics: tool.schema.array(tool.schema.string()).optional() },
15453
+ args: unsubscribeSpec.arguments(zodSchemaApi(tool.schema)),
14918
15454
  async execute(args, ctx) {
14919
15455
  ctx.metadata({ title: "Envoy unsubscribe" });
14920
- await envoy.unsubscribe({ sessionID: ctx.sessionID, topics: args.topics ?? [] });
15456
+ await envoy.unsubscribe({
15457
+ sessionID: ctx.sessionID,
15458
+ topics: args.topics ?? []
15459
+ });
14921
15460
  return "ok";
14922
15461
  }
14923
15462
  }),
14924
15463
  envoy_list: tool({
14925
15464
  description: listSpec.description,
14926
- args: {},
15465
+ args: listSpec.arguments(zodSchemaApi(tool.schema)),
14927
15466
  async execute(_args, ctx) {
14928
15467
  ctx.metadata({ title: "Envoy list" });
14929
15468
  return JSON.stringify(await envoy.getInterest(ctx.sessionID));
@@ -14931,31 +15470,33 @@ var server_default = async (input) => {
14931
15470
  }),
14932
15471
  envoy_send: tool({
14933
15472
  description: sendSpec.description,
14934
- args: { session_id: tool.schema.string(), message: tool.schema.string() },
15473
+ args: sendSpec.arguments(zodSchemaApi(tool.schema)),
14935
15474
  async execute(args, ctx) {
14936
15475
  ctx.metadata({ title: "Envoy send" });
14937
15476
  return JSON.stringify(await envoy.send({
14938
15477
  sourceSessionID: ctx.sessionID,
14939
15478
  targetSessionID: args.session_id,
14940
- message: args.message
15479
+ message: args.message,
15480
+ ...toMessageMetadata(args)
14941
15481
  }));
14942
15482
  }
14943
15483
  }),
14944
15484
  envoy_publish: tool({
14945
15485
  description: publishSpec.description,
14946
- args: { topic: tool.schema.string(), message: tool.schema.string() },
15486
+ args: publishSpec.arguments(zodSchemaApi(tool.schema)),
14947
15487
  async execute(args, ctx) {
14948
15488
  ctx.metadata({ title: "Envoy publish" });
14949
15489
  return JSON.stringify(await envoy.publish({
14950
15490
  sourceSessionID: ctx.sessionID,
14951
15491
  topic: args.topic,
14952
- message: args.message
15492
+ message: args.message,
15493
+ ...toMessageMetadata(args)
14953
15494
  }));
14954
15495
  }
14955
15496
  }),
14956
15497
  envoy_role_set: tool({
14957
15498
  description: roleSetSpec.description,
14958
- args: { role: tool.schema.string() },
15499
+ args: roleSetSpec.arguments(zodSchemaApi(tool.schema)),
14959
15500
  async execute(args, ctx) {
14960
15501
  ctx.metadata({ title: "Set Envoy role" });
14961
15502
  return JSON.stringify(await envoy.setRole({ sessionID: ctx.sessionID, role: args.role }));
@@ -14963,7 +15504,7 @@ var server_default = async (input) => {
14963
15504
  }),
14964
15505
  envoy_whoami: tool({
14965
15506
  description: whoamiSpec.description,
14966
- args: {},
15507
+ args: whoamiSpec.arguments(zodSchemaApi(tool.schema)),
14967
15508
  async execute(_args, ctx) {
14968
15509
  ctx.metadata({ title: "Envoy whoami" });
14969
15510
  const sessionID = ctx.sessionID;
@@ -14978,11 +15519,15 @@ var server_default = async (input) => {
14978
15519
  }),
14979
15520
  envoy_sessions: tool({
14980
15521
  description: sessionsSpec.description,
14981
- args: { machine: tool.schema.string().optional() },
15522
+ args: sessionsSpec.arguments(zodSchemaApi(tool.schema)),
14982
15523
  async execute(args, ctx) {
14983
15524
  ctx.metadata({ title: "Envoy sessions" });
14984
- const sessions = await envoy.listSessions();
14985
- 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);
14986
15531
  }
14987
15532
  })
14988
15533
  }