@sjawhar/opencode-legion-envoy 9.7.0 → 9.8.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
@@ -14663,7 +14663,7 @@ var dispatchToolSpecs = [
14663
14663
  {
14664
14664
  name: "dispatch_doc_read",
14665
14665
  example: { issue: "DSP-1" },
14666
- description: "Read a live document or a named document version, or the text of an uploaded file at its latest or named version. " + "Do not use it for issue status, asks, or events; " + "use dispatch read instead. Supply ref, issue, or project plus artifact; issue plus an omitted artifact reads the primary document. " + "A live read returns its document token for an optional dispatch doc-edit precondition. With blocks it lists the live document's blocks instead of its markdown, in document order: each block's id, type, token, anchored comments and asks, and opening words, and under a table each row by the index delete_row takes and each cell by its row and the column delete_column takes; block ids, block tokens and those indexes are what dispatch doc-edit takes. " + "A picture (an uploaded PNG, JPEG, GIF or WebP of at most 3,750,000 bytes, 5 MB once base64-encoded) comes back as an image you see, with its name, type, size and version, every time you ask, including one a read already showed this session; " + "any other file that is not UTF-8 text is described, with the route that serves its bytes. " + OWNER_REFERENCE,
14666
+ description: "Read a live document or a named document version, or the text of an uploaded file at its latest or named version. " + "Do not use it for issue status, asks, or events; " + "use dispatch read instead. Supply ref, issue, or project plus artifact; issue plus an omitted artifact reads the primary document. " + "A live read returns its document token for an optional dispatch doc-edit precondition. With blocks it lists the live document's blocks instead of its markdown, in document order: each block's id, type, token, anchored comments and asks, and opening words, and under a table each row by the index delete_row takes and each cell by its row and the column delete_column takes; block ids, block tokens and those indexes are what dispatch doc-edit takes. " + "After the markdown it prints every open thread anchored on the document in full: an open ask with its id and question and each reply's author, time and text, and a comment thread with its root's id, author, quote and text and each reply's author, time and text, in order; a resolved thread, and an ask that is answered or resolved, is not printed. " + "A picture (an uploaded PNG, JPEG, GIF or WebP of at most 3,750,000 bytes, 5 MB once base64-encoded) comes back as an image you see, with its name, type, size and version, every time you ask, including one a read already showed this session; " + "any other file that is not UTF-8 text is described, with the route that serves its bytes. " + OWNER_REFERENCE,
14667
14667
  arguments: (z) => ({
14668
14668
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
14669
14669
  project: z.string().describe("Project key owning the document.").optional(),
@@ -15933,8 +15933,8 @@ class DispatchClient {
15933
15933
  async resolveComment(id, actor) {
15934
15934
  return this.#json("POST", ["api", "v1", "comments", id, "resolve"], { actor });
15935
15935
  }
15936
- async getComments(issue, artifact) {
15937
- return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue), "comments"], undefined, artifact ? { artifact } : undefined);
15936
+ async getComments(issue) {
15937
+ return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue), "comments"]);
15938
15938
  }
15939
15939
  async getIssueReferences(issue) {
15940
15940
  return this.#json("GET", [
@@ -18040,22 +18040,64 @@ function messageSummary({ message, replies }, graph) {
18040
18040
  ].join(`
18041
18041
  `);
18042
18042
  }
18043
- async function openArtifactMarks(client, resolved) {
18043
+ async function openAnchoredThreads(client, resolved) {
18044
18044
  const asksPromise = resolved.owner.kind === "project" ? client.getArtifactAsks(resolved.artifact.id) : Promise.resolve(resolved.issue?.open_asks ?? []);
18045
- const commentsPromise = resolved.owner.kind === "project" ? client.getArtifactComments(resolved.artifact.id) : client.getComments(resolved.issue?.key ?? "", resolved.artifact.id);
18045
+ const commentsPromise = resolved.owner.kind === "project" ? client.getArtifactComments(resolved.artifact.id) : client.getComments(resolved.issue?.key ?? "");
18046
18046
  const commentsResultPromise = commentsPromise.then((value) => ({ status: "fulfilled", value }), (reason) => ({ status: "rejected", reason }));
18047
- const asks = await asksPromise;
18048
- const marks = asks.filter((ask) => ask.state === "open" && ask.anchor?.artifact_id === resolved.artifact.id).map((ask) => `ask ${ask.id}`);
18047
+ const asks = (await asksPromise).filter((ask) => ask.state === "open" && ask.anchor?.artifact_id === resolved.artifact.id);
18049
18048
  const commentsResult = await commentsResultPromise;
18050
- if (commentsResult.status === "rejected") {
18051
- if (dispatchAnswered(commentsResult.reason, 404))
18052
- return marks;
18049
+ if (commentsResult.status === "rejected" && !dispatchAnswered(commentsResult.reason, 404)) {
18053
18050
  throw commentsResult.reason;
18054
18051
  }
18055
- return [
18056
- ...marks,
18057
- ...commentsResult.value.filter((comment) => !comment.resolved && comment.anchor?.artifact_id === resolved.artifact.id).map((comment) => `comment ${comment.id}`)
18058
- ];
18052
+ const comments = commentsResult.status === "fulfilled" ? commentsResult.value : [];
18053
+ const askReplies = new Map(asks.map((ask) => [ask.id, []]));
18054
+ for (const comment of comments) {
18055
+ if (comment.ask_id)
18056
+ askReplies.get(comment.ask_id)?.push(comment);
18057
+ }
18058
+ const askThreads = asks.map((ask) => [
18059
+ `ask ${ask.id} \xB7 ${actorText(ask.author)} \xB7 ${ask.created_at}`,
18060
+ ...anchorLines(ask),
18061
+ `Question: ${ask.question}`,
18062
+ ...threadReplyLines(askReplies.get(ask.id) ?? [])
18063
+ ].join(`
18064
+ `));
18065
+ const byId = new Map(comments.map((comment) => [comment.id, comment]));
18066
+ const roots = comments.filter((comment) => comment.reply_to === null && !comment.ask_id && !comment.resolved && comment.anchor?.artifact_id === resolved.artifact.id);
18067
+ const replies = new Map(roots.map((root) => [root.id, []]));
18068
+ for (const comment of comments) {
18069
+ const root = comment.reply_to === null ? undefined : threadRootId(comment, byId);
18070
+ if (root !== undefined)
18071
+ replies.get(root)?.push(comment);
18072
+ }
18073
+ const commentThreads = roots.map((root) => [
18074
+ `comment ${root.id} \xB7 ${actorText(root.author)} \xB7 ${root.created_at}`,
18075
+ ...anchorLines(root),
18076
+ `Body: ${root.body}`,
18077
+ ...threadReplyLines(replies.get(root.id) ?? [])
18078
+ ].join(`
18079
+ `));
18080
+ return [...askThreads, ...commentThreads];
18081
+ }
18082
+ function threadReplyLines(replies) {
18083
+ return replies.flatMap((reply) => [
18084
+ `reply ${reply.id} \xB7 ${actorText(reply.author)} \xB7 ${reply.created_at}`,
18085
+ `Body: ${reply.body}`
18086
+ ]);
18087
+ }
18088
+ function threadRootId(comment, byId) {
18089
+ const seen = new Set;
18090
+ let current = comment;
18091
+ while (current.reply_to !== null) {
18092
+ if (seen.has(current.id))
18093
+ return;
18094
+ seen.add(current.id);
18095
+ const parent = byId.get(current.reply_to);
18096
+ if (parent === undefined)
18097
+ return;
18098
+ current = parent;
18099
+ }
18100
+ return current.id;
18059
18101
  }
18060
18102
  async function blockAsks(client, resolved, state) {
18061
18103
  const asks = await (resolved.issue === undefined ? client.getArtifactAsks(resolved.artifact.id, state) : client.listIssueAsks(resolved.issue.key, state));
@@ -19020,24 +19062,27 @@ Document token: ${listed.token}`,
19020
19062
  } : { issue: resolved.issue?.key }, version, resolved.owner.kind === "project" ? resolved.owner.project : resolved.owner.issue, shown);
19021
19063
  }
19022
19064
  const documentPromise = client.docRead(resolved.artifact.id, version);
19023
- const marksPromise = openArtifactMarks(client, resolved);
19024
- const marksResultPromise = marksPromise.then((value) => ({ status: "fulfilled", value }), (reason) => ({ status: "rejected", reason }));
19065
+ const threadsPromise = openAnchoredThreads(client, resolved);
19066
+ const threadsResultPromise = threadsPromise.then((value) => ({ status: "fulfilled", value }), (reason) => ({ status: "rejected", reason }));
19025
19067
  const document = await documentPromise;
19026
- const marksResult = await marksResultPromise;
19027
- if (marksResult.status === "rejected")
19028
- throw marksResult.reason;
19029
- const marks = marksResult.value;
19068
+ const threadsResult = await threadsResultPromise;
19069
+ if (threadsResult.status === "rejected")
19070
+ throw threadsResult.reason;
19071
+ const threads = threadsResult.value;
19030
19072
  const approval = approvalLine(resolved.artifact);
19031
19073
  const trailer = [
19032
19074
  ..."token" in document && document.token !== undefined ? [`Document token: ${document.token}`] : [],
19033
- ...marks.length === 0 ? [] : [`Open anchored asks/comments: ${marks.join(", ")}`],
19034
19075
  ...approval === undefined ? [] : [approval]
19035
19076
  ];
19036
19077
  return {
19037
- text: trailer.length === 0 ? document.markdown : `${document.markdown}
19078
+ text: [
19079
+ document.markdown,
19080
+ ...trailer.length === 0 ? [] : [trailer.join(`
19081
+ `)],
19082
+ ...threads.length === 0 ? [] : ["Open anchored threads:", ...threads]
19083
+ ].join(`
19038
19084
 
19039
- ${trailer.join(`
19040
- `)}`,
19085
+ `),
19041
19086
  details: resolved.owner.kind === "project" ? {
19042
19087
  project: resolved.artifact.project,
19043
19088
  document: `${resolved.artifact.project}/${resolved.artifact.slug}`
@@ -20191,7 +20236,9 @@ var SessionWireSchema = object({
20191
20236
  roles: array(string2()).optional(),
20192
20237
  capabilities: array(string2()).optional(),
20193
20238
  self_subscribed: boolean2(),
20194
- last_seen: number2().int().optional()
20239
+ last_seen: number2().int().optional(),
20240
+ plugin: string2().optional(),
20241
+ plugin_version: string2().optional()
20195
20242
  });
20196
20243
  var RoleWireSchema = object({
20197
20244
  role: string2(),
@@ -20327,7 +20374,9 @@ function createEnvoyClient(config) {
20327
20374
  ...input.capabilities === undefined ? {} : { capabilities: input.capabilities },
20328
20375
  driving: input.driving,
20329
20376
  ...input.selfSubscribed === undefined ? {} : { self_subscribed: input.selfSubscribed },
20330
- host: machineID()
20377
+ host: machineID(),
20378
+ plugin: input.plugin.name,
20379
+ plugin_version: input.plugin.version
20331
20380
  }))),
20332
20381
  unsubscribe: async (input) => {
20333
20382
  await post("/v1/interests/unsubscribe", {
@@ -14192,7 +14192,7 @@ var dispatchToolSpecs = [
14192
14192
  {
14193
14193
  name: "dispatch_doc_read",
14194
14194
  example: { issue: "DSP-1" },
14195
- description: "Read a live document or a named document version, or the text of an uploaded file at its latest or named version. " + "Do not use it for issue status, asks, or events; " + "use dispatch read instead. Supply ref, issue, or project plus artifact; issue plus an omitted artifact reads the primary document. " + "A live read returns its document token for an optional dispatch doc-edit precondition. With blocks it lists the live document's blocks instead of its markdown, in document order: each block's id, type, token, anchored comments and asks, and opening words, and under a table each row by the index delete_row takes and each cell by its row and the column delete_column takes; block ids, block tokens and those indexes are what dispatch doc-edit takes. " + "A picture (an uploaded PNG, JPEG, GIF or WebP of at most 3,750,000 bytes, 5 MB once base64-encoded) comes back as an image you see, with its name, type, size and version, every time you ask, including one a read already showed this session; " + "any other file that is not UTF-8 text is described, with the route that serves its bytes. " + OWNER_REFERENCE,
14195
+ description: "Read a live document or a named document version, or the text of an uploaded file at its latest or named version. " + "Do not use it for issue status, asks, or events; " + "use dispatch read instead. Supply ref, issue, or project plus artifact; issue plus an omitted artifact reads the primary document. " + "A live read returns its document token for an optional dispatch doc-edit precondition. With blocks it lists the live document's blocks instead of its markdown, in document order: each block's id, type, token, anchored comments and asks, and opening words, and under a table each row by the index delete_row takes and each cell by its row and the column delete_column takes; block ids, block tokens and those indexes are what dispatch doc-edit takes. " + "After the markdown it prints every open thread anchored on the document in full: an open ask with its id and question and each reply's author, time and text, and a comment thread with its root's id, author, quote and text and each reply's author, time and text, in order; a resolved thread, and an ask that is answered or resolved, is not printed. " + "A picture (an uploaded PNG, JPEG, GIF or WebP of at most 3,750,000 bytes, 5 MB once base64-encoded) comes back as an image you see, with its name, type, size and version, every time you ask, including one a read already showed this session; " + "any other file that is not UTF-8 text is described, with the route that serves its bytes. " + OWNER_REFERENCE,
14196
14196
  arguments: (z) => ({
14197
14197
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
14198
14198
  project: z.string().describe("Project key owning the document.").optional(),
@@ -15342,7 +15342,9 @@ var SessionWireSchema = object({
15342
15342
  roles: array(string2()).optional(),
15343
15343
  capabilities: array(string2()).optional(),
15344
15344
  self_subscribed: boolean2(),
15345
- last_seen: number2().int().optional()
15345
+ last_seen: number2().int().optional(),
15346
+ plugin: string2().optional(),
15347
+ plugin_version: string2().optional()
15346
15348
  });
15347
15349
  var RoleWireSchema = object({
15348
15350
  role: string2(),
@@ -15478,7 +15480,9 @@ function createEnvoyClient(config) {
15478
15480
  ...input.capabilities === undefined ? {} : { capabilities: input.capabilities },
15479
15481
  driving: input.driving,
15480
15482
  ...input.selfSubscribed === undefined ? {} : { self_subscribed: input.selfSubscribed },
15481
- host: machineID()
15483
+ host: machineID(),
15484
+ plugin: input.plugin.name,
15485
+ plugin_version: input.plugin.version
15482
15486
  }))),
15483
15487
  unsubscribe: async (input) => {
15484
15488
  await post("/v1/interests/unsubscribe", {
@@ -15581,6 +15585,8 @@ function sourceSession(sessionID) {
15581
15585
 
15582
15586
  // src/server.ts
15583
15587
  import { tool } from "@opencode-ai/plugin/tool";
15588
+ // package.json
15589
+ var version2 = "9.8.0";
15584
15590
 
15585
15591
  // src/log.ts
15586
15592
  import { appendFile, mkdir } from "fs/promises";
@@ -15671,6 +15677,7 @@ var skillsDirectory = [
15671
15677
  path3.resolve(moduleDirectory, "../../../skills")
15672
15678
  ].find((dir) => existsSync2(dir));
15673
15679
  var PACKAGE_NAME = "@sjawhar/opencode-legion-envoy";
15680
+ var PLUGIN = { name: PACKAGE_NAME, version: version2 };
15674
15681
  function packageRoot(from) {
15675
15682
  for (let directory = from;; directory = path3.dirname(directory)) {
15676
15683
  if (manifestName(path3.join(directory, "package.json")) === PACKAGE_NAME)
@@ -15716,7 +15723,8 @@ function envoyTools(client, port, title) {
15716
15723
  topics: args.topics,
15717
15724
  port: port() ?? 0,
15718
15725
  title: title() ?? "",
15719
- driving: true
15726
+ driving: true,
15727
+ plugin: PLUGIN
15720
15728
  }));
15721
15729
  }
15722
15730
  }),
@@ -15879,7 +15887,8 @@ var server_default = async (input) => {
15879
15887
  topics: [agentSubject(sessionID)],
15880
15888
  port,
15881
15889
  title: title ?? "",
15882
- driving
15890
+ driving,
15891
+ plugin: PLUGIN
15883
15892
  }).catch(() => {});
15884
15893
  let stopEnvoy = () => {};
15885
15894
  if (envoyOn !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "9.7.0",
3
+ "version": "9.8.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "dist/src/server.js",
@@ -12,7 +12,7 @@ except a clear defect a role reports ([Issues handed to Legion](#issues-handed-t
12
12
 
13
13
  ## Rules
14
14
 
15
- - Handle no wake until the extension's startup claim of `legion-<project>-controller` succeeded, because without it the daemon does not know you as its controller.
15
+ - Handle no wake until the extension has registered this session with the daemon as the project's controller, at session startup or, in a controller an operator started, from its first command once Oh My Pi has started, because without that registration the daemon does not know you as its controller.
16
16
  - Answer a person's direct message first, in the conversation. A plain user turn that is neither the start message nor a task sent with `legion claims deliver` is a person writing from Dispatch's Agents page.
17
17
  - A direct Dispatch message that decides scope, priority or an owner is a ruling: after you answer it, run the weekly-plan reconciliation (`skill://legion-controller/references/backlog-stewardship.md`), because the plan must record the rule.
18
18
  - Handle one wake per turn, then end the turn. Never poll or wait for an event, because the daemon wakes you again (`tick` at least every `controller_wake_interval_seconds`).
@@ -83,8 +83,8 @@ environment, then run:
83
83
  It checks `LEGION_PROJECT` against the daemon, registers the session, claims the Envoy role, and
84
84
  moves the daemon's recorded controller session id to this session; the `legion` tool's controller
85
85
  operations then work there. Never pass the secret as a command argument or copy it into a
86
- transcript, because others read transcripts. Do not set `LEGION_CONTROLLER=1`, because a session
87
- carrying that marker claims at startup as the launched controller. Run `/legion-claim-controller`
86
+ transcript, because others read transcripts. Do not set `LEGION_CONTROLLER=1`: that marker makes
87
+ the session the launched controller, which needs a start message. Run `/legion-claim-controller`
88
88
  once per session; do not run it again after an Envoy listener restart, because the heartbeat
89
89
  re-claims the role.
90
90
 
package/src/server.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  } from "@legion/envoy-client/tool-contract";
13
13
  import { createEnvoyClient, type EnvoyClient } from "@legion/envoy-client/transport";
14
14
  import { type ToolDefinition, tool } from "@opencode-ai/plugin/tool";
15
+ import { version } from "../package.json" with { type: "json" };
15
16
  import { logger } from "./log";
16
17
  import { resolvePort } from "./port";
17
18
 
@@ -29,6 +30,9 @@ const skillsDirectory = [
29
30
 
30
31
  const PACKAGE_NAME = "@sjawhar/opencode-legion-envoy";
31
32
 
33
+ /** The plugin every registration names: this package and the version its manifest carries. */
34
+ const PLUGIN = { name: PACKAGE_NAME, version };
35
+
32
36
  /** The package root: the nearest directory above `from` whose package.json names this package.
33
37
  * The packed server.js sits in dist/src/, the repo's server.ts in src/. A manifest on the way up
34
38
  * that is missing, unreadable or not JSON is passed over. */
@@ -93,6 +97,7 @@ function envoyTools(
93
97
  port: port() ?? 0,
94
98
  title: title() ?? "",
95
99
  driving: true,
100
+ plugin: PLUGIN,
96
101
  })
97
102
  );
98
103
  },
@@ -307,6 +312,7 @@ export default async (input: { serverUrl: URL }) => {
307
312
  port,
308
313
  title: title ?? "",
309
314
  driving,
315
+ plugin: PLUGIN,
310
316
  })
311
317
  .catch(() => {});
312
318