@sjawhar/pi-envoy 10.4.0 → 10.6.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
@@ -14033,7 +14033,8 @@ var dispatchToolSpecs = [
14033
14033
  labels: z2.array(z2.string({ min: 1, max: 40 }), { max: 20 }).describe("Optional initial labels, at most 20 labels of up to 40 characters.").optional(),
14034
14034
  priority: z2.number({ int: true, min: 0, max: 3 }).describe("Optional coarse priority: P0 is highest and P3 is lowest.").optional(),
14035
14035
  assignee: z2.string().describe("Email of the person who answers this issue's asks (someone who has signed in to Dispatch); defaults to your owner when you act for a person, else the parent's assignee, else unassigned.").optional(),
14036
- components: componentsArgument(z2).optional()
14036
+ components: componentsArgument(z2).optional(),
14037
+ repo: z2.string().describe("The repository the issue targets, owner/name, mapped to the issue's project in Settings; a child inherits its parent's when unset, and an issue with none on its parent chain takes the project's default.").optional()
14037
14038
  })
14038
14039
  },
14039
14040
  {
@@ -14043,7 +14044,7 @@ var dispatchToolSpecs = [
14043
14044
  status: "done",
14044
14045
  reason: "Shipped in owner/repo#7; verified on the production dashboard."
14045
14046
  },
14046
- description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, replace the issues it is blocked by, or attach it to " + "architecture components. Status is one of " + `${ISSUE_STATUSES.join(", ")}; outside Legion, move it yourself as the work advances; inside ` + "Legion the daemon moves it. A non-empty external_links is " + "merged into the issue's existing links by URL, so linking the pull request you just opened " + "keeps every earlier link; an empty external_links removes every link instead. components " + "replaces the issue's own attachment. Closing an issue " + "(status done) requires reason, the note that says why: it is posted on the issue as a " + "message, then the issue closes, because a closed issue refuses messages, comments, and " + "artifacts; reason goes only with status done. A closed issue takes only rank, components, " + "a reopening status (any status but done), and an external_links write that adds no new " + "link (so a link can move to the issue that replaced it); everything else, priority " + "included, waits for the reopen. " + "priority is yours to set and a human overrides it; rank, the board's own order, is not " + "settable here. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
14047
+ description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, replace the issues it is blocked by, attach it to " + "architecture components, or set the repository it targets. Status is one of " + `${ISSUE_STATUSES.join(", ")}; outside Legion, move it yourself as the work advances; inside ` + "Legion the daemon moves it. A non-empty external_links is " + "merged into the issue's existing links by URL, so linking the pull request you just opened " + "keeps every earlier link; an empty external_links removes every link instead. components " + "replaces the issue's own attachment. Closing an issue " + "(status done) requires reason, the note that says why: it is posted on the issue as a " + "message, then the issue closes, because a closed issue refuses messages, comments, and " + "artifacts; reason goes only with status done. A closed issue takes only rank, components, " + "a reopening status (any status but done), and an external_links write that adds no new " + "link (so a link can move to the issue that replaced it); everything else, priority " + "included, waits for the reopen. " + "priority is yours to set and a human overrides it; rank, the board's own order, is not " + "settable here. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
14047
14048
  arguments: (z2) => ({
14048
14049
  issue: z2.string().describe(ISSUE_REFERENCE),
14049
14050
  status: z2.enum(ISSUE_STATUSES).describe("New lifecycle status.").optional(),
@@ -14055,15 +14056,16 @@ var dispatchToolSpecs = [
14055
14056
  route: z2.string().describe("Route the issue to role:<name> or session:<id>; an empty string clears it.").optional(),
14056
14057
  parent: z2.string().describe("Parent issue key in the same project; an empty string clears the parent.").optional(),
14057
14058
  blocked_by: z2.array(z2.string({ min: 1 }), { max: MAX_ISSUE_BLOCKERS }).describe("Replacement list of issue keys in the same project this issue waits on; [] clears it. Dispatch refuses a dependency cycle.").optional(),
14058
- components: componentsArgument(z2).optional()
14059
+ components: componentsArgument(z2).optional(),
14060
+ repo: z2.string().describe("The repository the issue targets, owner/name, mapped to the issue's project in Settings; an empty string clears it, so the issue inherits its parent's or the project's default again.").optional()
14059
14061
  }),
14060
14062
  validation: {
14061
14063
  check: (value) => {
14062
14064
  const input = value;
14063
14065
  const reasonFits = input.status === "done" ? typeof input.reason === "string" && input.reason.trim() !== "" : input.reason === undefined;
14064
- return reasonFits && (typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || typeof input.priority === "number" || input.priority === null || Array.isArray(input.external_links) || typeof input.route === "string" || typeof input.parent === "string" || Array.isArray(input.blocked_by) || typeof input.components === "object" && input.components !== null);
14066
+ return reasonFits && (typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || typeof input.priority === "number" || input.priority === null || Array.isArray(input.external_links) || typeof input.route === "string" || typeof input.parent === "string" || Array.isArray(input.blocked_by) || typeof input.components === "object" && input.components !== null || typeof input.repo === "string");
14065
14067
  },
14066
- message: "Issue update requires at least one field besides issue: status, title, labels, priority, external_links, route, parent, blocked_by, or components. " + "status done requires reason, a non-empty note saying why the issue is closing, posted on the issue before it closes because a closed issue refuses messages, comments, and artifacts; reason goes only with status done."
14068
+ message: "Issue update requires at least one field besides issue: status, title, labels, priority, external_links, route, parent, blocked_by, components, or repo. " + "status done requires reason, a non-empty note saying why the issue is closing, posted on the issue before it closes because a closed issue refuses messages, comments, and artifacts; reason goes only with status done."
14067
14069
  },
14068
14070
  strict: true
14069
14071
  },
@@ -16577,14 +16579,14 @@ function toolSchema(tool) {
16577
16579
  toolSchemas.set(tool, schema);
16578
16580
  return schema;
16579
16581
  }
16580
- async function resolveOwnerArguments(tool, input, cwd, env, exec, serverUrl, problems) {
16582
+ async function resolveOwnerArguments(tool, input, cwd, env, exec, dashboardUrls, problems) {
16581
16583
  if (issueFreeTools[tool] === true)
16582
16584
  return { args: input, ref: null, owner: null };
16583
16585
  const refArgument = input.ref;
16584
16586
  let ref = null;
16585
16587
  let args = input;
16586
16588
  if (typeof refArgument === "string") {
16587
- const refText = dispatchRefFromUrl(refArgument, serverUrl) ?? refArgument;
16589
+ const refText = dashboardUrls.map((dashboard) => dispatchRefFromUrl(refArgument, dashboard)).find((ref2) => ref2 !== undefined) ?? refArgument;
16588
16590
  if (refText !== refArgument)
16589
16591
  args = { ...input, ref: refText };
16590
16592
  const agentArtifact = parseAgentArtifactRef(refText);
@@ -16846,7 +16848,32 @@ function progressText(progress, sep) {
16846
16848
  }
16847
16849
  return parts.join(sep);
16848
16850
  }
16849
- function issueSummary(issue2, events, references, graph, titles) {
16851
+ function repositoryLine(issue2) {
16852
+ if (issue2.resolved_repo === undefined) {
16853
+ return "Repository: unknown (this Dispatch server predates repository fields; upgrade it)";
16854
+ }
16855
+ if (issue2.resolved_repo === null)
16856
+ return "Repository: none";
16857
+ if (issue2.repo !== null && issue2.repo !== undefined)
16858
+ return `Repository: ${issue2.repo} (own)`;
16859
+ return `Repository: ${issue2.resolved_repo} (inherited or project default)`;
16860
+ }
16861
+ function repositoryFieldsWarnings(issue2) {
16862
+ if (issue2.resolved_repo !== undefined)
16863
+ return {};
16864
+ return {
16865
+ warnings: [
16866
+ `dispatch: warning: Dispatch issue is missing resolved_repo: this Dispatch server predates repository fields, so ${issue2.key}'s repository is unknown; upgrade the server`
16867
+ ]
16868
+ };
16869
+ }
16870
+ function refuseUnsavedRepository(verb, issue2, repo2) {
16871
+ if (repo2 === undefined || issue2.repo !== undefined)
16872
+ return;
16873
+ const lost = repo2 === "" ? "the repository was not cleared" : `${repo2} was not saved`;
16874
+ throw new Error(`Dispatch ${verb} ${issue2.key} without its repository: this Dispatch server predates repository fields, so ${lost}; upgrade the server`);
16875
+ }
16876
+ function issueSummary(issue2, events, references, graph, linkBase, titles) {
16850
16877
  const asks = issue2.open_asks;
16851
16878
  const spec = issue2.artifacts?.find((artifact) => artifact.primary);
16852
16879
  const specApproval = spec === undefined ? undefined : approvalLine(spec);
@@ -16864,6 +16891,7 @@ function issueSummary(issue2, events, references, graph, titles) {
16864
16891
  return [
16865
16892
  `Title: ${issue2.title}`,
16866
16893
  `Key: ${issue2.key}`,
16894
+ `Link: ${new URL(`/issues/${encodeURIComponent(issue2.key)}`, linkBase).toString()}`,
16867
16895
  `Status: ${issue2.status}`,
16868
16896
  `Assignee: ${issue2.assignee ?? "unassigned"}`,
16869
16897
  `Claimed by: ${issue2.claim === null ? "nobody" : claimText(issue2.claim, titles)}`,
@@ -16871,6 +16899,7 @@ function issueSummary(issue2, events, references, graph, titles) {
16871
16899
  `Labels: ${issue2.labels.length === 0 ? "none" : issue2.labels.join(", ")}`,
16872
16900
  componentsLine(issue2.components),
16873
16901
  `Route: ${routeText(issue2, titles)}`,
16902
+ repositoryLine(issue2),
16874
16903
  `Progress: ${progressText(issue2.progress, ", ") || "none"}`,
16875
16904
  ...specApproval === undefined ? [] : [`Spec ${specApproval.replace(/^Approval/, "approval")}`],
16876
16905
  "External links:",
@@ -17328,6 +17357,7 @@ function withAccount(error48, account) {
17328
17357
  }
17329
17358
  async function executeDispatchTool(input) {
17330
17359
  const { url: configUrl, token: configToken } = input.config;
17360
+ const linkBase = input.config.publicUrl ?? configUrl;
17331
17361
  const baseFetch = input.fetchImpl ?? fetch;
17332
17362
  const fetchImpl = Object.assign(async (...args2) => {
17333
17363
  try {
@@ -17342,7 +17372,7 @@ async function executeDispatchTool(input) {
17342
17372
  const env = input.env ?? process.env;
17343
17373
  const exec = input.exec ?? defaultExec;
17344
17374
  const problems = [];
17345
- const ownerArguments = await resolveOwnerArguments(input.tool, input.args, input.cwd, env, exec, configUrl, problems);
17375
+ const ownerArguments = await resolveOwnerArguments(input.tool, input.args, input.cwd, env, exec, input.config.publicUrl === undefined ? [configUrl] : [configUrl, input.config.publicUrl], problems);
17346
17376
  const ownerMissing = issueFreeTools[input.tool] !== true && ownerArguments.owner === null;
17347
17377
  const schema = toolSchema(input.tool);
17348
17378
  const parsed = schema.safeParse(ownerArguments.args, { reportInput: true });
@@ -17360,13 +17390,13 @@ async function executeDispatchTool(input) {
17360
17390
  const project = optionalString(ownerArguments.args, "project");
17361
17391
  if (project !== undefined) {
17362
17392
  const response2 = await client2.openAsksForProject(project);
17363
- return { text: formatOpenAsksSummary(response2, configUrl), details: { ...response2 } };
17393
+ return { text: formatOpenAsksSummary(response2, linkBase), details: { ...response2 } };
17364
17394
  }
17365
17395
  const sessionId = input.sessionId?.trim();
17366
17396
  if (!sessionId)
17367
17397
  throw new Error("host session id is required for dispatch open-asks");
17368
17398
  const response = await client2.openAsks(sessionId);
17369
- return { text: formatOpenAsksSummary(response, configUrl), details: { ...response } };
17399
+ return { text: formatOpenAsksSummary(response, linkBase), details: { ...response } };
17370
17400
  }
17371
17401
  if (input.tool === "dispatch_whoami") {
17372
17402
  const sessionId = input.sessionId?.trim();
@@ -17425,6 +17455,7 @@ async function executeDispatchTool(input) {
17425
17455
  const priority = optionalPriority(args, "priority");
17426
17456
  const assignee = optionalString(args, "assignee");
17427
17457
  const components = optionalComponents(args, "components");
17458
+ const repo2 = optionalString(args, "repo");
17428
17459
  const labels = args.labels;
17429
17460
  const blockedBy = args.blocked_by;
17430
17461
  try {
@@ -17439,20 +17470,24 @@ async function executeDispatchTool(input) {
17439
17470
  ...priority === undefined ? {} : { priority },
17440
17471
  ...assignee === undefined ? {} : { assignee },
17441
17472
  ...components === undefined ? {} : { components },
17473
+ ...repo2 === undefined ? {} : { repo: repo2 },
17442
17474
  ...Array.isArray(labels) ? { labels } : {},
17443
17475
  actor
17444
17476
  });
17477
+ refuseUnsavedRepository("created", created, repo2);
17445
17478
  const componentGuidance = await architectureGuidance(client, created.project, created.components);
17446
17479
  const adviceLines = [
17447
17480
  ...renderAdvice(input.tool, created.key, created.advice, {
17448
17481
  isPrimarySpec: spec !== undefined
17449
17482
  }),
17450
17483
  ...componentGuidance === undefined ? [] : [componentGuidance],
17451
- ...renderSuggestions(created.advice?.suggestions, configUrl)
17484
+ ...renderSuggestions(created.advice?.suggestions, linkBase)
17452
17485
  ];
17486
+ const href = new URL(`/issues/${encodeURIComponent(created.key)}`, linkBase).toString();
17487
+ const assigned = created.assignee === undefined ? "" : created.assignee === null ? "; unassigned" : `; assigned to ${created.assignee}`;
17453
17488
  return {
17454
17489
  text: [
17455
- `Created ${created.key}: ${created.title} ${notSubscribed(issueTopic(created.key))}`,
17490
+ `Created ${created.key}: ${created.title} \u2192 ${href}${assigned} ${notSubscribed(issueTopic(created.key))}`,
17456
17491
  ...adviceLines
17457
17492
  ].join(`
17458
17493
  `),
@@ -17470,7 +17505,7 @@ async function executeDispatchTool(input) {
17470
17505
  text: [
17471
17506
  `Not created: "${title}" looks like a duplicate.`,
17472
17507
  ...candidates.map((candidate) => {
17473
- const href = new URL(candidate.href, configUrl).toString();
17508
+ const href = new URL(candidate.href, linkBase).toString();
17474
17509
  return `${candidate.key} [${candidate.status}] ${candidate.title} \u2192 ${href}`;
17475
17510
  }),
17476
17511
  "Reference the existing issue, or run dispatch issue again with --force after reading it."
@@ -17488,6 +17523,7 @@ async function executeDispatchTool(input) {
17488
17523
  const route = optionalString(args, "route");
17489
17524
  const parent = optionalString(args, "parent");
17490
17525
  const components = optionalComponents(args, "components");
17526
+ const repo2 = optionalString(args, "repo");
17491
17527
  const priority = optionalPriority(args, "priority");
17492
17528
  const labels = Array.isArray(args.labels) ? args.labels : undefined;
17493
17529
  const blockedBy = Array.isArray(args.blocked_by) ? args.blocked_by : undefined;
@@ -17527,6 +17563,7 @@ async function executeDispatchTool(input) {
17527
17563
  ...parent === undefined ? {} : { parent: parent === "" ? null : parent },
17528
17564
  ...blockedBy === undefined ? {} : { blocked_by: blockedBy },
17529
17565
  ...components === undefined ? {} : { components },
17566
+ ...repo2 === undefined ? {} : { repo: repo2 === "" ? null : repo2 },
17530
17567
  ...sentLinks === undefined ? {} : { external_links: sentLinks },
17531
17568
  actor
17532
17569
  });
@@ -17541,6 +17578,7 @@ async function executeDispatchTool(input) {
17541
17578
  throw refusalWithCode(error48, taken, landed);
17542
17579
  throw withAccount(error48, landed);
17543
17580
  }
17581
+ refuseUnsavedRepository("updated", after, repo2);
17544
17582
  const linkCount = `(${after.external_links.length} ${after.external_links.length === 1 ? "link" : "links"})`;
17545
17583
  const changes = [
17546
17584
  ...closingNote === undefined ? [] : [`reason posted as message ${closingNote.id} (${closingNote.ref})`],
@@ -17556,7 +17594,10 @@ async function executeDispatchTool(input) {
17556
17594
  ...blockedBy === undefined ? [] : [
17557
17595
  after.blocked_by.length === 0 ? "blocked_by cleared" : `blocked by ${after.blocked_by.join(", ")}`
17558
17596
  ],
17559
- ...components === undefined ? [] : [componentsChange(components, after.components)]
17597
+ ...components === undefined ? [] : [componentsChange(components, after.components)],
17598
+ ...repo2 === undefined ? [] : [
17599
+ after.repo === null ? `repo cleared (resolves to ${after.resolved_repo ?? "none"})` : `repo -> ${after.repo}`
17600
+ ]
17560
17601
  ];
17561
17602
  const adviceLines = renderAdvice(input.tool, after.key, after.advice, {
17562
17603
  setsStatus: status !== undefined
@@ -17611,7 +17652,7 @@ async function executeDispatchTool(input) {
17611
17652
  ...limit === undefined ? {} : { limit },
17612
17653
  ...offset === undefined ? {} : { offset }
17613
17654
  });
17614
- return searchAnswer(search, query, offset, configUrl);
17655
+ return searchAnswer(search, query, offset, linkBase);
17615
17656
  }
17616
17657
  case "dispatch_issues": {
17617
17658
  const project = stringArg(args, "project");
@@ -17675,7 +17716,7 @@ async function executeDispatchTool(input) {
17675
17716
  } catch (error48) {
17676
17717
  throw refusalWithCode(error48);
17677
17718
  }
17678
- const href = new URL(`/projects/${encodeURIComponent(project.key)}`, configUrl).toString();
17719
+ const href = new URL(`/projects/${encodeURIComponent(project.key)}`, linkBase).toString();
17679
17720
  return {
17680
17721
  text: `Created project ${project.key} (${project.name}) at ${project.created_at}: ${href}`,
17681
17722
  details: { project: project.key, name: project.name, created_at: project.created_at }
@@ -17768,7 +17809,7 @@ async function executeDispatchTool(input) {
17768
17809
  const askOwner = ask.issue_key !== null ? issueTopic(ask.issue_key) : resolved === undefined ? issueTopic(issue2()) : documentTopic(resolved.artifact);
17769
17810
  const adviceLines = [
17770
17811
  ...renderAdvice(input.tool, askOwner.label, ask.advice, {}),
17771
- ...renderSuggestions(ask.advice?.suggestions, configUrl)
17812
+ ...renderSuggestions(ask.advice?.suggestions, linkBase)
17772
17813
  ];
17773
17814
  return {
17774
17815
  text: [
@@ -18249,9 +18290,10 @@ ${trailer.join(`
18249
18290
  ])
18250
18291
  ]);
18251
18292
  return {
18252
- text: issueSummary(read.issue, read.events, references, [...pictures2.lines, ...graph], titles),
18293
+ text: issueSummary(read.issue, read.events, references, [...pictures2.lines, ...graph], linkBase, titles),
18253
18294
  details: { issue: read.issue.key },
18254
- ...showImages(pictures2)
18295
+ ...showImages(pictures2),
18296
+ ...repositoryFieldsWarnings(read.issue)
18255
18297
  };
18256
18298
  }
18257
18299
  default:
@@ -18987,7 +19029,17 @@ function dispatchSettings(env, config2) {
18987
19029
  }
18988
19030
  const url2 = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
18989
19031
  const token = source.kind === "variable" ? source.token : fileBearer(source, url2, "DISPATCH", env);
18990
- return { enabled: true, url: url2, token };
19032
+ const publicUrl = nonEmpty(env.DISPATCH_PUBLIC_URL);
19033
+ const publicParsed = publicUrl === undefined ? undefined : httpUrl2(publicUrl);
19034
+ if (publicUrl !== undefined && (publicParsed === undefined || publicParsed.port === "0")) {
19035
+ return offWithError("DISPATCH_PUBLIC_URL must be an http or https URL");
19036
+ }
19037
+ return {
19038
+ enabled: true,
19039
+ url: url2,
19040
+ token,
19041
+ ...publicUrl === undefined ? {} : { publicUrl: publicUrl.replace(/\/+$/, "") }
19042
+ };
18991
19043
  } catch (error48) {
18992
19044
  return offWithError(messageFor(error48));
18993
19045
  }
@@ -19156,6 +19208,10 @@ async function runDispatchCli(argv, rawEnv, io) {
19156
19208
  ...io.fetchImpl === undefined ? {} : { fetchImpl: io.fetchImpl }
19157
19209
  });
19158
19210
  text = result.text;
19211
+ for (const warning of result.warnings ?? []) {
19212
+ io.stderr(`${warning}
19213
+ `);
19214
+ }
19159
19215
  try {
19160
19216
  for (const image of result.images ?? []) {
19161
19217
  const picture = writePicture(dir, image);
@@ -19197,6 +19253,7 @@ async function runDispatchCli(argv, rawEnv, io) {
19197
19253
  var readText = (path2) => readFileSync4(path2 === "-" ? 0 : path2, "utf-8");
19198
19254
  process.exit(await runDispatchCli(Bun.argv.slice(2), process.env, {
19199
19255
  stdout: (text) => process.stdout.write(text),
19256
+ stderr: (text) => process.stderr.write(text),
19200
19257
  readText,
19201
19258
  cwd: process.cwd()
19202
19259
  }));
package/dist/envoy.js CHANGED
@@ -30020,7 +30020,8 @@ var dispatchToolSpecs = [
30020
30020
  labels: z2.array(z2.string({ min: 1, max: 40 }), { max: 20 }).describe("Optional initial labels, at most 20 labels of up to 40 characters.").optional(),
30021
30021
  priority: z2.number({ int: true, min: 0, max: 3 }).describe("Optional coarse priority: P0 is highest and P3 is lowest.").optional(),
30022
30022
  assignee: z2.string().describe("Email of the person who answers this issue's asks (someone who has signed in to Dispatch); defaults to your owner when you act for a person, else the parent's assignee, else unassigned.").optional(),
30023
- components: componentsArgument(z2).optional()
30023
+ components: componentsArgument(z2).optional(),
30024
+ repo: z2.string().describe("The repository the issue targets, owner/name, mapped to the issue's project in Settings; a child inherits its parent's when unset, and an issue with none on its parent chain takes the project's default.").optional()
30024
30025
  })
30025
30026
  },
30026
30027
  {
@@ -30030,7 +30031,7 @@ var dispatchToolSpecs = [
30030
30031
  status: "done",
30031
30032
  reason: "Shipped in owner/repo#7; verified on the production dashboard."
30032
30033
  },
30033
- description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, replace the issues it is blocked by, or attach it to " + "architecture components. Status is one of " + `${ISSUE_STATUSES.join(", ")}; outside Legion, move it yourself as the work advances; inside ` + "Legion the daemon moves it. A non-empty external_links is " + "merged into the issue's existing links by URL, so linking the pull request you just opened " + "keeps every earlier link; an empty external_links removes every link instead. components " + "replaces the issue's own attachment. Closing an issue " + "(status done) requires reason, the note that says why: it is posted on the issue as a " + "message, then the issue closes, because a closed issue refuses messages, comments, and " + "artifacts; reason goes only with status done. A closed issue takes only rank, components, " + "a reopening status (any status but done), and an external_links write that adds no new " + "link (so a link can move to the issue that replaced it); everything else, priority " + "included, waits for the reopen. " + "priority is yours to set and a human overrides it; rank, the board's own order, is not " + "settable here. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
30034
+ description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, replace the issues it is blocked by, attach it to " + "architecture components, or set the repository it targets. Status is one of " + `${ISSUE_STATUSES.join(", ")}; outside Legion, move it yourself as the work advances; inside ` + "Legion the daemon moves it. A non-empty external_links is " + "merged into the issue's existing links by URL, so linking the pull request you just opened " + "keeps every earlier link; an empty external_links removes every link instead. components " + "replaces the issue's own attachment. Closing an issue " + "(status done) requires reason, the note that says why: it is posted on the issue as a " + "message, then the issue closes, because a closed issue refuses messages, comments, and " + "artifacts; reason goes only with status done. A closed issue takes only rank, components, " + "a reopening status (any status but done), and an external_links write that adds no new " + "link (so a link can move to the issue that replaced it); everything else, priority " + "included, waits for the reopen. " + "priority is yours to set and a human overrides it; rank, the board's own order, is not " + "settable here. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
30034
30035
  arguments: (z2) => ({
30035
30036
  issue: z2.string().describe(ISSUE_REFERENCE),
30036
30037
  status: z2.enum(ISSUE_STATUSES).describe("New lifecycle status.").optional(),
@@ -30042,15 +30043,16 @@ var dispatchToolSpecs = [
30042
30043
  route: z2.string().describe("Route the issue to role:<name> or session:<id>; an empty string clears it.").optional(),
30043
30044
  parent: z2.string().describe("Parent issue key in the same project; an empty string clears the parent.").optional(),
30044
30045
  blocked_by: z2.array(z2.string({ min: 1 }), { max: MAX_ISSUE_BLOCKERS }).describe("Replacement list of issue keys in the same project this issue waits on; [] clears it. Dispatch refuses a dependency cycle.").optional(),
30045
- components: componentsArgument(z2).optional()
30046
+ components: componentsArgument(z2).optional(),
30047
+ repo: z2.string().describe("The repository the issue targets, owner/name, mapped to the issue's project in Settings; an empty string clears it, so the issue inherits its parent's or the project's default again.").optional()
30046
30048
  }),
30047
30049
  validation: {
30048
30050
  check: (value) => {
30049
30051
  const input = value;
30050
30052
  const reasonFits = input.status === "done" ? typeof input.reason === "string" && input.reason.trim() !== "" : input.reason === undefined;
30051
- return reasonFits && (typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || typeof input.priority === "number" || input.priority === null || Array.isArray(input.external_links) || typeof input.route === "string" || typeof input.parent === "string" || Array.isArray(input.blocked_by) || typeof input.components === "object" && input.components !== null);
30053
+ return reasonFits && (typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || typeof input.priority === "number" || input.priority === null || Array.isArray(input.external_links) || typeof input.route === "string" || typeof input.parent === "string" || Array.isArray(input.blocked_by) || typeof input.components === "object" && input.components !== null || typeof input.repo === "string");
30052
30054
  },
30053
- message: "Issue update requires at least one field besides issue: status, title, labels, priority, external_links, route, parent, blocked_by, or components. " + "status done requires reason, a non-empty note saying why the issue is closing, posted on the issue before it closes because a closed issue refuses messages, comments, and artifacts; reason goes only with status done."
30055
+ message: "Issue update requires at least one field besides issue: status, title, labels, priority, external_links, route, parent, blocked_by, components, or repo. " + "status done requires reason, a non-empty note saying why the issue is closing, posted on the issue before it closes because a closed issue refuses messages, comments, and artifacts; reason goes only with status done."
30054
30056
  },
30055
30057
  strict: true
30056
30058
  },
@@ -30463,6 +30465,12 @@ var DISPATCH_DOCUMENT_TOPIC_PREFIX = `${DISPATCH_TOPIC_PREFIX}document.`;
30463
30465
  function agentSubject(session) {
30464
30466
  return `${AGENT_TOPIC_PREFIX}${session}`;
30465
30467
  }
30468
+ // ../contracts/src/slack.ts
30469
+ var SLACK_POST_TEXT_MAX = 4000;
30470
+ function slackTopicChannel(topic) {
30471
+ const [scope, source, , channel] = topic.split(".");
30472
+ return scope === "notifications" && source === "slack" ? channel ?? "" : undefined;
30473
+ }
30466
30474
  // ../contracts/src/tool-schema.ts
30467
30475
  function overCapMessage(length, max) {
30468
30476
  return `is ${length - max} characters over the ${max}-character limit (${length}/${max})`;
@@ -32577,7 +32585,17 @@ function dispatchSettings(env, config2) {
32577
32585
  }
32578
32586
  const url2 = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
32579
32587
  const token = source.kind === "variable" ? source.token : fileBearer(source, url2, "DISPATCH", env);
32580
- return { enabled: true, url: url2, token };
32588
+ const publicUrl = nonEmpty(env.DISPATCH_PUBLIC_URL);
32589
+ const publicParsed = publicUrl === undefined ? undefined : httpUrl2(publicUrl);
32590
+ if (publicUrl !== undefined && (publicParsed === undefined || publicParsed.port === "0")) {
32591
+ return offWithError("DISPATCH_PUBLIC_URL must be an http or https URL");
32592
+ }
32593
+ return {
32594
+ enabled: true,
32595
+ url: url2,
32596
+ token,
32597
+ ...publicUrl === undefined ? {} : { publicUrl: publicUrl.replace(/\/+$/, "") }
32598
+ };
32581
32599
  } catch (error48) {
32582
32600
  return offWithError(messageFor(error48));
32583
32601
  }
@@ -33698,6 +33716,293 @@ function sessionDispatchSettings(file2, env, options) {
33698
33716
  };
33699
33717
  }
33700
33718
 
33719
+ // ../envoy-client/src/slack-post.ts
33720
+ import { BlockList, isIP } from "net";
33721
+ var SLACK_BOT_TOKEN_SECRET = "LEGION_SLACK_BOT_TOKEN";
33722
+ var SLACK_POST_API_URL_VARIABLE = "SLACK_POST_API_URL";
33723
+ var POST_TIMEOUT_MS = 30000;
33724
+ var SLACK_ERROR_CODES = [
33725
+ "access_denied",
33726
+ "accesslimited",
33727
+ "account_inactive",
33728
+ "agent_prompt_display_not_allowed",
33729
+ "app_access_restricted",
33730
+ "as_user_not_supported",
33731
+ "attachment_payload_limit_exceeded",
33732
+ "cannot_reply_to_message",
33733
+ "channel_not_found",
33734
+ "deprecated_endpoint",
33735
+ "draft_already_deleted",
33736
+ "draft_already_sent",
33737
+ "draft_has_conflict",
33738
+ "draft_not_found",
33739
+ "duplicate_channel_not_found",
33740
+ "duplicate_message_not_found",
33741
+ "ekm_access_denied",
33742
+ "enterprise_is_restricted",
33743
+ "fatal_error",
33744
+ "internal_error",
33745
+ "invalid_arg_name",
33746
+ "invalid_arguments",
33747
+ "invalid_array_arg",
33748
+ "invalid_auth",
33749
+ "invalid_blocks",
33750
+ "invalid_blocks_format",
33751
+ "invalid_charset",
33752
+ "invalid_form_data",
33753
+ "invalid_metadata_format",
33754
+ "invalid_metadata_schema",
33755
+ "invalid_post_type",
33756
+ "is_archived",
33757
+ "markdown_text_conflict",
33758
+ "message_limit_exceeded",
33759
+ "messages_tab_disabled",
33760
+ "metadata_must_be_sent_from_app",
33761
+ "metadata_too_large",
33762
+ "method_deprecated",
33763
+ "missing_file_data",
33764
+ "missing_post_type",
33765
+ "missing_scope",
33766
+ "msg_blocks_too_long",
33767
+ "no_permission",
33768
+ "no_text",
33769
+ "not_allowed_token_type",
33770
+ "not_authed",
33771
+ "not_in_channel",
33772
+ "org_login_required",
33773
+ "rate_limited",
33774
+ "ratelimited",
33775
+ "request_timeout",
33776
+ "restricted_action",
33777
+ "restricted_action_non_threadable_channel",
33778
+ "restricted_action_read_only_channel",
33779
+ "restricted_action_thread_locked",
33780
+ "restricted_action_thread_only_channel",
33781
+ "send_on_behalf_not_allowed",
33782
+ "service_unavailable",
33783
+ "slack_connect_canvas_sharing_blocked",
33784
+ "slack_connect_file_link_sharing_blocked",
33785
+ "slack_connect_lists_sharing_blocked",
33786
+ "team_access_not_granted",
33787
+ "team_added_to_org",
33788
+ "team_not_found",
33789
+ "token_expired",
33790
+ "token_revoked",
33791
+ "too_many_attachments",
33792
+ "too_many_contact_cards",
33793
+ "two_factor_setup_required"
33794
+ ];
33795
+ var POSTER = `
33796
+ const codes = ${JSON.stringify(Object.fromEntries(SLACK_ERROR_CODES.map((code) => [code, true])))};
33797
+ const token = process.env.LEGION_SLACK_BOT_TOKEN || "";
33798
+ const out = (value) => {
33799
+ let line = JSON.stringify(value);
33800
+ if (token !== "") line = line.split(token).join("[redacted]");
33801
+ process.stdout.write(line + "\\n");
33802
+ };
33803
+ let sent = false;
33804
+ try {
33805
+ const input = JSON.parse(await Bun.stdin.text());
33806
+ if (token === "") {
33807
+ out({ invalid: "the poster's environment carries no LEGION_SLACK_BOT_TOKEN" });
33808
+ process.exit(0);
33809
+ }
33810
+ const base = (process.env.SLACK_POST_API_URL || "https://slack.com/api").replace(/\\/+$/, "");
33811
+ let status = 0;
33812
+ let raw = "";
33813
+ try {
33814
+ sent = true;
33815
+ const response = await fetch(base + "/chat.postMessage", {
33816
+ method: "POST",
33817
+ headers: { Authorization: "Bearer " + token, "Content-Type": "application/json; charset=utf-8" },
33818
+ body: JSON.stringify({ channel: input.channel, thread_ts: input.thread_ts, text: input.text }),
33819
+ signal: AbortSignal.timeout(10000),
33820
+ });
33821
+ status = response.status;
33822
+ raw = await response.text();
33823
+ } catch {
33824
+ out({ unanswered: true });
33825
+ process.exit(0);
33826
+ }
33827
+ let answer;
33828
+ try {
33829
+ answer = JSON.parse(raw);
33830
+ } catch {}
33831
+ if (!answer || typeof answer !== "object" || typeof answer.ok !== "boolean") {
33832
+ out({ invalid: "HTTP " + Number(status) + " with a body that is not Slack's JSON" });
33833
+ } else if (!answer.ok) {
33834
+ out({ refused: typeof answer.error === "string" && Object.hasOwn(codes, answer.error) ? answer.error : null });
33835
+ } else if (
33836
+ typeof answer.ts !== "string" || !/^[0-9]{1,12}\\.[0-9]{1,8}$/.test(answer.ts) ||
33837
+ (answer.channel !== undefined && (typeof answer.channel !== "string" || !/^[CDG][A-Z0-9]{1,30}$/.test(answer.channel)))
33838
+ ) {
33839
+ out({ invalid: "ok with a ts or channel that is not Slack's" });
33840
+ } else {
33841
+ out({ ts: answer.ts, channel: answer.channel === undefined ? input.channel : answer.channel });
33842
+ }
33843
+ } catch {
33844
+ out(sent ? { unanswered: true } : { invalid: "a reply the poster could not read" });
33845
+ }
33846
+ `;
33847
+ var MAY_HAVE_POSTED = "the reply may have been posted, and posting it again may put it in the thread twice";
33848
+ var URL_CANDIDATE = /(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,31}:\/\/[^\s<>|"'`]+/gi;
33849
+ var WEB_SCHEME = /^https?:\/\//i;
33850
+ var BARE_HOST = /(?<![\w@./:%-])((?:[a-z0-9-]{1,63}\.)*[a-z0-9-]{1,63})\.?(:\d{1,5})?(?![\w-])/gi;
33851
+ var BARE_IPV6 = /(?<![\w@./:%-])\[([0-9a-f:.]{2,45})(?:%[\w.-]{1,32})?\](?::\d{1,5})?/gi;
33852
+ var TRAILING_PUNCTUATION = /[.,;:!?)\]}]+$/;
33853
+ var CLUSTER_ADDRESSES = new BlockList;
33854
+ for (const [network, prefix] of [
33855
+ ["0.0.0.0", 8],
33856
+ ["10.0.0.0", 8],
33857
+ ["100.64.0.0", 10],
33858
+ ["127.0.0.0", 8],
33859
+ ["169.254.0.0", 16],
33860
+ ["172.16.0.0", 12],
33861
+ ["192.168.0.0", 16]
33862
+ ]) {
33863
+ CLUSTER_ADDRESSES.addSubnet(network, prefix, "ipv4");
33864
+ }
33865
+ for (const [network, prefix] of [
33866
+ ["::", 96],
33867
+ ["fc00::", 7],
33868
+ ["fe80::", 10]
33869
+ ]) {
33870
+ CLUSTER_ADDRESSES.addSubnet(network, prefix, "ipv6");
33871
+ }
33872
+ function linkInsideCluster(text, env) {
33873
+ const { DISPATCH_URL: apiUrl, DISPATCH_PUBLIC_URL: publicUrl } = env;
33874
+ const apiHost = hostOf(apiUrl);
33875
+ const publicHost = hostOf(publicUrl);
33876
+ const dispatchHost = apiHost !== undefined && publicHost !== undefined && apiHost !== publicHost ? apiHost : undefined;
33877
+ const decoded = text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
33878
+ for (const match of decoded.matchAll(URL_CANDIDATE)) {
33879
+ if (!WEB_SCHEME.test(match[0]))
33880
+ continue;
33881
+ for (const written of new Set([match[0], match[0].replace(TRAILING_PUNCTUATION, "")])) {
33882
+ const host = hostOf(written);
33883
+ if (host !== undefined && insideCluster(host, dispatchHost, true))
33884
+ return written;
33885
+ }
33886
+ }
33887
+ const prose = decoded.replace(URL_CANDIDATE, (url2) => " ".repeat(url2.length));
33888
+ for (const match of prose.matchAll(BARE_IPV6)) {
33889
+ if (insideCluster(`[${match[1]}]`, dispatchHost, false))
33890
+ return match[0];
33891
+ }
33892
+ for (const match of prose.matchAll(BARE_HOST)) {
33893
+ const host = (match[1] ?? "").toLowerCase();
33894
+ if (!host.includes(".") && match[2] === undefined)
33895
+ continue;
33896
+ if (insideCluster(host, dispatchHost, false))
33897
+ return match[0];
33898
+ }
33899
+ return;
33900
+ }
33901
+ function hostOf(value) {
33902
+ if (value === undefined || value === "")
33903
+ return;
33904
+ try {
33905
+ return new URL(value).hostname.toLowerCase().replace(/\.$/, "") || undefined;
33906
+ } catch {
33907
+ return;
33908
+ }
33909
+ }
33910
+ function insideCluster(host, dispatchHost, fromUrl) {
33911
+ if (host === dispatchHost)
33912
+ return true;
33913
+ if (host.endsWith(".svc") || host.includes(".svc.") || host.endsWith(".cluster.local"))
33914
+ return true;
33915
+ const address = host.startsWith("[") ? host.slice(1, -1) : host;
33916
+ const family = isIP(address);
33917
+ if (family !== 0)
33918
+ return CLUSTER_ADDRESSES.check(address, family === 4 ? "ipv4" : "ipv6");
33919
+ return fromUrl && !host.includes(".");
33920
+ }
33921
+ async function postSlackReply(reply, options = {}) {
33922
+ const env = options.env ?? process.env;
33923
+ const internal = linkInsideCluster(reply.text, env);
33924
+ if (internal !== undefined) {
33925
+ 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`);
33926
+ }
33927
+ const override = env[SLACK_POST_API_URL_VARIABLE];
33928
+ if (override !== undefined && override !== "" && !allowedAPIURL(override)) {
33929
+ throw new Error(`${SLACK_POST_API_URL_VARIABLE} must be an https URL, or http on a loopback host (got ${override}); nothing was posted`);
33930
+ }
33931
+ const { PATH: searchPath = "" } = env;
33932
+ const client = Bun.which("agent-secrets", { PATH: searchPath });
33933
+ if (client === null) {
33934
+ 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");
33935
+ }
33936
+ const reason = `slack_post: reply in Slack channel ${reply.channel}, thread ${reply.threadTs}`;
33937
+ const child = Bun.spawn([client, SLACK_BOT_TOKEN_SECRET, "--reason", reason, "--wait", "0s", "--", "bun", "-e", POSTER], {
33938
+ env,
33939
+ stdin: new Blob([
33940
+ JSON.stringify({ channel: reply.channel, thread_ts: reply.threadTs, text: reply.text })
33941
+ ]),
33942
+ stdout: "pipe",
33943
+ stderr: "pipe"
33944
+ });
33945
+ const timer = setTimeout(() => child.kill("SIGKILL"), POST_TIMEOUT_MS);
33946
+ const [stdout, stderr, code] = await Promise.all([
33947
+ new Response(child.stdout).text(),
33948
+ new Response(child.stderr).text(),
33949
+ child.exited
33950
+ ]).finally(() => clearTimeout(timer));
33951
+ const said = stderr.trim().split(`
33952
+ `).filter(Boolean).join(" ");
33953
+ const line = posterLine(stdout);
33954
+ if (line === undefined) {
33955
+ if (child.signalCode === "SIGKILL") {
33956
+ throw new Error(`slack_post got no answer within ${POST_TIMEOUT_MS / 1000} s: ${MAY_HAVE_POSTED}`);
33957
+ }
33958
+ if (said.startsWith("agent-secrets:")) {
33959
+ if (code === 75) {
33960
+ throw new Error(`the secrets broker has not granted ${SLACK_BOT_TOKEN_SECRET}: its request is waiting for approval (${said}); nothing was posted`);
33961
+ }
33962
+ if (code === 77) {
33963
+ throw new Error(`the secrets broker denied ${SLACK_BOT_TOKEN_SECRET} (${said}); nothing was posted`);
33964
+ }
33965
+ throw new Error(`the secrets broker did not release ${SLACK_BOT_TOKEN_SECRET} (${said}); nothing was posted`);
33966
+ }
33967
+ throw new Error(`slack_post's poster exited ${code} without an answer: ${MAY_HAVE_POSTED}`);
33968
+ }
33969
+ if (line.refused === null) {
33970
+ throw new Error("Slack refused chat.postMessage with an error code slack_post does not pass on");
33971
+ }
33972
+ if (line.refused !== undefined)
33973
+ throw new Error(`Slack refused chat.postMessage: ${line.refused}`);
33974
+ if (line.unanswered !== undefined) {
33975
+ throw new Error(`Slack did not answer chat.postMessage: ${MAY_HAVE_POSTED}`);
33976
+ }
33977
+ if (line.ts === undefined) {
33978
+ throw new Error(`chat.postMessage answered ${line.invalid ?? "nothing"}: ${MAY_HAVE_POSTED}`);
33979
+ }
33980
+ return { channel: line.channel ?? reply.channel, threadTs: reply.threadTs, ts: line.ts };
33981
+ }
33982
+ function posterLine(stdout) {
33983
+ const last = stdout.trim().split(`
33984
+ `).at(-1);
33985
+ if (last === undefined || last === "")
33986
+ return;
33987
+ try {
33988
+ const parsed = JSON.parse(last);
33989
+ return typeof parsed === "object" && parsed !== null ? parsed : undefined;
33990
+ } catch {
33991
+ return;
33992
+ }
33993
+ }
33994
+ function allowedAPIURL(value) {
33995
+ let url2;
33996
+ try {
33997
+ url2 = new URL(value);
33998
+ } catch {
33999
+ return false;
34000
+ }
34001
+ if (url2.protocol === "https:")
34002
+ return true;
34003
+ return url2.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url2.hostname);
34004
+ }
34005
+
33701
34006
  // ../envoy-client/src/tool-contract.ts
33702
34007
  var DELIVERY_CONTRACT = "Delivery is at-least-once, possibly out of order across topics; use id for dedupe and at for freshness.";
33703
34008
  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 +34040,8 @@ var EnvoyToolOperation = {
33735
34040
  setRole: "setRole",
33736
34041
  getRole: "getRole",
33737
34042
  whoami: "whoami",
33738
- listSessions: "listSessions"
34043
+ listSessions: "listSessions",
34044
+ slackPost: "slackPost"
33739
34045
  };
33740
34046
  var envoyToolSpecs = [
33741
34047
  {
@@ -33806,6 +34112,16 @@ var envoyToolSpecs = [
33806
34112
  title: schema.string().optional()
33807
34113
  }),
33808
34114
  operation: EnvoyToolOperation.listSessions
34115
+ },
34116
+ {
34117
+ name: "slack_post",
34118
+ 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.",
34119
+ arguments: (schema) => ({
34120
+ channel: schema.string({ min: 1 }).describe("Slack channel id, such as C0123ABCD."),
34121
+ thread_ts: schema.string({ min: 1 }).describe("The thread's root ts, which keeps the reply in the mention's thread."),
34122
+ text: schema.string({ min: 1, max: SLACK_POST_TEXT_MAX }).describe(`The reply, in Slack mrkdwn, at most ${SLACK_POST_TEXT_MAX} characters.`)
34123
+ }),
34124
+ operation: EnvoyToolOperation.slackPost
33809
34125
  }
33810
34126
  ];
33811
34127
  function sendConfirmationText(result) {
@@ -34070,7 +34386,7 @@ function sourceSession(sessionID) {
34070
34386
 
34071
34387
  // ../pi-shared/src/interface.ts
34072
34388
  import { logger } from "@oh-my-pi/pi-utils";
34073
- var ENVOY_PLUGIN_INTERFACE_VERSION = 2;
34389
+ var ENVOY_PLUGIN_INTERFACE_VERSION = 3;
34074
34390
  var ENVOY_PLUGIN_INTERFACE_KEY = Symbol.for("legion.pi-shared.envoy-plugin-interface");
34075
34391
  var LEGION_PLUGIN_LOADED_KEY = Symbol.for("legion.pi-legion.loaded");
34076
34392
  var LEGACY_LEGION_LOADED_KEY = Symbol.for("legion.pi-envoy.legion-loaded");
@@ -34084,7 +34400,12 @@ function envoyPluginInterface() {
34084
34400
  const created = {
34085
34401
  version: ENVOY_PLUGIN_INTERFACE_VERSION,
34086
34402
  publishers: [],
34087
- roleClaim: { instances: [], managedSessions: new Set, regained: undefined },
34403
+ roleClaim: {
34404
+ instances: [],
34405
+ managedSessions: new Set,
34406
+ regained: undefined,
34407
+ slackRoleOwned: false
34408
+ },
34088
34409
  injectedUserTurns: new Map,
34089
34410
  bootstrappedSession: { file: undefined }
34090
34411
  };
@@ -35717,9 +36038,21 @@ function envoyExtension(pi) {
35717
36038
  });
35718
36039
  if (registry2 === undefined)
35719
36040
  return;
36041
+ const retired = publishedInterface?.roleClaim.slackRoleOwned === true ? registry2.topics.filter((topic) => slackTopicChannel(topic) !== undefined) : [];
36042
+ if (retired.length > 0) {
36043
+ await client.unsubscribe({ sessionID, topics: retired }).catch((error48) => {
36044
+ logger3.warn("envoy: could not unregister a resumed controller's retired Slack topics", {
36045
+ sessionID,
36046
+ topics: retired,
36047
+ error: messageFor(error48)
36048
+ });
36049
+ });
36050
+ }
35720
36051
  for (const topic of registry2.topics) {
35721
36052
  if (topic === agentSubject(sessionID) || topic.startsWith(ROLE_TOPIC_PREFIX))
35722
36053
  continue;
36054
+ if (retired.includes(topic))
36055
+ continue;
35723
36056
  await subscribe(topic);
35724
36057
  }
35725
36058
  };
@@ -36287,6 +36620,14 @@ Not delivered: that holder is the session this subagent sends as, and the listen
36287
36620
  title
36288
36621
  });
36289
36622
  }
36623
+ case EnvoyToolOperation.slackPost: {
36624
+ const posted = await postSlackReply({
36625
+ channel: stringFor(parameters, "channel"),
36626
+ threadTs: stringFor(parameters, "thread_ts"),
36627
+ text: stringFor(parameters, "text")
36628
+ });
36629
+ return toolSuccess(`posted ${posted.ts} in ${posted.channel}, thread ${posted.threadTs}`, { channel: posted.channel, thread_ts: posted.threadTs, ts: posted.ts });
36630
+ }
36290
36631
  }
36291
36632
  } catch (error48) {
36292
36633
  return toolFailure(error48);
@@ -153,7 +153,7 @@ that GitHub issue or pull request. Only `dispatch issue` with `--external` creat
153
153
  `dispatch issue --external owner/repo#n --project <project> --title '<title>'` before addressing it.
154
154
 
155
155
  Create an issue for newly tracked work with `dispatch issue --project <KEY> --title '<title>'` (`dispatch issue --help` lists `--parent`, `--spec-file`, `--label`, `--priority` and the rest).
156
- Labels are optional: Dispatch trims them, preserves their case, and removes case-insensitive duplicates. `--priority` is yours on creation too — see [Priority is yours to set](skill://dispatch/references/issues.md). Set `--assignee` (the email of a person who has signed in to Dispatch) only when the human said who owns the work; otherwise the default above applies, so a child inherits its parent's assignee. It prints
156
+ Labels are optional: Dispatch trims them, preserves their case, and removes case-insensitive duplicates. `--priority` is yours on creation too — see [Priority is yours to set](skill://dispatch/references/issues.md). Set `--assignee` (the email of a person who has signed in to Dispatch) only when the human said who owns the work; otherwise the default above applies, so a child inherits its parent's assignee. Set `--repo <owner/name>` when the issue targets a repository other than the one it would inherit: its parent's, else the project's default; the repository must be mapped to the project in Settings, and `dispatch read` shows the one the issue resolves to on its `Repository:` line. It prints
157
157
  the new key; creating an issue does not subscribe you to it (see [Following](#following)). Use `dispatch issue` only to create an issue; never use it to park a question. When you pass a spec,
158
158
  follow [Writing a spec](#writing-a-spec).
159
159
 
@@ -85,12 +85,13 @@ issues you own as well as the root. An issue left at `triage` while work is unde
85
85
  Waiting for the deploy lane is not a status and is never announced.
86
86
 
87
87
  ```bash
88
- # PATCH /api/v1/issues/{key} — status, title, labels, priority, external links (merged by URL, or cleared by an empty list), route, parent, blocked_by
88
+ # PATCH /api/v1/issues/{key} — status, title, labels, priority, external links (merged by URL, or cleared by an empty list), route, parent, blocked_by, repo
89
89
  dispatch issue-update --issue PROJ-175 --status testing
90
90
  dispatch issue-update --issue PROJ-175 --status done --reason 'Shipped in owner/repo#7; verified on the production dashboard.'
91
91
  dispatch issue-update --issue PROJ-175 --priority 1 # 0–3; see Priority is yours to set
92
92
  dispatch issue-update --issue PROJ-175 --external-link https://github.com/owner/repo/pull/7
93
93
  dispatch issue-update --issue PROJ-175 --parent PROJ-170 # same-project key; --parent '' clears it
94
+ dispatch issue-update --issue PROJ-175 --repo acme/widgets # mapped to the project; --repo '' clears it
94
95
  ```
95
96
 
96
97
  Closing takes a `--reason`, and the command refuses `--status done` without one: it posts the reason on
@@ -100,9 +101,16 @@ error names the posted message; after a timeout or a server error it also says t
100
101
  landed, so read the issue's status first. A retry points its reason at the posted message rather
101
102
  than repeating it.
102
103
 
103
- The three clears differ: `--clear-priority` sends null, `--parent ''` and `--route ''` clear with an
104
- empty string, and `--clear-external-links` / `--clear-blocked-by` send an empty list. Guessing the
105
- wrong shape is a refusal either way.
104
+ The three clears differ: `--clear-priority` sends null, `--parent ''`, `--route ''` and
105
+ `--repo ''` clear with an empty string, and `--clear-external-links` / `--clear-blocked-by` send an
106
+ empty list. Guessing the wrong shape is a refusal either way.
107
+
108
+ An issue targets one repository: its own `--repo`, else its nearest ancestor's, else the project's
109
+ default (set on the Settings page), else none. A repository not mapped to the issue's project is
110
+ refused `REPO_NOT_MAPPED`, naming it. `dispatch read` prints the result as
111
+ `Repository: owner/name (own)`, `Repository: owner/name (inherited or project default)`, or
112
+ `Repository: none`. `Repository: unknown (…)` means the Dispatch server is older than repository
113
+ fields: the read still works, and a `--repo` write there fails rather than drop the repository.
106
114
 
107
115
  Link the pull request that delivers the issue with `--external-link` when you open it; the issue page
108
116
  renders its state and checks from that link, and `dispatch read` lists it under `External links:`.
@@ -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.4.0",
3
+ "version": "10.6.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "omp": {