@sjawhar/opencode-legion-envoy 9.6.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
@@ -13894,7 +13894,10 @@ var IssueEventPayloadSchema = object({
13894
13894
  status: string2().optional(),
13895
13895
  priority: number2().int().min(0).max(3).nullable().optional(),
13896
13896
  rank: string2().optional(),
13897
- route: string2().nullish()
13897
+ route: string2().nullish(),
13898
+ changed: array(string2()).optional(),
13899
+ ancestors: array(string2()).optional(),
13900
+ thread_logins: array(string2()).optional()
13898
13901
  });
13899
13902
  var ArtifactCreatedEventPayloadSchema = object({
13900
13903
  artifact: object({ id: string2().optional(), slug: string2().optional(), name: string2().optional() }).optional()
@@ -13948,11 +13951,12 @@ var askEventPayloadFields = {
13948
13951
  primary: boolean2().optional()
13949
13952
  }).optional(),
13950
13953
  resolution: object({
13951
- kind: _enum2(["retracted", "resolved"]).optional(),
13954
+ kind: _enum2(["retracted", "resolved", "withdrawn"]).optional(),
13952
13955
  reason: string2().optional(),
13953
13956
  actor: object({ kind: string2().optional(), id: string2().optional() }).passthrough().optional(),
13954
13957
  at: string2().optional()
13955
- }).nullish()
13958
+ }).nullish(),
13959
+ thread_logins: array(string2()).optional()
13956
13960
  };
13957
13961
  var AskEventPayloadSchema = intersection(object(askEventPayloadFields), object({
13958
13962
  previous: never().optional(),
@@ -14014,7 +14018,8 @@ var CommentEventPayloadSchema = object({
14014
14018
  author: object({ kind: string2(), id: string2() }).optional(),
14015
14019
  created_at: string2().optional(),
14016
14020
  mentions: array(CommentMentionSchema).optional(),
14017
- deliveries: array(CommentDeliverySchema).optional()
14021
+ deliveries: array(CommentDeliverySchema).optional(),
14022
+ thread_logins: array(string2()).optional()
14018
14023
  }).passthrough();
14019
14024
  var MessageEventPayloadSchema = object({
14020
14025
  id: string2().optional(),
@@ -14023,7 +14028,8 @@ var MessageEventPayloadSchema = object({
14023
14028
  in_reply_to: string2().nullish(),
14024
14029
  reply_body: string2().optional(),
14025
14030
  thread_target: string2().optional(),
14026
- author: object({ kind: string2(), id: string2() }).optional()
14031
+ author: object({ kind: string2(), id: string2() }).optional(),
14032
+ thread_logins: array(string2()).optional()
14027
14033
  });
14028
14034
  var DispatchTargetedResourceIDSchema = uuid2();
14029
14035
  var DispatchTargetedMessagePayloadSchema = MessageEventPayloadSchema.extend({
@@ -14093,7 +14099,7 @@ var AskFollowerEventPayloadSchema = object({
14093
14099
 
14094
14100
  // ../contracts/src/actor-label.ts
14095
14101
  function shortSessionId(id) {
14096
- return `session:${id.slice(0, 8)}\u2026`;
14102
+ return id.startsWith("legion-daemon:") ? id : `session:${id.slice(0, 8)}\u2026`;
14097
14103
  }
14098
14104
  function sessionLabel(sessionId, title) {
14099
14105
  const trimmed = title?.trim() ?? "";
@@ -14424,7 +14430,7 @@ var dispatchToolSpecs = [
14424
14430
  priority: 1,
14425
14431
  before: "DSP-2"
14426
14432
  },
14427
- 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, set the repository it targets, or move it on its project's " + "board. 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 a board move " + "(before, after), 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. before and after move the issue on " + "its project's board: before puts it above that issue, after below, and both together " + "land it between them. A project's board order is one agent's to keep; in a Legion " + "deployment that is the controller. Never reassign an issue a person holds to get a " + "faster answer: who holds it is their call. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
14433
+ 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, set the repository it targets, or move it on its project's " + "board. 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. Closing an issue withdraws every open ask " + "it holds (resolution kind withdrawn, naming the closer), and reopening it brings exactly " + "those asks back. A closed issue takes only a board move " + "(before, after), 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. before and after move the issue on " + "its project's board: before puts it above that issue, after below, and both together " + "land it between them. A project's board order is one agent's to keep; in a Legion " + "deployment that is the controller. Never reassign an issue a person holds to get a " + "faster answer: who holds it is their call. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
14428
14434
  arguments: (z) => ({
14429
14435
  issue: z.string().describe(ISSUE_REFERENCE),
14430
14436
  status: z.enum(ISSUE_STATUSES).describe("New lifecycle status.").optional(),
@@ -14451,7 +14457,7 @@ var dispatchToolSpecs = [
14451
14457
  {
14452
14458
  name: "dispatch_claim",
14453
14459
  example: { issue: "DSP-1" },
14454
- description: "Claim a Dispatch issue before you start implementing it, so no other session takes the same work, " + "and release it when you stop. Pass the issue alone to claim it, or --release to give it up. " + "Your claim records your own session and shows on every read of the issue: the dashboard header, the " + "issue list and board, dispatch read, and dispatch issues. Claiming is refused with 409 ISSUE_CLAIMED " + "when another session holds the issue and is still running; the refusal names that session, so talk to " + "it instead of working the same issue in parallel. When a human holds the claim the refusal names the " + "person, not a session: there is nothing running to message, so ask them on the issue rather than " + "taking it. 409 CLAIM_CONTENDED means the issue changed " + "hands twice while your call ran, so nothing was applied and nobody's liveness was checked: read " + "the issue and decide again. A claim whose session is no longer running may be " + "taken: the takeover is recorded on the issue and the session that lost it is told. A claim is not the " + "issue's status \u2014 claiming moves nothing, and the result names who moves the status: you, with " + "dispatch issue-update, or Legion's daemon on an issue it runs, where a status written by anyone " + "else is undone. A claim is released by its holder or any human, and by " + "any agent once the holder's session is no longer running. " + ISSUE_REFERENCE,
14460
+ description: "Claim a Dispatch issue before you start implementing it, so no other session takes the same work, " + "and release it when you stop. Pass the issue alone to claim it, or --release to give it up. " + "Your claim records your own session and shows on every read of the issue: the dashboard header, the " + "issue list and board, dispatch read, and dispatch issues. While you hold it, a person's answers, " + "comments and messages on the issue reach your session directly (a new comment that mentions you " + "arrives once, as the mention, when the mention reached you; when the mention's send fails, the " + "comment still reaches you directly; a mention whose receipt timed out can arrive twice, and the " + "second copy is the same comment); an agent's events and every other change on the issue still " + "need a subscription to it, except in an ask you follow: anyone else's answer, hand-back, " + "resolution or edit of it (other than a quiet edit), and every reply there (new, edited, resolved " + "or reopened), reach you " + "as its follower. Claiming is refused with 409 ISSUE_CLAIMED " + "when another session holds the issue and is still running; the refusal names that session, so talk to " + "it instead of working the same issue in parallel. When a human holds the claim the refusal names the " + "person, not a session: there is nothing running to message, so ask them on the issue rather than " + "taking it. 409 CLAIM_CONTENDED means the issue changed " + "hands twice while your call ran, so nothing was applied and nobody's liveness was checked: read " + "the issue and decide again. A claim whose session is no longer running may be " + "taken: the takeover is recorded on the issue and the session that lost it is told. A claim is not the " + "issue's status \u2014 claiming moves nothing, and the result names who moves the status: you, with " + "dispatch issue-update, or Legion's daemon on an issue it runs, where a status written by anyone " + "else is undone. A claim is released by its holder or any human, and by " + "any agent once the holder's session is no longer running. " + ISSUE_REFERENCE,
14455
14461
  arguments: (z) => ({
14456
14462
  issue: z.string().describe(ISSUE_REFERENCE),
14457
14463
  release: z.boolean().describe("Give up your claim instead of taking it; the issue's status does not change.").optional()
@@ -14538,7 +14544,7 @@ var dispatchToolSpecs = [
14538
14544
  kind: "retracted",
14539
14545
  reason: "A newer question supersedes this one."
14540
14546
  },
14541
- description: "Retract an open question that is moot or resolve one after finding the answer. This closes " + "the question without answering it. An ask that lives as an `ask` block in a document is " + 'closed in the document too. A reason beginning "removed from the document in version" is ' + "refused: that marks a retraction the document's own settlement wrote. Use retracted for an ask " + "a newer one replaces or the design made moot, resolved for one whose answer you found; an " + "answered ask cannot be resolved, and resolving records no decision by a person.",
14547
+ description: "Retract an open question that is moot or resolve one after finding the answer. This closes " + "the question without answering it. An ask that lives as an `ask` block in a document is " + 'closed in the document too. A reason beginning "removed from the document in version" is ' + "refused: that marks a retraction the document's own settlement wrote. Use retracted for an ask " + "a newer one replaces or the design made moot, resolved for one whose answer you found; an " + "answered ask cannot be resolved, and resolving records no decision by a person. An issue's " + "close withdrew its open asks (kind withdrawn, which only Dispatch writes, never this tool); " + "an ask on a closed issue is refused (409 ISSUE_CLOSED): reopen the issue first.",
14542
14548
  arguments: (z) => ({
14543
14549
  ask: z.string().describe("Ask id (uuid) to close; an 8+ hex prefix unique among this session's own open asks " + "works too."),
14544
14550
  kind: z.enum(["retracted", "resolved"]).describe("Whether the ask is retracted or self-resolved."),
@@ -14657,7 +14663,7 @@ var dispatchToolSpecs = [
14657
14663
  {
14658
14664
  name: "dispatch_doc_read",
14659
14665
  example: { issue: "DSP-1" },
14660
- 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,
14661
14667
  arguments: (z) => ({
14662
14668
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
14663
14669
  project: z.string().describe("Project key owning the document.").optional(),
@@ -15927,8 +15933,8 @@ class DispatchClient {
15927
15933
  async resolveComment(id, actor) {
15928
15934
  return this.#json("POST", ["api", "v1", "comments", id, "resolve"], { actor });
15929
15935
  }
15930
- async getComments(issue, artifact) {
15931
- 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"]);
15932
15938
  }
15933
15939
  async getIssueReferences(issue) {
15934
15940
  return this.#json("GET", [
@@ -18034,22 +18040,64 @@ function messageSummary({ message, replies }, graph) {
18034
18040
  ].join(`
18035
18041
  `);
18036
18042
  }
18037
- async function openArtifactMarks(client, resolved) {
18043
+ async function openAnchoredThreads(client, resolved) {
18038
18044
  const asksPromise = resolved.owner.kind === "project" ? client.getArtifactAsks(resolved.artifact.id) : Promise.resolve(resolved.issue?.open_asks ?? []);
18039
- 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 ?? "");
18040
18046
  const commentsResultPromise = commentsPromise.then((value) => ({ status: "fulfilled", value }), (reason) => ({ status: "rejected", reason }));
18041
- const asks = await asksPromise;
18042
- 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);
18043
18048
  const commentsResult = await commentsResultPromise;
18044
- if (commentsResult.status === "rejected") {
18045
- if (dispatchAnswered(commentsResult.reason, 404))
18046
- return marks;
18049
+ if (commentsResult.status === "rejected" && !dispatchAnswered(commentsResult.reason, 404)) {
18047
18050
  throw commentsResult.reason;
18048
18051
  }
18049
- return [
18050
- ...marks,
18051
- ...commentsResult.value.filter((comment) => !comment.resolved && comment.anchor?.artifact_id === resolved.artifact.id).map((comment) => `comment ${comment.id}`)
18052
- ];
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;
18053
18101
  }
18054
18102
  async function blockAsks(client, resolved, state) {
18055
18103
  const asks = await (resolved.issue === undefined ? client.getArtifactAsks(resolved.artifact.id, state) : client.listIssueAsks(resolved.issue.key, state));
@@ -18574,7 +18622,8 @@ async function executeDispatchTool(input) {
18574
18622
  throw new Error(`Dispatch claimed ${issueKey} but answered with no claim`);
18575
18623
  }
18576
18624
  const claimed = `${issueKey}: claimed by you since ${held.at}. Its status is ${after.status}`;
18577
- text = legionManaged ? `${claimed}; ${LEGION_OWNS_STATUS} Release the claim when you stop.` : `${claimed}; a claim moves nothing, so move it to in_progress with dispatch issue-update when you start, and release the claim when you stop.`;
18625
+ const reaches = `A person's answers, comments and messages on ${issueKey} now reach you directly.`;
18626
+ text = legionManaged ? `${claimed}; ${LEGION_OWNS_STATUS} Release the claim when you stop. ${reaches}` : `${claimed}; a claim moves nothing, so move it to in_progress with dispatch issue-update when you start, and release the claim when you stop. ${reaches}`;
18578
18627
  }
18579
18628
  return {
18580
18629
  text: `${text}${await notSubscribed(issueTopic(issueKey), input.subscriptions)}`,
@@ -19013,24 +19062,27 @@ Document token: ${listed.token}`,
19013
19062
  } : { issue: resolved.issue?.key }, version, resolved.owner.kind === "project" ? resolved.owner.project : resolved.owner.issue, shown);
19014
19063
  }
19015
19064
  const documentPromise = client.docRead(resolved.artifact.id, version);
19016
- const marksPromise = openArtifactMarks(client, resolved);
19017
- 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 }));
19018
19067
  const document = await documentPromise;
19019
- const marksResult = await marksResultPromise;
19020
- if (marksResult.status === "rejected")
19021
- throw marksResult.reason;
19022
- const marks = marksResult.value;
19068
+ const threadsResult = await threadsResultPromise;
19069
+ if (threadsResult.status === "rejected")
19070
+ throw threadsResult.reason;
19071
+ const threads = threadsResult.value;
19023
19072
  const approval = approvalLine(resolved.artifact);
19024
19073
  const trailer = [
19025
19074
  ..."token" in document && document.token !== undefined ? [`Document token: ${document.token}`] : [],
19026
- ...marks.length === 0 ? [] : [`Open anchored asks/comments: ${marks.join(", ")}`],
19027
19075
  ...approval === undefined ? [] : [approval]
19028
19076
  ];
19029
19077
  return {
19030
- 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(`
19031
19084
 
19032
- ${trailer.join(`
19033
- `)}`,
19085
+ `),
19034
19086
  details: resolved.owner.kind === "project" ? {
19035
19087
  project: resolved.artifact.project,
19036
19088
  document: `${resolved.artifact.project}/${resolved.artifact.slug}`
@@ -20184,7 +20236,9 @@ var SessionWireSchema = object({
20184
20236
  roles: array(string2()).optional(),
20185
20237
  capabilities: array(string2()).optional(),
20186
20238
  self_subscribed: boolean2(),
20187
- last_seen: number2().int().optional()
20239
+ last_seen: number2().int().optional(),
20240
+ plugin: string2().optional(),
20241
+ plugin_version: string2().optional()
20188
20242
  });
20189
20243
  var RoleWireSchema = object({
20190
20244
  role: string2(),
@@ -20320,7 +20374,9 @@ function createEnvoyClient(config) {
20320
20374
  ...input.capabilities === undefined ? {} : { capabilities: input.capabilities },
20321
20375
  driving: input.driving,
20322
20376
  ...input.selfSubscribed === undefined ? {} : { self_subscribed: input.selfSubscribed },
20323
- host: machineID()
20377
+ host: machineID(),
20378
+ plugin: input.plugin.name,
20379
+ plugin_version: input.plugin.version
20324
20380
  }))),
20325
20381
  unsubscribe: async (input) => {
20326
20382
  await post("/v1/interests/unsubscribe", {
@@ -13594,7 +13594,10 @@ var IssueEventPayloadSchema = object({
13594
13594
  status: string2().optional(),
13595
13595
  priority: number2().int().min(0).max(3).nullable().optional(),
13596
13596
  rank: string2().optional(),
13597
- route: string2().nullish()
13597
+ route: string2().nullish(),
13598
+ changed: array(string2()).optional(),
13599
+ ancestors: array(string2()).optional(),
13600
+ thread_logins: array(string2()).optional()
13598
13601
  });
13599
13602
  var ArtifactCreatedEventPayloadSchema = object({
13600
13603
  artifact: object({ id: string2().optional(), slug: string2().optional(), name: string2().optional() }).optional()
@@ -13648,11 +13651,12 @@ var askEventPayloadFields = {
13648
13651
  primary: boolean2().optional()
13649
13652
  }).optional(),
13650
13653
  resolution: object({
13651
- kind: _enum2(["retracted", "resolved"]).optional(),
13654
+ kind: _enum2(["retracted", "resolved", "withdrawn"]).optional(),
13652
13655
  reason: string2().optional(),
13653
13656
  actor: object({ kind: string2().optional(), id: string2().optional() }).passthrough().optional(),
13654
13657
  at: string2().optional()
13655
- }).nullish()
13658
+ }).nullish(),
13659
+ thread_logins: array(string2()).optional()
13656
13660
  };
13657
13661
  var AskEventPayloadSchema = intersection(object(askEventPayloadFields), object({
13658
13662
  previous: never().optional(),
@@ -13714,7 +13718,8 @@ var CommentEventPayloadSchema = object({
13714
13718
  author: object({ kind: string2(), id: string2() }).optional(),
13715
13719
  created_at: string2().optional(),
13716
13720
  mentions: array(CommentMentionSchema).optional(),
13717
- deliveries: array(CommentDeliverySchema).optional()
13721
+ deliveries: array(CommentDeliverySchema).optional(),
13722
+ thread_logins: array(string2()).optional()
13718
13723
  }).passthrough();
13719
13724
  var MessageEventPayloadSchema = object({
13720
13725
  id: string2().optional(),
@@ -13723,7 +13728,8 @@ var MessageEventPayloadSchema = object({
13723
13728
  in_reply_to: string2().nullish(),
13724
13729
  reply_body: string2().optional(),
13725
13730
  thread_target: string2().optional(),
13726
- author: object({ kind: string2(), id: string2() }).optional()
13731
+ author: object({ kind: string2(), id: string2() }).optional(),
13732
+ thread_logins: array(string2()).optional()
13727
13733
  });
13728
13734
  var DispatchTargetedResourceIDSchema = uuid2();
13729
13735
  var DispatchTargetedMessagePayloadSchema = MessageEventPayloadSchema.extend({
@@ -13953,7 +13959,7 @@ var dispatchToolSpecs = [
13953
13959
  priority: 1,
13954
13960
  before: "DSP-2"
13955
13961
  },
13956
- 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, set the repository it targets, or move it on its project's " + "board. 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 a board move " + "(before, after), 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. before and after move the issue on " + "its project's board: before puts it above that issue, after below, and both together " + "land it between them. A project's board order is one agent's to keep; in a Legion " + "deployment that is the controller. Never reassign an issue a person holds to get a " + "faster answer: who holds it is their call. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
13962
+ 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, set the repository it targets, or move it on its project's " + "board. 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. Closing an issue withdraws every open ask " + "it holds (resolution kind withdrawn, naming the closer), and reopening it brings exactly " + "those asks back. A closed issue takes only a board move " + "(before, after), 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. before and after move the issue on " + "its project's board: before puts it above that issue, after below, and both together " + "land it between them. A project's board order is one agent's to keep; in a Legion " + "deployment that is the controller. Never reassign an issue a person holds to get a " + "faster answer: who holds it is their call. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
13957
13963
  arguments: (z) => ({
13958
13964
  issue: z.string().describe(ISSUE_REFERENCE),
13959
13965
  status: z.enum(ISSUE_STATUSES).describe("New lifecycle status.").optional(),
@@ -13980,7 +13986,7 @@ var dispatchToolSpecs = [
13980
13986
  {
13981
13987
  name: "dispatch_claim",
13982
13988
  example: { issue: "DSP-1" },
13983
- description: "Claim a Dispatch issue before you start implementing it, so no other session takes the same work, " + "and release it when you stop. Pass the issue alone to claim it, or --release to give it up. " + "Your claim records your own session and shows on every read of the issue: the dashboard header, the " + "issue list and board, dispatch read, and dispatch issues. Claiming is refused with 409 ISSUE_CLAIMED " + "when another session holds the issue and is still running; the refusal names that session, so talk to " + "it instead of working the same issue in parallel. When a human holds the claim the refusal names the " + "person, not a session: there is nothing running to message, so ask them on the issue rather than " + "taking it. 409 CLAIM_CONTENDED means the issue changed " + "hands twice while your call ran, so nothing was applied and nobody's liveness was checked: read " + "the issue and decide again. A claim whose session is no longer running may be " + "taken: the takeover is recorded on the issue and the session that lost it is told. A claim is not the " + "issue's status \u2014 claiming moves nothing, and the result names who moves the status: you, with " + "dispatch issue-update, or Legion's daemon on an issue it runs, where a status written by anyone " + "else is undone. A claim is released by its holder or any human, and by " + "any agent once the holder's session is no longer running. " + ISSUE_REFERENCE,
13989
+ description: "Claim a Dispatch issue before you start implementing it, so no other session takes the same work, " + "and release it when you stop. Pass the issue alone to claim it, or --release to give it up. " + "Your claim records your own session and shows on every read of the issue: the dashboard header, the " + "issue list and board, dispatch read, and dispatch issues. While you hold it, a person's answers, " + "comments and messages on the issue reach your session directly (a new comment that mentions you " + "arrives once, as the mention, when the mention reached you; when the mention's send fails, the " + "comment still reaches you directly; a mention whose receipt timed out can arrive twice, and the " + "second copy is the same comment); an agent's events and every other change on the issue still " + "need a subscription to it, except in an ask you follow: anyone else's answer, hand-back, " + "resolution or edit of it (other than a quiet edit), and every reply there (new, edited, resolved " + "or reopened), reach you " + "as its follower. Claiming is refused with 409 ISSUE_CLAIMED " + "when another session holds the issue and is still running; the refusal names that session, so talk to " + "it instead of working the same issue in parallel. When a human holds the claim the refusal names the " + "person, not a session: there is nothing running to message, so ask them on the issue rather than " + "taking it. 409 CLAIM_CONTENDED means the issue changed " + "hands twice while your call ran, so nothing was applied and nobody's liveness was checked: read " + "the issue and decide again. A claim whose session is no longer running may be " + "taken: the takeover is recorded on the issue and the session that lost it is told. A claim is not the " + "issue's status \u2014 claiming moves nothing, and the result names who moves the status: you, with " + "dispatch issue-update, or Legion's daemon on an issue it runs, where a status written by anyone " + "else is undone. A claim is released by its holder or any human, and by " + "any agent once the holder's session is no longer running. " + ISSUE_REFERENCE,
13984
13990
  arguments: (z) => ({
13985
13991
  issue: z.string().describe(ISSUE_REFERENCE),
13986
13992
  release: z.boolean().describe("Give up your claim instead of taking it; the issue's status does not change.").optional()
@@ -14067,7 +14073,7 @@ var dispatchToolSpecs = [
14067
14073
  kind: "retracted",
14068
14074
  reason: "A newer question supersedes this one."
14069
14075
  },
14070
- description: "Retract an open question that is moot or resolve one after finding the answer. This closes " + "the question without answering it. An ask that lives as an `ask` block in a document is " + 'closed in the document too. A reason beginning "removed from the document in version" is ' + "refused: that marks a retraction the document's own settlement wrote. Use retracted for an ask " + "a newer one replaces or the design made moot, resolved for one whose answer you found; an " + "answered ask cannot be resolved, and resolving records no decision by a person.",
14076
+ description: "Retract an open question that is moot or resolve one after finding the answer. This closes " + "the question without answering it. An ask that lives as an `ask` block in a document is " + 'closed in the document too. A reason beginning "removed from the document in version" is ' + "refused: that marks a retraction the document's own settlement wrote. Use retracted for an ask " + "a newer one replaces or the design made moot, resolved for one whose answer you found; an " + "answered ask cannot be resolved, and resolving records no decision by a person. An issue's " + "close withdrew its open asks (kind withdrawn, which only Dispatch writes, never this tool); " + "an ask on a closed issue is refused (409 ISSUE_CLOSED): reopen the issue first.",
14071
14077
  arguments: (z) => ({
14072
14078
  ask: z.string().describe("Ask id (uuid) to close; an 8+ hex prefix unique among this session's own open asks " + "works too."),
14073
14079
  kind: z.enum(["retracted", "resolved"]).describe("Whether the ask is retracted or self-resolved."),
@@ -14186,7 +14192,7 @@ var dispatchToolSpecs = [
14186
14192
  {
14187
14193
  name: "dispatch_doc_read",
14188
14194
  example: { issue: "DSP-1" },
14189
- 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,
14190
14196
  arguments: (z) => ({
14191
14197
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
14192
14198
  project: z.string().describe("Project key owning the document.").optional(),
@@ -15336,7 +15342,9 @@ var SessionWireSchema = object({
15336
15342
  roles: array(string2()).optional(),
15337
15343
  capabilities: array(string2()).optional(),
15338
15344
  self_subscribed: boolean2(),
15339
- last_seen: number2().int().optional()
15345
+ last_seen: number2().int().optional(),
15346
+ plugin: string2().optional(),
15347
+ plugin_version: string2().optional()
15340
15348
  });
15341
15349
  var RoleWireSchema = object({
15342
15350
  role: string2(),
@@ -15472,7 +15480,9 @@ function createEnvoyClient(config) {
15472
15480
  ...input.capabilities === undefined ? {} : { capabilities: input.capabilities },
15473
15481
  driving: input.driving,
15474
15482
  ...input.selfSubscribed === undefined ? {} : { self_subscribed: input.selfSubscribed },
15475
- host: machineID()
15483
+ host: machineID(),
15484
+ plugin: input.plugin.name,
15485
+ plugin_version: input.plugin.version
15476
15486
  }))),
15477
15487
  unsubscribe: async (input) => {
15478
15488
  await post("/v1/interests/unsubscribe", {
@@ -15575,6 +15585,8 @@ function sourceSession(sessionID) {
15575
15585
 
15576
15586
  // src/server.ts
15577
15587
  import { tool } from "@opencode-ai/plugin/tool";
15588
+ // package.json
15589
+ var version2 = "9.8.0";
15578
15590
 
15579
15591
  // src/log.ts
15580
15592
  import { appendFile, mkdir } from "fs/promises";
@@ -15665,6 +15677,7 @@ var skillsDirectory = [
15665
15677
  path3.resolve(moduleDirectory, "../../../skills")
15666
15678
  ].find((dir) => existsSync2(dir));
15667
15679
  var PACKAGE_NAME = "@sjawhar/opencode-legion-envoy";
15680
+ var PLUGIN = { name: PACKAGE_NAME, version: version2 };
15668
15681
  function packageRoot(from) {
15669
15682
  for (let directory = from;; directory = path3.dirname(directory)) {
15670
15683
  if (manifestName(path3.join(directory, "package.json")) === PACKAGE_NAME)
@@ -15710,7 +15723,8 @@ function envoyTools(client, port, title) {
15710
15723
  topics: args.topics,
15711
15724
  port: port() ?? 0,
15712
15725
  title: title() ?? "",
15713
- driving: true
15726
+ driving: true,
15727
+ plugin: PLUGIN
15714
15728
  }));
15715
15729
  }
15716
15730
  }),
@@ -15873,7 +15887,8 @@ var server_default = async (input) => {
15873
15887
  topics: [agentSubject(sessionID)],
15874
15888
  port,
15875
15889
  title: title ?? "",
15876
- driving
15890
+ driving,
15891
+ plugin: PLUGIN
15877
15892
  }).catch(() => {});
15878
15893
  let stopEnvoy = () => {};
15879
15894
  if (envoyOn !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "9.6.0",
3
+ "version": "9.8.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "dist/src/server.js",
package/skills/AGENTS.md CHANGED
@@ -22,7 +22,7 @@ They are Markdown instructions loaded by Oh My Pi sessions; the daemon and the t
22
22
  | `thermonuclear-deep-review/` | the `thermonuclear-deep-review` agent, and the reviewer (the Security Guidelines) | the correctness rubric of the reviewer's pair, with its tagged, diff-triggered Security Guidelines and the attack on the PR body's claims (adapted from the MIT-licensed Thermos plugin in `cursor/plugins`; `LICENSE` beside it) |
23
23
 
24
24
  The owning skill above is where each contract is defined; a role prompt that needs a contract from its own seat points there or restates only its own step. This file lists and does not restate.
25
- The skills here ship in two Oh My Pi plugins, and the partition is kept in one place, `scripts/pi-plugin-prepack.sh` at the repository root, keyed by package name: `@sjawhar/pi-envoy` (`packages/pi-envoy`) ships `asd-ste100`, `dispatch`, `dispatch-first`, `dispatch-writing-specs` and `envoy`, the skills every session with Dispatch reads; `@sjawhar/pi-legion` (`packages/pi-legion`) ships `legion-architect`, `legion-controller`, `legion-oracle`, `legion-retro`, `legion-worker`, `leading-a-work-stream`, `ce-simplify-code`, `thermonuclear-code-quality` and `thermonuclear-deep-review`, the skills a Legion pane's prompts load. Each plugin's prepack stages its list into its `dist/skills`, and `packages/pi-shared/test/skills-partition.test.ts` holds the two lists to this directory and to the plugin's skill lists in `.github/workflows/release.yaml` (its `changes` filter and its version step): together they are every skill directory here, and none is in both. A link from a skill in one partition into a skill in the other (a Legion skill's link into `dispatch`) is written `skill://<name>/<path>`, never as a relative path: a staged partition is installed on its own, so a relative link out of it dangles, while a `skill://` link resolves by name once both plugins are installed.
25
+ The skills here ship in two Oh My Pi plugins, and the partition is kept in one place, `scripts/pi-plugin-prepack.sh` at the repository root, keyed by package name: `@sjawhar/pi-envoy` (`packages/pi-envoy`) ships `asd-ste100`, `dispatch`, `dispatch-first`, `dispatch-writing-specs` and `envoy`, the skills every session with Dispatch reads; `@sjawhar/pi-legion` (`packages/pi-legion`) ships `legion-architect`, `legion-controller`, `legion-oracle`, `legion-retro`, `legion-worker`, `leading-a-work-stream`, `ce-simplify-code`, `thermonuclear-code-quality` and `thermonuclear-deep-review`, the skills a Legion pane's prompts load. Each plugin's prepack stages its list into its `dist/skills`, and `packages/pi-shared/test/skills-partition.test.ts` holds the two lists to this directory and to the plugin's skill lists in `.github/workflows/release.yaml` (the `--patch-path` and the positional `skills/<name>/` entries of its version arguments in the workflow's `env:`, `PI_ENVOY_VERSION_ARGS` and `PI_LEGION_VERSION_ARGS`): together they are every skill directory here, and none is in both. A link from a skill in one partition into a skill in the other (a Legion skill's link into `dispatch`) is written `skill://<name>/<path>`, never as a relative path: a staged partition is installed on its own, so a relative link out of it dangles, while a `skill://` link resolves by name once both plugins are installed.
26
26
  A Legion prompt (a skill here, a role prompt, or an agent definition in `packages/pi-legion/agents/`) names a task agent only as `task(agent="<name>")` and a skill it tells the model to load only as `skill://<name>`. Those are the two forms the Go daemon's boot gate and `legion probe-image` resolve through Oh My Pi, refusing by name one it cannot find; a dispatch or a load written any other way goes unchecked. An agent or skill Legion's prompts name is shipped here or in `packages/pi-legion/agents/`, unless Oh My Pi bundles it.
27
27
  A skill file an agent loads stays under 51,200 bytes, Oh My Pi's spill threshold (a longer one arrives with its middle cut out), and a skill's detail lives in `references/` files linked as `skill://<name>/references/<file>.md`, never by relative path, which a read by path cuts at 300 lines. The rules are `packages/pi-shared/test/skills-guard.ts`, run over each plugin's staged partition: `packages/pi-envoy/src/skills-guard.test.ts` over the Envoy plugin's five skills, with the `dispatch-first` budget check and the word budget of the three Dispatch skills (`dispatch`, `dispatch-first` and `dispatch-writing-specs` together under 2,000 words, every file and frontmatter counted), and `packages/pi-legion/src/skills-guard.test.ts` over the Legion plugin's nine, with the daemon's prompts as linking roots and every `legion-worker` reference linked from somewhere. Each fails on a file at the threshold, on any `SKILL.md` body of 500 lines or more, on a skill whose frontmatter `name` is not its directory's name, on a `skill://<name>/<path>` link that names a missing file or a missing `#heading` (resolved against this directory, where both partitions live), and on a relative link that leaves the staged partition or names no file. The Envoy guard also fails on a `task(agent="<name>")` in the Envoy plugin's skills that names an agent neither that plugin nor Oh My Pi bundles (the bundled list read off the pinned binary's `omp agents unpack` under `LEGION_TEST_OMP`, never written down), since those skills reach a session with no other plugin installed; a `skill://legion-*` mention there is a sentence conditioned on a Legion role, inert for a person and right for a pane with both plugins, which the guard leaves alone.
28
28
  The text a worker boots with (its role prompt) lives in `packages/daemon/internal/prompts/roles/`,
@@ -43,9 +43,9 @@ Which machines, files or people, the numbers, then "A happens to X, or B to Y?"
43
43
 
44
44
  ## Issue status is yours to move
45
45
 
46
- Outside Legion, claim your issue (`dispatch claim`) and move it and your children: `in_progress` at implementation, `testing` on a production-like surface, `needs_review` while its PR waits, `done` with `--reason` once driven in production. An issue with the `legion` label, or under one: never write its status, and claim it only as a Legion role of its tree, even when its claim reads `· not running`, as a suspended architect's does: taking it takes the tree out of the workflow.
46
+ Outside Legion, claim your issue (`dispatch claim`) and move it and your children: `in_progress` at implementation, `testing` on a production-like surface, `needs_review` while its PR waits, `done` with `--reason` once driven in production. An issue with the `legion` label, or under one: never write its status or route it, and claim it only as a Legion role of its tree, even when its claim reads `· not running`, as a suspended architect's does: taking it removes the tree from the workflow.
47
47
 
48
- A claim delivers no comments: that turn, `dispatch issue-update --route session:<your id>` (`--route ''` on release) or `envoy_subscribe notifications.dispatch.issue.<KEY>.>`. Never route such an issue.
48
+ A claim, or `dispatch issue-update --route session:<your id>` (`--route ''` on release), brings a person's answers, comments and messages; for agents' activity, `envoy_subscribe notifications.dispatch.issue.<KEY>.>`.
49
49
 
50
50
  ## Close what you opened
51
51
 
@@ -30,11 +30,10 @@ Another agent can build and check the work from the spec alone. A small change g
30
30
  For each spec, ask, comment and message:
31
31
 
32
32
  - Write in `skill://asd-ste100`'s strict mode.
33
- - State the current design, never how people reached it.
34
- - Link each source where the person wrote it: `dispatch://KEY/comment/<id>`, `/ask/<id>`, `/message/<id>`, or a Slack URL. With no link, state the design.
33
+ - Write about the current design, never the person or how people reached it. Never quote people or upload their words.
34
+ - Link each source where the person wrote it. With no link, state the design.
35
35
  - In a spec, cite code by file and symbol, never a line number: it goes stale at the next edit.
36
36
  - A sentence that cites a gate passing (a test, review, check or proof) also says, in that sentence, what the pass does not show: "The tests pass; they catch a wrong verdict, not a proof of a contradiction."
37
- - Write about the design, never the person. Never quote people or upload their words.
38
37
  - Name your session only if a reader must reach it.
39
38
  - Keep only text that helps a reader understand, decide, build, check.
40
39
  - Explain each name or term a new reader may not know. Say in a clause what each issue you name does for this reader.
@@ -60,11 +59,11 @@ For each spec, ask, comment and message:
60
59
  - Each design choice no source states is a `###` entry: choice, reason, options weighed, parts shaped.
61
60
  - An unsure entry adds an `Unsure:` paragraph: what would change your mind.
62
61
  - A replaced entry moves under a final `### Replaced`.
63
- - A person's decision is their answered ask block. Each part it shapes links it.
62
+ - Link a person's answered ask block from each part it shapes.
64
63
 
65
64
  ## Old specs
66
65
 
67
- At your next edit, rewrite a spec outside the outline whole, in one version. Move its ask blocks with `doc-edit`'s `move`.
66
+ At your next edit, rewrite a spec outside the outline whole, in one version. Move its ask blocks with `doc-edit`'s `move`. Kept text follows the text rules: restate each retelling, quotation or unlinked attribution as design, or cut it.
68
67
 
69
68
  ## Approval
70
69
 
@@ -73,4 +72,4 @@ At your next edit, rewrite a spec outside the outline whole, in one version. Mov
73
72
 
74
73
  ## The plan
75
74
 
76
- Write the approved spec's plan with `writing-plans` as the issue's `plan.md`, not a repository file.
75
+ Write the plan with `writing-plans` as the issue's `plan.md`, not a repository file.
@@ -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`).
@@ -30,9 +30,9 @@ unassigned.
30
30
 
31
31
  Each time you read it:
32
32
 
33
- - `Status: done` means someone closed it, and Dispatch then refuses an ask or a message there
34
- (`409 ISSUE_CLOSED`) and hides its open asks. Reopen it with `set_status` to `icebox` and say so
35
- in the day's report.
33
+ - `Status: done` means someone closed it: the close withdrew its open asks (each recorded
34
+ `withdrawn`; a reopen brings them back), and Dispatch refuses an ask or a message there
35
+ (`409 ISSUE_CLOSED`). Reopen it with `set_status` to `icebox` and say so in the day's report.
36
36
  - `Assignee: unassigned` with a person known: assign it, naming your session (`dispatch whoami`):
37
37
  `curl -sS -X PATCH -H "Authorization: Bearer $(cat "$DISPATCH_TOKEN_FILE")" -H 'Content-Type: application/json' -d '{"assignee": "<email>", "actor": {"kind": "session", "id": "<session id>"}}' "$DISPATCH_URL/api/v1/issues/<REPORT KEY>"`.
38
38
  An answer that does not print the issue with its new `assignee` left the assignee as it was
@@ -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
 
@@ -118,9 +118,9 @@ An ask goes on the controller's report issue whenever it is open: the issue the
118
118
  instructions name for Legion's reports, else the project's `Legion daily report`, which
119
119
  `dispatch search --query '"Legion daily report"' --project <PROJECT>` finds. It stays open in
120
120
  `icebox`, so the ask stays answerable whatever happens to the issue it came from; once an issue
121
- closes, Dispatch takes its open asks out of the Inbox and refuses their answers
122
- (`409 ISSUE_CLOSED`). The question names the issue the finding came from by its `dispatch://`
123
- reference.
121
+ closes, its close withdraws its open asks (a reopen brings them back), and Dispatch refuses their
122
+ answers (`409 ISSUE_CLOSED`). The question names the issue the finding came from by its
123
+ `dispatch://` reference.
124
124
 
125
125
  When the project has no report issue yet, or a person closed it (`dispatch read` shows `Status:
126
126
  done`, and Dispatch refuses the ask with `409 ISSUE_CLOSED`), do not create or reopen one yourself
@@ -289,10 +289,11 @@ reviewing until you finish.
289
289
  repository's deploy workflow (`projects.<KEY>.deploys`), at each completed run of that workflow
290
290
  on the default branch whose named step (such as `Apply`) succeeded after the merge, once per such
291
291
  job; or, for `sjawhar/legion`, on a Legion daemon's boot on a build containing the merge, on
292
- Dispatch's `server.started` naming such a build, or on the build the daemon read from Dispatch at
293
- its boot. The woken look's task names the event (`Dispatch's issue.deployed: run <id> completed <t>`,
292
+ Dispatch's `server.started` announcing that every server process serves such a build, or on the
293
+ build the daemon read from Dispatch at its boot. The woken look's task names the event
294
+ (`Dispatch's issue.deployed: run <id> completed <t>`,
294
295
  `the deploy workflow's run: <repo> <workflow> run <id> attempt <n>, job <job name>: <step> succeeded at <t>`,
295
- `the Legion daemon's boot on build <sha>`, `Dispatch's start on build <sha>`,
296
+ `the Legion daemon's boot on build <sha>`, `Dispatch serving build <sha> on every server process`,
296
297
  `Dispatch's build <sha>`), and its `Production:` line names it too, so the pull request carries
297
298
  the cause. Never poll for the deploy, never wait on a reply, and never ask anyone for approval
298
299
  to deploy. Open a `dispatch ask` to a person only when the instructions name nobody for that
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