@sjawhar/pi-envoy 10.3.0 → 10.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/dispatch.js CHANGED
@@ -16577,14 +16577,14 @@ function toolSchema(tool) {
16577
16577
  toolSchemas.set(tool, schema);
16578
16578
  return schema;
16579
16579
  }
16580
- async function resolveOwnerArguments(tool, input, cwd, env, exec, serverUrl, problems) {
16580
+ async function resolveOwnerArguments(tool, input, cwd, env, exec, dashboardUrls, problems) {
16581
16581
  if (issueFreeTools[tool] === true)
16582
16582
  return { args: input, ref: null, owner: null };
16583
16583
  const refArgument = input.ref;
16584
16584
  let ref = null;
16585
16585
  let args = input;
16586
16586
  if (typeof refArgument === "string") {
16587
- const refText = dispatchRefFromUrl(refArgument, serverUrl) ?? refArgument;
16587
+ const refText = dashboardUrls.map((dashboard) => dispatchRefFromUrl(refArgument, dashboard)).find((ref2) => ref2 !== undefined) ?? refArgument;
16588
16588
  if (refText !== refArgument)
16589
16589
  args = { ...input, ref: refText };
16590
16590
  const agentArtifact = parseAgentArtifactRef(refText);
@@ -16846,7 +16846,7 @@ function progressText(progress, sep) {
16846
16846
  }
16847
16847
  return parts.join(sep);
16848
16848
  }
16849
- function issueSummary(issue2, events, references, graph, titles) {
16849
+ function issueSummary(issue2, events, references, graph, linkBase, titles) {
16850
16850
  const asks = issue2.open_asks;
16851
16851
  const spec = issue2.artifacts?.find((artifact) => artifact.primary);
16852
16852
  const specApproval = spec === undefined ? undefined : approvalLine(spec);
@@ -16864,6 +16864,7 @@ function issueSummary(issue2, events, references, graph, titles) {
16864
16864
  return [
16865
16865
  `Title: ${issue2.title}`,
16866
16866
  `Key: ${issue2.key}`,
16867
+ `Link: ${new URL(`/issues/${encodeURIComponent(issue2.key)}`, linkBase).toString()}`,
16867
16868
  `Status: ${issue2.status}`,
16868
16869
  `Assignee: ${issue2.assignee ?? "unassigned"}`,
16869
16870
  `Claimed by: ${issue2.claim === null ? "nobody" : claimText(issue2.claim, titles)}`,
@@ -17328,6 +17329,7 @@ function withAccount(error48, account) {
17328
17329
  }
17329
17330
  async function executeDispatchTool(input) {
17330
17331
  const { url: configUrl, token: configToken } = input.config;
17332
+ const linkBase = input.config.publicUrl ?? configUrl;
17331
17333
  const baseFetch = input.fetchImpl ?? fetch;
17332
17334
  const fetchImpl = Object.assign(async (...args2) => {
17333
17335
  try {
@@ -17342,7 +17344,7 @@ async function executeDispatchTool(input) {
17342
17344
  const env = input.env ?? process.env;
17343
17345
  const exec = input.exec ?? defaultExec;
17344
17346
  const problems = [];
17345
- const ownerArguments = await resolveOwnerArguments(input.tool, input.args, input.cwd, env, exec, configUrl, problems);
17347
+ const ownerArguments = await resolveOwnerArguments(input.tool, input.args, input.cwd, env, exec, input.config.publicUrl === undefined ? [configUrl] : [configUrl, input.config.publicUrl], problems);
17346
17348
  const ownerMissing = issueFreeTools[input.tool] !== true && ownerArguments.owner === null;
17347
17349
  const schema = toolSchema(input.tool);
17348
17350
  const parsed = schema.safeParse(ownerArguments.args, { reportInput: true });
@@ -17360,13 +17362,13 @@ async function executeDispatchTool(input) {
17360
17362
  const project = optionalString(ownerArguments.args, "project");
17361
17363
  if (project !== undefined) {
17362
17364
  const response2 = await client2.openAsksForProject(project);
17363
- return { text: formatOpenAsksSummary(response2, configUrl), details: { ...response2 } };
17365
+ return { text: formatOpenAsksSummary(response2, linkBase), details: { ...response2 } };
17364
17366
  }
17365
17367
  const sessionId = input.sessionId?.trim();
17366
17368
  if (!sessionId)
17367
17369
  throw new Error("host session id is required for dispatch open-asks");
17368
17370
  const response = await client2.openAsks(sessionId);
17369
- return { text: formatOpenAsksSummary(response, configUrl), details: { ...response } };
17371
+ return { text: formatOpenAsksSummary(response, linkBase), details: { ...response } };
17370
17372
  }
17371
17373
  if (input.tool === "dispatch_whoami") {
17372
17374
  const sessionId = input.sessionId?.trim();
@@ -17448,11 +17450,13 @@ async function executeDispatchTool(input) {
17448
17450
  isPrimarySpec: spec !== undefined
17449
17451
  }),
17450
17452
  ...componentGuidance === undefined ? [] : [componentGuidance],
17451
- ...renderSuggestions(created.advice?.suggestions, configUrl)
17453
+ ...renderSuggestions(created.advice?.suggestions, linkBase)
17452
17454
  ];
17455
+ const href = new URL(`/issues/${encodeURIComponent(created.key)}`, linkBase).toString();
17456
+ const assigned = created.assignee === undefined ? "" : created.assignee === null ? "; unassigned" : `; assigned to ${created.assignee}`;
17453
17457
  return {
17454
17458
  text: [
17455
- `Created ${created.key}: ${created.title} ${notSubscribed(issueTopic(created.key))}`,
17459
+ `Created ${created.key}: ${created.title} \u2192 ${href}${assigned} ${notSubscribed(issueTopic(created.key))}`,
17456
17460
  ...adviceLines
17457
17461
  ].join(`
17458
17462
  `),
@@ -17470,7 +17474,7 @@ async function executeDispatchTool(input) {
17470
17474
  text: [
17471
17475
  `Not created: "${title}" looks like a duplicate.`,
17472
17476
  ...candidates.map((candidate) => {
17473
- const href = new URL(candidate.href, configUrl).toString();
17477
+ const href = new URL(candidate.href, linkBase).toString();
17474
17478
  return `${candidate.key} [${candidate.status}] ${candidate.title} \u2192 ${href}`;
17475
17479
  }),
17476
17480
  "Reference the existing issue, or run dispatch issue again with --force after reading it."
@@ -17611,7 +17615,7 @@ async function executeDispatchTool(input) {
17611
17615
  ...limit === undefined ? {} : { limit },
17612
17616
  ...offset === undefined ? {} : { offset }
17613
17617
  });
17614
- return searchAnswer(search, query, offset, configUrl);
17618
+ return searchAnswer(search, query, offset, linkBase);
17615
17619
  }
17616
17620
  case "dispatch_issues": {
17617
17621
  const project = stringArg(args, "project");
@@ -17675,7 +17679,7 @@ async function executeDispatchTool(input) {
17675
17679
  } catch (error48) {
17676
17680
  throw refusalWithCode(error48);
17677
17681
  }
17678
- const href = new URL(`/projects/${encodeURIComponent(project.key)}`, configUrl).toString();
17682
+ const href = new URL(`/projects/${encodeURIComponent(project.key)}`, linkBase).toString();
17679
17683
  return {
17680
17684
  text: `Created project ${project.key} (${project.name}) at ${project.created_at}: ${href}`,
17681
17685
  details: { project: project.key, name: project.name, created_at: project.created_at }
@@ -17768,7 +17772,7 @@ async function executeDispatchTool(input) {
17768
17772
  const askOwner = ask.issue_key !== null ? issueTopic(ask.issue_key) : resolved === undefined ? issueTopic(issue2()) : documentTopic(resolved.artifact);
17769
17773
  const adviceLines = [
17770
17774
  ...renderAdvice(input.tool, askOwner.label, ask.advice, {}),
17771
- ...renderSuggestions(ask.advice?.suggestions, configUrl)
17775
+ ...renderSuggestions(ask.advice?.suggestions, linkBase)
17772
17776
  ];
17773
17777
  return {
17774
17778
  text: [
@@ -18249,7 +18253,7 @@ ${trailer.join(`
18249
18253
  ])
18250
18254
  ]);
18251
18255
  return {
18252
- text: issueSummary(read.issue, read.events, references, [...pictures2.lines, ...graph], titles),
18256
+ text: issueSummary(read.issue, read.events, references, [...pictures2.lines, ...graph], linkBase, titles),
18253
18257
  details: { issue: read.issue.key },
18254
18258
  ...showImages(pictures2)
18255
18259
  };
@@ -18987,7 +18991,17 @@ function dispatchSettings(env, config2) {
18987
18991
  }
18988
18992
  const url2 = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
18989
18993
  const token = source.kind === "variable" ? source.token : fileBearer(source, url2, "DISPATCH", env);
18990
- return { enabled: true, url: url2, token };
18994
+ const publicUrl = nonEmpty(env.DISPATCH_PUBLIC_URL);
18995
+ const publicParsed = publicUrl === undefined ? undefined : httpUrl2(publicUrl);
18996
+ if (publicUrl !== undefined && (publicParsed === undefined || publicParsed.port === "0")) {
18997
+ return offWithError("DISPATCH_PUBLIC_URL must be an http or https URL");
18998
+ }
18999
+ return {
19000
+ enabled: true,
19001
+ url: url2,
19002
+ token,
19003
+ ...publicUrl === undefined ? {} : { publicUrl: publicUrl.replace(/\/+$/, "") }
19004
+ };
18991
19005
  } catch (error48) {
18992
19006
  return offWithError(messageFor(error48));
18993
19007
  }
package/dist/envoy.js CHANGED
@@ -30463,6 +30463,12 @@ var DISPATCH_DOCUMENT_TOPIC_PREFIX = `${DISPATCH_TOPIC_PREFIX}document.`;
30463
30463
  function agentSubject(session) {
30464
30464
  return `${AGENT_TOPIC_PREFIX}${session}`;
30465
30465
  }
30466
+ // ../contracts/src/slack.ts
30467
+ var SLACK_POST_TEXT_MAX = 4000;
30468
+ function slackTopicChannel(topic) {
30469
+ const [scope, source, , channel] = topic.split(".");
30470
+ return scope === "notifications" && source === "slack" ? channel ?? "" : undefined;
30471
+ }
30466
30472
  // ../contracts/src/tool-schema.ts
30467
30473
  function overCapMessage(length, max) {
30468
30474
  return `is ${length - max} characters over the ${max}-character limit (${length}/${max})`;
@@ -32577,7 +32583,17 @@ function dispatchSettings(env, config2) {
32577
32583
  }
32578
32584
  const url2 = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
32579
32585
  const token = source.kind === "variable" ? source.token : fileBearer(source, url2, "DISPATCH", env);
32580
- return { enabled: true, url: url2, token };
32586
+ const publicUrl = nonEmpty(env.DISPATCH_PUBLIC_URL);
32587
+ const publicParsed = publicUrl === undefined ? undefined : httpUrl2(publicUrl);
32588
+ if (publicUrl !== undefined && (publicParsed === undefined || publicParsed.port === "0")) {
32589
+ return offWithError("DISPATCH_PUBLIC_URL must be an http or https URL");
32590
+ }
32591
+ return {
32592
+ enabled: true,
32593
+ url: url2,
32594
+ token,
32595
+ ...publicUrl === undefined ? {} : { publicUrl: publicUrl.replace(/\/+$/, "") }
32596
+ };
32581
32597
  } catch (error48) {
32582
32598
  return offWithError(messageFor(error48));
32583
32599
  }
@@ -33698,6 +33714,293 @@ function sessionDispatchSettings(file2, env, options) {
33698
33714
  };
33699
33715
  }
33700
33716
 
33717
+ // ../envoy-client/src/slack-post.ts
33718
+ import { BlockList, isIP } from "net";
33719
+ var SLACK_BOT_TOKEN_SECRET = "LEGION_SLACK_BOT_TOKEN";
33720
+ var SLACK_POST_API_URL_VARIABLE = "SLACK_POST_API_URL";
33721
+ var POST_TIMEOUT_MS = 30000;
33722
+ var SLACK_ERROR_CODES = [
33723
+ "access_denied",
33724
+ "accesslimited",
33725
+ "account_inactive",
33726
+ "agent_prompt_display_not_allowed",
33727
+ "app_access_restricted",
33728
+ "as_user_not_supported",
33729
+ "attachment_payload_limit_exceeded",
33730
+ "cannot_reply_to_message",
33731
+ "channel_not_found",
33732
+ "deprecated_endpoint",
33733
+ "draft_already_deleted",
33734
+ "draft_already_sent",
33735
+ "draft_has_conflict",
33736
+ "draft_not_found",
33737
+ "duplicate_channel_not_found",
33738
+ "duplicate_message_not_found",
33739
+ "ekm_access_denied",
33740
+ "enterprise_is_restricted",
33741
+ "fatal_error",
33742
+ "internal_error",
33743
+ "invalid_arg_name",
33744
+ "invalid_arguments",
33745
+ "invalid_array_arg",
33746
+ "invalid_auth",
33747
+ "invalid_blocks",
33748
+ "invalid_blocks_format",
33749
+ "invalid_charset",
33750
+ "invalid_form_data",
33751
+ "invalid_metadata_format",
33752
+ "invalid_metadata_schema",
33753
+ "invalid_post_type",
33754
+ "is_archived",
33755
+ "markdown_text_conflict",
33756
+ "message_limit_exceeded",
33757
+ "messages_tab_disabled",
33758
+ "metadata_must_be_sent_from_app",
33759
+ "metadata_too_large",
33760
+ "method_deprecated",
33761
+ "missing_file_data",
33762
+ "missing_post_type",
33763
+ "missing_scope",
33764
+ "msg_blocks_too_long",
33765
+ "no_permission",
33766
+ "no_text",
33767
+ "not_allowed_token_type",
33768
+ "not_authed",
33769
+ "not_in_channel",
33770
+ "org_login_required",
33771
+ "rate_limited",
33772
+ "ratelimited",
33773
+ "request_timeout",
33774
+ "restricted_action",
33775
+ "restricted_action_non_threadable_channel",
33776
+ "restricted_action_read_only_channel",
33777
+ "restricted_action_thread_locked",
33778
+ "restricted_action_thread_only_channel",
33779
+ "send_on_behalf_not_allowed",
33780
+ "service_unavailable",
33781
+ "slack_connect_canvas_sharing_blocked",
33782
+ "slack_connect_file_link_sharing_blocked",
33783
+ "slack_connect_lists_sharing_blocked",
33784
+ "team_access_not_granted",
33785
+ "team_added_to_org",
33786
+ "team_not_found",
33787
+ "token_expired",
33788
+ "token_revoked",
33789
+ "too_many_attachments",
33790
+ "too_many_contact_cards",
33791
+ "two_factor_setup_required"
33792
+ ];
33793
+ var POSTER = `
33794
+ const codes = ${JSON.stringify(Object.fromEntries(SLACK_ERROR_CODES.map((code) => [code, true])))};
33795
+ const token = process.env.LEGION_SLACK_BOT_TOKEN || "";
33796
+ const out = (value) => {
33797
+ let line = JSON.stringify(value);
33798
+ if (token !== "") line = line.split(token).join("[redacted]");
33799
+ process.stdout.write(line + "\\n");
33800
+ };
33801
+ let sent = false;
33802
+ try {
33803
+ const input = JSON.parse(await Bun.stdin.text());
33804
+ if (token === "") {
33805
+ out({ invalid: "the poster's environment carries no LEGION_SLACK_BOT_TOKEN" });
33806
+ process.exit(0);
33807
+ }
33808
+ const base = (process.env.SLACK_POST_API_URL || "https://slack.com/api").replace(/\\/+$/, "");
33809
+ let status = 0;
33810
+ let raw = "";
33811
+ try {
33812
+ sent = true;
33813
+ const response = await fetch(base + "/chat.postMessage", {
33814
+ method: "POST",
33815
+ headers: { Authorization: "Bearer " + token, "Content-Type": "application/json; charset=utf-8" },
33816
+ body: JSON.stringify({ channel: input.channel, thread_ts: input.thread_ts, text: input.text }),
33817
+ signal: AbortSignal.timeout(10000),
33818
+ });
33819
+ status = response.status;
33820
+ raw = await response.text();
33821
+ } catch {
33822
+ out({ unanswered: true });
33823
+ process.exit(0);
33824
+ }
33825
+ let answer;
33826
+ try {
33827
+ answer = JSON.parse(raw);
33828
+ } catch {}
33829
+ if (!answer || typeof answer !== "object" || typeof answer.ok !== "boolean") {
33830
+ out({ invalid: "HTTP " + Number(status) + " with a body that is not Slack's JSON" });
33831
+ } else if (!answer.ok) {
33832
+ out({ refused: typeof answer.error === "string" && Object.hasOwn(codes, answer.error) ? answer.error : null });
33833
+ } else if (
33834
+ typeof answer.ts !== "string" || !/^[0-9]{1,12}\\.[0-9]{1,8}$/.test(answer.ts) ||
33835
+ (answer.channel !== undefined && (typeof answer.channel !== "string" || !/^[CDG][A-Z0-9]{1,30}$/.test(answer.channel)))
33836
+ ) {
33837
+ out({ invalid: "ok with a ts or channel that is not Slack's" });
33838
+ } else {
33839
+ out({ ts: answer.ts, channel: answer.channel === undefined ? input.channel : answer.channel });
33840
+ }
33841
+ } catch {
33842
+ out(sent ? { unanswered: true } : { invalid: "a reply the poster could not read" });
33843
+ }
33844
+ `;
33845
+ var MAY_HAVE_POSTED = "the reply may have been posted, and posting it again may put it in the thread twice";
33846
+ var URL_CANDIDATE = /(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,31}:\/\/[^\s<>|"'`]+/gi;
33847
+ var WEB_SCHEME = /^https?:\/\//i;
33848
+ var BARE_HOST = /(?<![\w@./:%-])((?:[a-z0-9-]{1,63}\.)*[a-z0-9-]{1,63})\.?(:\d{1,5})?(?![\w-])/gi;
33849
+ var BARE_IPV6 = /(?<![\w@./:%-])\[([0-9a-f:.]{2,45})(?:%[\w.-]{1,32})?\](?::\d{1,5})?/gi;
33850
+ var TRAILING_PUNCTUATION = /[.,;:!?)\]}]+$/;
33851
+ var CLUSTER_ADDRESSES = new BlockList;
33852
+ for (const [network, prefix] of [
33853
+ ["0.0.0.0", 8],
33854
+ ["10.0.0.0", 8],
33855
+ ["100.64.0.0", 10],
33856
+ ["127.0.0.0", 8],
33857
+ ["169.254.0.0", 16],
33858
+ ["172.16.0.0", 12],
33859
+ ["192.168.0.0", 16]
33860
+ ]) {
33861
+ CLUSTER_ADDRESSES.addSubnet(network, prefix, "ipv4");
33862
+ }
33863
+ for (const [network, prefix] of [
33864
+ ["::", 96],
33865
+ ["fc00::", 7],
33866
+ ["fe80::", 10]
33867
+ ]) {
33868
+ CLUSTER_ADDRESSES.addSubnet(network, prefix, "ipv6");
33869
+ }
33870
+ function linkInsideCluster(text, env) {
33871
+ const { DISPATCH_URL: apiUrl, DISPATCH_PUBLIC_URL: publicUrl } = env;
33872
+ const apiHost = hostOf(apiUrl);
33873
+ const publicHost = hostOf(publicUrl);
33874
+ const dispatchHost = apiHost !== undefined && publicHost !== undefined && apiHost !== publicHost ? apiHost : undefined;
33875
+ const decoded = text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
33876
+ for (const match of decoded.matchAll(URL_CANDIDATE)) {
33877
+ if (!WEB_SCHEME.test(match[0]))
33878
+ continue;
33879
+ for (const written of new Set([match[0], match[0].replace(TRAILING_PUNCTUATION, "")])) {
33880
+ const host = hostOf(written);
33881
+ if (host !== undefined && insideCluster(host, dispatchHost, true))
33882
+ return written;
33883
+ }
33884
+ }
33885
+ const prose = decoded.replace(URL_CANDIDATE, (url2) => " ".repeat(url2.length));
33886
+ for (const match of prose.matchAll(BARE_IPV6)) {
33887
+ if (insideCluster(`[${match[1]}]`, dispatchHost, false))
33888
+ return match[0];
33889
+ }
33890
+ for (const match of prose.matchAll(BARE_HOST)) {
33891
+ const host = (match[1] ?? "").toLowerCase();
33892
+ if (!host.includes(".") && match[2] === undefined)
33893
+ continue;
33894
+ if (insideCluster(host, dispatchHost, false))
33895
+ return match[0];
33896
+ }
33897
+ return;
33898
+ }
33899
+ function hostOf(value) {
33900
+ if (value === undefined || value === "")
33901
+ return;
33902
+ try {
33903
+ return new URL(value).hostname.toLowerCase().replace(/\.$/, "") || undefined;
33904
+ } catch {
33905
+ return;
33906
+ }
33907
+ }
33908
+ function insideCluster(host, dispatchHost, fromUrl) {
33909
+ if (host === dispatchHost)
33910
+ return true;
33911
+ if (host.endsWith(".svc") || host.includes(".svc.") || host.endsWith(".cluster.local"))
33912
+ return true;
33913
+ const address = host.startsWith("[") ? host.slice(1, -1) : host;
33914
+ const family = isIP(address);
33915
+ if (family !== 0)
33916
+ return CLUSTER_ADDRESSES.check(address, family === 4 ? "ipv4" : "ipv6");
33917
+ return fromUrl && !host.includes(".");
33918
+ }
33919
+ async function postSlackReply(reply, options = {}) {
33920
+ const env = options.env ?? process.env;
33921
+ const internal = linkInsideCluster(reply.text, env);
33922
+ if (internal !== undefined) {
33923
+ throw new Error(`the reply links ${internal}, an address inside the cluster nobody in Slack can open; link Dispatch's dashboard instead (dispatch prints it when DISPATCH_PUBLIC_URL is set); nothing was posted`);
33924
+ }
33925
+ const override = env[SLACK_POST_API_URL_VARIABLE];
33926
+ if (override !== undefined && override !== "" && !allowedAPIURL(override)) {
33927
+ throw new Error(`${SLACK_POST_API_URL_VARIABLE} must be an https URL, or http on a loopback host (got ${override}); nothing was posted`);
33928
+ }
33929
+ const { PATH: searchPath = "" } = env;
33930
+ const client = Bun.which("agent-secrets", { PATH: searchPath });
33931
+ if (client === null) {
33932
+ throw new Error("agent-secrets is not on PATH: slack_post takes the bot token from the secrets broker, which this session is not enrolled with; nothing was posted");
33933
+ }
33934
+ const reason = `slack_post: reply in Slack channel ${reply.channel}, thread ${reply.threadTs}`;
33935
+ const child = Bun.spawn([client, SLACK_BOT_TOKEN_SECRET, "--reason", reason, "--wait", "0s", "--", "bun", "-e", POSTER], {
33936
+ env,
33937
+ stdin: new Blob([
33938
+ JSON.stringify({ channel: reply.channel, thread_ts: reply.threadTs, text: reply.text })
33939
+ ]),
33940
+ stdout: "pipe",
33941
+ stderr: "pipe"
33942
+ });
33943
+ const timer = setTimeout(() => child.kill("SIGKILL"), POST_TIMEOUT_MS);
33944
+ const [stdout, stderr, code] = await Promise.all([
33945
+ new Response(child.stdout).text(),
33946
+ new Response(child.stderr).text(),
33947
+ child.exited
33948
+ ]).finally(() => clearTimeout(timer));
33949
+ const said = stderr.trim().split(`
33950
+ `).filter(Boolean).join(" ");
33951
+ const line = posterLine(stdout);
33952
+ if (line === undefined) {
33953
+ if (child.signalCode === "SIGKILL") {
33954
+ throw new Error(`slack_post got no answer within ${POST_TIMEOUT_MS / 1000} s: ${MAY_HAVE_POSTED}`);
33955
+ }
33956
+ if (said.startsWith("agent-secrets:")) {
33957
+ if (code === 75) {
33958
+ throw new Error(`the secrets broker has not granted ${SLACK_BOT_TOKEN_SECRET}: its request is waiting for approval (${said}); nothing was posted`);
33959
+ }
33960
+ if (code === 77) {
33961
+ throw new Error(`the secrets broker denied ${SLACK_BOT_TOKEN_SECRET} (${said}); nothing was posted`);
33962
+ }
33963
+ throw new Error(`the secrets broker did not release ${SLACK_BOT_TOKEN_SECRET} (${said}); nothing was posted`);
33964
+ }
33965
+ throw new Error(`slack_post's poster exited ${code} without an answer: ${MAY_HAVE_POSTED}`);
33966
+ }
33967
+ if (line.refused === null) {
33968
+ throw new Error("Slack refused chat.postMessage with an error code slack_post does not pass on");
33969
+ }
33970
+ if (line.refused !== undefined)
33971
+ throw new Error(`Slack refused chat.postMessage: ${line.refused}`);
33972
+ if (line.unanswered !== undefined) {
33973
+ throw new Error(`Slack did not answer chat.postMessage: ${MAY_HAVE_POSTED}`);
33974
+ }
33975
+ if (line.ts === undefined) {
33976
+ throw new Error(`chat.postMessage answered ${line.invalid ?? "nothing"}: ${MAY_HAVE_POSTED}`);
33977
+ }
33978
+ return { channel: line.channel ?? reply.channel, threadTs: reply.threadTs, ts: line.ts };
33979
+ }
33980
+ function posterLine(stdout) {
33981
+ const last = stdout.trim().split(`
33982
+ `).at(-1);
33983
+ if (last === undefined || last === "")
33984
+ return;
33985
+ try {
33986
+ const parsed = JSON.parse(last);
33987
+ return typeof parsed === "object" && parsed !== null ? parsed : undefined;
33988
+ } catch {
33989
+ return;
33990
+ }
33991
+ }
33992
+ function allowedAPIURL(value) {
33993
+ let url2;
33994
+ try {
33995
+ url2 = new URL(value);
33996
+ } catch {
33997
+ return false;
33998
+ }
33999
+ if (url2.protocol === "https:")
34000
+ return true;
34001
+ return url2.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url2.hostname);
34002
+ }
34003
+
33701
34004
  // ../envoy-client/src/tool-contract.ts
33702
34005
  var DELIVERY_CONTRACT = "Delivery is at-least-once, possibly out of order across topics; use id for dedupe and at for freshness.";
33703
34006
  var TOPIC_GUIDE = "Topic guide (all are under notifications.): agent.<session_id> (subscribe: own inbox); role.<role> " + "(publish-to; holders claim via envoy_role_set). > matches one or more trailing tokens and does not " + "match the base subject. Envoy registers the concrete base when you subscribe to <subject>.>, so " + "the recommended default remains github.<owner>.<repo>.pr.<n>.>. <owner> and <repo> are one token " + "each, with every dot in the name written _ (sjawhar/.github is github.sjawhar._github; " + "acme/site.io is github.acme.site_io), and a topic spelled with the dot receives nothing. " + "Default PR subscription: " + "github.<owner>.<repo>.pr.<n>.> (it receives the quiet PR family): pr.<n> (lifecycle: " + "opened/synchronize/closed; closed carries merged, merge_commit_sha, merged_by, head_sha), " + "pr.<n>.comment, pr.<n>.review, pr.<n>.mention, pr.<n>.checks (one settlement event per commit whose checks settle, the head or not, naming its sha: " + 'passed/failed/cancelled/skipped with failing names and URLs; re-fires with superseded_settlement: "true" ' + "when new runs appear for the same commit). Other GitHub: issue.<n>, issue.<n>.comment, " + "issue.<n>.mention, mention, push.branch.<name>, push.tag.<name>, workflow.<file>.<action> (only " + "runs without an associated PR); slack.<team>.<channel>.message|mention and " + "slack.<team>.<channel>.thread.<ts>.message|mention; ghostwispr.<session>.<kind>; " + "whatsapp.<phone>.<jid>.<kind>; envoy.exceptions.<original-topic>.";
@@ -33735,7 +34038,8 @@ var EnvoyToolOperation = {
33735
34038
  setRole: "setRole",
33736
34039
  getRole: "getRole",
33737
34040
  whoami: "whoami",
33738
- listSessions: "listSessions"
34041
+ listSessions: "listSessions",
34042
+ slackPost: "slackPost"
33739
34043
  };
33740
34044
  var envoyToolSpecs = [
33741
34045
  {
@@ -33806,6 +34110,16 @@ var envoyToolSpecs = [
33806
34110
  title: schema.string().optional()
33807
34111
  }),
33808
34112
  operation: EnvoyToolOperation.listSessions
34113
+ },
34114
+ {
34115
+ name: "slack_post",
34116
+ description: "Reply in a Slack thread as the Legion Slack app, with the bot token the secrets broker grants this session (LEGION_SLACK_BOT_TOKEN); the session never holds it. channel is the Slack channel id (a mention's channel_id); thread_ts is the thread's root ts: the mention's thread_ts, or its own ts when it has none. Answers the new message's ts. A refusal carries Slack's own error code (not_in_channel: the app is not in that channel) or the broker's state (waiting for approval, denied). Sent once and never retried: after 'Slack did not answer' the reply may already be in the thread, so do not post it again.",
34117
+ arguments: (schema) => ({
34118
+ channel: schema.string({ min: 1 }).describe("Slack channel id, such as C0123ABCD."),
34119
+ thread_ts: schema.string({ min: 1 }).describe("The thread's root ts, which keeps the reply in the mention's thread."),
34120
+ text: schema.string({ min: 1, max: SLACK_POST_TEXT_MAX }).describe(`The reply, in Slack mrkdwn, at most ${SLACK_POST_TEXT_MAX} characters.`)
34121
+ }),
34122
+ operation: EnvoyToolOperation.slackPost
33809
34123
  }
33810
34124
  ];
33811
34125
  function sendConfirmationText(result) {
@@ -34070,7 +34384,7 @@ function sourceSession(sessionID) {
34070
34384
 
34071
34385
  // ../pi-shared/src/interface.ts
34072
34386
  import { logger } from "@oh-my-pi/pi-utils";
34073
- var ENVOY_PLUGIN_INTERFACE_VERSION = 2;
34387
+ var ENVOY_PLUGIN_INTERFACE_VERSION = 3;
34074
34388
  var ENVOY_PLUGIN_INTERFACE_KEY = Symbol.for("legion.pi-shared.envoy-plugin-interface");
34075
34389
  var LEGION_PLUGIN_LOADED_KEY = Symbol.for("legion.pi-legion.loaded");
34076
34390
  var LEGACY_LEGION_LOADED_KEY = Symbol.for("legion.pi-envoy.legion-loaded");
@@ -34084,7 +34398,12 @@ function envoyPluginInterface() {
34084
34398
  const created = {
34085
34399
  version: ENVOY_PLUGIN_INTERFACE_VERSION,
34086
34400
  publishers: [],
34087
- roleClaim: { instances: [], managedSessions: new Set, regained: undefined },
34401
+ roleClaim: {
34402
+ instances: [],
34403
+ managedSessions: new Set,
34404
+ regained: undefined,
34405
+ slackRoleOwned: false
34406
+ },
34088
34407
  injectedUserTurns: new Map,
34089
34408
  bootstrappedSession: { file: undefined }
34090
34409
  };
@@ -35717,9 +36036,21 @@ function envoyExtension(pi) {
35717
36036
  });
35718
36037
  if (registry2 === undefined)
35719
36038
  return;
36039
+ const retired = publishedInterface?.roleClaim.slackRoleOwned === true ? registry2.topics.filter((topic) => slackTopicChannel(topic) !== undefined) : [];
36040
+ if (retired.length > 0) {
36041
+ await client.unsubscribe({ sessionID, topics: retired }).catch((error48) => {
36042
+ logger3.warn("envoy: could not unregister a resumed controller's retired Slack topics", {
36043
+ sessionID,
36044
+ topics: retired,
36045
+ error: messageFor(error48)
36046
+ });
36047
+ });
36048
+ }
35720
36049
  for (const topic of registry2.topics) {
35721
36050
  if (topic === agentSubject(sessionID) || topic.startsWith(ROLE_TOPIC_PREFIX))
35722
36051
  continue;
36052
+ if (retired.includes(topic))
36053
+ continue;
35723
36054
  await subscribe(topic);
35724
36055
  }
35725
36056
  };
@@ -36287,6 +36618,14 @@ Not delivered: that holder is the session this subagent sends as, and the listen
36287
36618
  title
36288
36619
  });
36289
36620
  }
36621
+ case EnvoyToolOperation.slackPost: {
36622
+ const posted = await postSlackReply({
36623
+ channel: stringFor(parameters, "channel"),
36624
+ threadTs: stringFor(parameters, "thread_ts"),
36625
+ text: stringFor(parameters, "text")
36626
+ });
36627
+ return toolSuccess(`posted ${posted.ts} in ${posted.channel}, thread ${posted.threadTs}`, { channel: posted.channel, thread_ts: posted.threadTs, ts: posted.ts });
36628
+ }
36290
36629
  }
36291
36630
  } catch (error48) {
36292
36631
  return toolFailure(error48);
@@ -57,8 +57,9 @@ dispatch://PROJECT/artifact/<document-ref>/comment/<id>
57
57
  ```
58
58
 
59
59
  A bare UUID or `KEY#seq` is not a reference; the `dispatch://` form is what Dispatch links and records. `dispatch read` also
60
- accepts the dashboard URL of an issue, spec, artifact, ask, comment, or project document on the configured server (it maps to the
61
- `dispatch://` form above), and an ask or comment id may be a unique prefix of at least 8 hex characters; a message id is always the
60
+ accepts the dashboard URL of an issue, spec, artifact, ask, comment, or project document on the configured server, or on its public
61
+ dashboard when `DISPATCH_PUBLIC_URL` names one (it maps to the `dispatch://` form above), and an issue's read names its page
62
+ (`Link:`, on that public dashboard when there is one), the link to hand a person. An ask or comment id may be a unique prefix of at least 8 hex characters; a message id is always the
62
63
  full uuid. A non-uuid id on `GET /asks/{id}`, `/comments/{id}`, or `/issues/{key}/messages/{id}` is a 400 `ASK_ID_INPUT` /
63
64
  `COMMENT_ID_INPUT` / `MESSAGE_ID_INPUT`, never a 500.
64
65
 
@@ -237,6 +237,12 @@ envoy_subscribe([
237
237
  This follows every message and mention in one thread. Channel-level topics end in `.message` or
238
238
  `.mention`; thread timestamps replace dots with underscores.
239
239
 
240
+ Reply in a thread with `slack_post(channel=<channel_id>, thread_ts=<the message's thread_ts, or
241
+ its ts>, text=…)`, which posts as the Legion Slack app with the bot token the secrets broker grants
242
+ the session (`LEGION_SLACK_BOT_TOKEN`). A refusal carries Slack's own error code, or the broker's
243
+ state when it has not granted the token. The post is sent once; when the answer is that Slack did
244
+ not answer, the reply may already be there, so do not post it again.
245
+
240
246
  ## Ghost Wispr
241
247
 
242
248
  Ghost Wispr topics are `notifications.ghostwispr.<session>.<kind>`, where `kind` is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-envoy",
3
- "version": "10.3.0",
3
+ "version": "10.5.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "omp": {