@sjawhar/opencode-legion-envoy 2.1.0 → 2.2.1

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.
@@ -13902,6 +13902,13 @@ var commentValidation = {
13902
13902
  },
13903
13903
  message: `${commentOwner.message} turn requires reply_to_ask.`
13904
13904
  };
13905
+ var messageValidation = {
13906
+ check: (value) => {
13907
+ const input = value;
13908
+ return typeof input.issue === "string" || typeof input.in_reply_to === "string";
13909
+ },
13910
+ message: "issue is required unless in_reply_to names a message delivered to this session, which is the one message with no issue."
13911
+ };
13905
13912
  var ISSUE_COMPONENTS_MODES = ["inherit", "explicit", "none"];
13906
13913
  function componentsArgument(z2) {
13907
13914
  return z2.object({
@@ -14119,12 +14126,13 @@ var dispatchToolSpecs = [
14119
14126
  {
14120
14127
  name: "dispatch_message",
14121
14128
  example: { issue: "DSP-1", body: "Implementation started." },
14122
- description: "Post a note humans must read now: a reply to a human's message, a deliverable that landed, or a blocker only " + "they can clear. Never progress or status updates - Dispatch is a high-signal record, not a log. Not a decision " + `(dispatch_ask) or document feedback (dispatch_comment). Body is at most 2,000 characters. ${ISSUE_REFERENCE}`,
14129
+ description: "Post a note humans must read now: a reply to a human's message, a deliverable that landed, or a blocker only " + "they can clear. Never progress or status updates - Dispatch is a high-signal record, not a log. Not a decision " + "(dispatch_ask) or document feedback (dispatch_comment). To answer a human's direct message to this session - " + "one sent from the Agents page, which names no issue - pass that message's bare id as in_reply_to and no issue; " + "the reply lands in that conversation, and a second call with the same in_reply_to posts nothing because " + "Dispatch keeps the one reply per message. Every other message names its issue. " + `Body is at most 2,000 characters. ${ISSUE_REFERENCE}`,
14123
14130
  arguments: (z2) => ({
14124
- issue: z2.string().describe(ISSUE_REFERENCE),
14131
+ issue: z2.string().describe(`${ISSUE_REFERENCE} Omit it only when in_reply_to answers a human's direct message to this session.`).optional(),
14125
14132
  body: z2.string({ max: 2000 }).describe("Update text, at most 2,000 characters."),
14126
- in_reply_to: z2.string().describe("Optional message id or dispatch://KEY/message/<id> reference to reply to, threading " + "this message under it so the reply stays with the original in the Conversation.").optional()
14127
- })
14133
+ in_reply_to: z2.string().describe("Optional message id or dispatch://KEY/message/<id> reference to reply to, threading " + "this message under it so the reply stays with the original in the Conversation. " + "A bare id with no issue answers a human's direct message to this session; a " + "dispatch://KEY/message/<id> names the issue its message lives on, so that form is a " + "reply on that issue.").optional()
14134
+ }),
14135
+ validation: messageValidation
14128
14136
  },
14129
14137
  {
14130
14138
  name: "dispatch_doc_edit",
@@ -14134,7 +14142,7 @@ var dispatchToolSpecs = [
14134
14142
  ops: [{ op: "delete_column", block: "table-123", index: 1 }],
14135
14143
  precondition: { blocks: [{ id: "table-123", token: "sha256:current-table-token" }] }
14136
14144
  },
14137
- description: "Apply deterministic document edits: replace or delete quoted text, insert markdown at an anchor, retype an identified paragraph or typed block into a schema-declared typed block, delete or move a whole block by its id, or delete a table row or column in place. " + "Do not use it for review feedback or for reading; use dispatch_comment, dispatch_suggest, or dispatch_doc_read instead. " + `For replace, delete, and quote anchors, find text as rendered: inline Markdown (**bold**, \`code\`) is tolerated and must be balanced; a leading '# ' matches a heading at any level. replace is inline: with is the new text of the matched span, so a marker of a different kind from the block's own stays literal text ('4. Design' written into a heading). A with that opens with a marker of the same kind as the matched block's own would write it twice and is INVALID_OP - including prose that merely looks like one ('1999. was a year' into an ordered item), which you write as text by escaping it ('1999\\. was a year'). The exception is a heading rename whose find carried a heading marker: replace(find="## Old", with="## New") gives '## New', and a different level applies only when find named the heading's actual level (find "## Old" with "### New" makes it an h3), since '# ' selects a heading without naming its level. Any non-empty with that renders to no text - a line indented four spaces or a tab, which markdown reads as a code block, or whitespace alone - is INVALID_OP rather than a silent deletion; pass an empty with to delete the matched text on purpose. ` + "A delete whose find is a block's entire text removes the block (a list emptied of its items goes too); delete with block removes any block by id, and move with block relocates one. delete_row and delete_column take a table block and a zero-based index, preserving the table block id and refusing to remove cells with open asks or unresolved comments. " + 'Insert and move anchors also accept "start", "end", "heading:<exact heading text>", and "block:<id>"; block ids and their tokens come from GET /api/v1/artifacts/{artifact UUID}/blocks (the route takes the artifact UUID, not its slug). ' + "Optionally require the state just read: precondition selects exactly one of a document token from dispatch_doc_read, or block {id, token} values from /blocks. A block guard must include every block the batch changes; Dispatch resolves quote targets and rejects an uncovered batch rather than applying it. Use a document token for insert or move, which depend on document order. Prefer block tokens when the covered content blocks are independent sections. Tokens include inline marks, so a fresh human comment also makes a stale edit fail. PRECONDITION_FAILED means re-read; EDIT_QUEUE_FULL means back off before retrying. " + "A batch that leaves the document exactly as it was mints no version, named or not, and the result says nothing changed and names each operation that did nothing. " + `The spec (or any document) holds requirements, design, and decisions - never progress, status, or timestamps. ${OWNER_REFERENCE} ${SPEC_WRITING_GUIDANCE}`,
14145
+ description: "Apply deterministic document edits: replace or delete quoted text, insert markdown at an anchor, retype an identified paragraph or typed block into a schema-declared typed block, delete or move a whole block by its id, or delete a table row or column in place. " + "Do not use it for review feedback or for reading; use dispatch_comment, dispatch_suggest, or dispatch_doc_read instead. " + `For replace, delete, and quote anchors, find text as rendered: inline Markdown (**bold**, \`code\`) is tolerated and must be balanced; a leading '# ' matches a heading at any level. replace is inline: with is the new text of the matched span, so a marker of a different kind from the block's own stays literal text ('4. Design' written into a heading). A with that opens with a marker of the same kind as the matched block's own would write it twice and is INVALID_OP - including prose that merely looks like one ('1999. was a year' into an ordered item), which you write as text by escaping it ('1999\\. was a year'). The exception is a heading rename whose find carried a heading marker: replace(find="## Old", with="## New") gives '## New', and a different level applies only when find named the heading's actual level (find "## Old" with "### New" makes it an h3), since '# ' selects a heading without naming its level. Any non-empty with that renders to no text - a line indented four spaces or a tab, which markdown reads as a code block, or whitespace alone - is INVALID_OP rather than a silent deletion; pass an empty with to delete the matched text on purpose. ` + "with cannot open a new block: after a hard line break inside with (two trailing spaces, or a backslash, before the newline) a heading, bullet, '1.'/'1)' ordered, or '>' blockquote marker is INVALID_OP too, since that line would stay escaped text inside the matched block - use insert, plus delete for what it replaces, to add the block. " + "A delete whose find is a block's entire text removes the block (a list emptied of its items goes too); delete with block removes any block by id, and move with block relocates one. delete_row and delete_column take a table block and a zero-based index, preserving the table block id and refusing to remove cells with open asks or unresolved comments. " + 'Insert and move anchors also accept "start", "end", "heading:<exact heading text>", and "block:<id>"; block ids and their tokens come from GET /api/v1/artifacts/{artifact UUID}/blocks (the route takes the artifact UUID, not its slug). ' + "Optionally require the state just read: precondition selects exactly one of a document token from dispatch_doc_read, or block {id, token} values from /blocks. A block guard must include every block the batch changes; Dispatch resolves quote targets and rejects an uncovered batch rather than applying it. Use a document token for insert or move, which depend on document order. Prefer block tokens when the covered content blocks are independent sections. Tokens include inline marks, so a fresh human comment also makes a stale edit fail. PRECONDITION_FAILED means re-read; EDIT_QUEUE_FULL means back off before retrying. " + "A batch that leaves the document exactly as it was mints no version, named or not, and the result says nothing changed and names each operation that did nothing. " + `The spec (or any document) holds requirements, design, and decisions - never progress, status, or timestamps. ${OWNER_REFERENCE} ${SPEC_WRITING_GUIDANCE}`,
14138
14146
  arguments: (z2) => ({
14139
14147
  issue: z2.string().describe(ISSUE_REFERENCE).optional(),
14140
14148
  project: z2.string().describe("Project key owning the document.").optional(),
@@ -14143,7 +14151,7 @@ var dispatchToolSpecs = [
14143
14151
  ops: z2.array(z2.object({
14144
14152
  op: z2.enum(DOC_EDIT_OPS).describe("Edit operation."),
14145
14153
  find: z2.string().describe("Text of the target as rendered, for replace or delete; inline markdown (**bold**, `code`) is tolerated and must be balanced; a leading '# ' matches a heading. A delete of a block's entire text removes the block.").optional(),
14146
- with: z2.string().describe("Replacement text for replace, parsed as inline markdown within the matched block; a marker of a different kind from the block's own is literal text, one of the same kind is refused unless it is a heading rename (where a level named by find is what lets with change it), a backslash escape keeps prose that merely looks like a marker, and any non-empty value that renders to no text is refused - only an empty value deletes the match.").optional(),
14154
+ with: z2.string().describe("Replacement text for replace, parsed as inline markdown within the matched block; a marker of a different kind from the block's own is literal text, one of the same kind is refused unless it is a heading rename (where a level named by find is what lets with change it), a backslash escape keeps prose that merely looks like a marker, a block marker after a hard line break is refused because replace cannot open a new block, and any non-empty value that renders to no text is refused - only an empty value deletes the match.").optional(),
14147
14155
  occurrence: z2.number({ int: true, min: 0 }).describe("Optional zero-based match occurrence.").optional(),
14148
14156
  markdown: z2.string().describe("Markdown to insert.").optional(),
14149
14157
  after: z2.string().describe(`Insert or move after this anchor: a quote of the neighbouring block's text, or one of "start", "end", "heading:<exact heading text>", "block:<id>".`).optional(),
@@ -15171,6 +15179,9 @@ class DispatchClient {
15171
15179
  async message(issue2, input) {
15172
15180
  return this.#json("POST", ["api", "v1", "issues", await this.#resolveIssue(issue2), "messages"], input);
15173
15181
  }
15182
+ async messageReply(id, input) {
15183
+ return this.#json("POST", ["api", "v1", "messages", id, "reply"], input);
15184
+ }
15174
15185
  async getMessage(issue2, id) {
15175
15186
  return this.#json("GET", [
15176
15187
  "api",
@@ -16019,6 +16030,10 @@ async function resolveOwnerArguments(tool, input, cwd, env, exec, serverUrl, pro
16019
16030
  owner: issue3 === undefined ? null : { kind: "issue", issue: issue3 }
16020
16031
  };
16021
16032
  }
16033
+ const replyTarget = args.in_reply_to;
16034
+ if (tool === "dispatch_message" && typeof replyTarget === "string" && !replyTarget.startsWith("dispatch://")) {
16035
+ return { args, ref, owner: null };
16036
+ }
16022
16037
  const legionIssue = env.LEGION_ISSUE;
16023
16038
  if (!legionIssue) {
16024
16039
  problems.push(ownerRequiredProblem);
@@ -16443,7 +16458,7 @@ async function executeDispatchTool(input) {
16443
16458
  const schema = toolSchema(input.tool);
16444
16459
  const parsed = schema.safeParse(ownerArguments.args, { reportInput: true });
16445
16460
  if (!parsed.success) {
16446
- const issues = parsed.error.issues.filter((issue3) => !ownerMissing || !(issue3.code === "invalid_type" && issue3.path.length === 1 && issue3.path[0] === "issue" && issue3.input === undefined || issue3.code === "custom" && issue3.path.length === 0 && issue3.message.startsWith("Exactly one of issue and project is required")));
16461
+ const issues = parsed.error.issues.filter((issue3) => !ownerMissing || !(issue3.code === "invalid_type" && issue3.path.length === 1 && issue3.path[0] === "issue" && issue3.input === undefined || issue3.code === "custom" && issue3.path.length === 0 && (issue3.message.startsWith("Exactly one of issue and project is required") || issue3.message.startsWith("issue is required unless in_reply_to"))));
16447
16462
  problems.push(...formatZodIssues(issues, schema));
16448
16463
  }
16449
16464
  problems.push(...argumentProblems(input.tool, ownerArguments.args));
@@ -16873,9 +16888,23 @@ ${followsAsk(askOwner)}`,
16873
16888
  }
16874
16889
  case "dispatch_message": {
16875
16890
  const inReplyTo = messageInReplyTo(args);
16891
+ const body = stringArg(args, "body");
16892
+ if (owner === null && inReplyTo !== undefined) {
16893
+ const reply = await client.messageReply(inReplyTo, { body, attempt: 1, actor });
16894
+ if (reply.body !== body) {
16895
+ return {
16896
+ text: `Message ${inReplyTo} was already answered by message ${reply.id}; Dispatch kept ` + "that reply and posted nothing. Wait for their next message rather than answering " + "this one again.",
16897
+ details: { message: reply.id, in_reply_to: inReplyTo, posted: false }
16898
+ };
16899
+ }
16900
+ return {
16901
+ text: `Replied to message ${inReplyTo} with message ${reply.id}`,
16902
+ details: { message: reply.id, in_reply_to: inReplyTo, posted: true }
16903
+ };
16904
+ }
16876
16905
  const issueKey = issue2();
16877
16906
  const message = await client.message(issueKey, {
16878
- body: stringArg(args, "body"),
16907
+ body,
16879
16908
  ...inReplyTo === undefined ? {} : { in_reply_to: inReplyTo },
16880
16909
  actor
16881
16910
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -45,6 +45,15 @@ vocabulary, and is often on a phone. Write for that person.
45
45
  came from — never a compressed standalone ask (his words are quoted under [Design changes are
46
46
  brainstormed here](#design-changes-are-brainstormed-here)). Give the reader the options, what
47
47
  each costs, and your recommendation with its reason; do not prescribe yourself a form.
48
+ - Describe a change by what its reader stands to lose, not by what the system does. The
49
+ engineering sentence names the change; the reader's sentence names who can do what today, what
50
+ they will not be able to do after it, what still works, and what you cannot tell. It is a
51
+ different sentence, not a shorter one — shortening keeps the nouns — and the test before
52
+ sending is whether its first sentence has a human subject. Run it even on a sentence you have
53
+ already simplified: a lead that names what a change does inside a system leaves the reader
54
+ nothing to act on, and the same options and recommendation, led with who loses what, are
55
+ answerable at once. Where the judgment rule below applies, the judgment leads and this rule
56
+ shapes the sentence under it.
48
57
  - Before posting, test it: could Sami, reading only this text on his phone, know what he is being
49
58
  told or asked? If not, rewrite it. Length is not the problem; density is.
50
59
  - When an ask or message communicates a judgment, lead with that judgment in one sentence and put the mechanism underneath it. Do not make the reader ask a second time whether the result is a win. This shapes communication only when a judgment exists; it does not pre-decide an open question or remove its genuine options. Inferred from the AGENTC-186 12-hour-cap incident (platform PO, 2026-09-17).
@@ -279,7 +288,7 @@ Sami, 2026-09-16, verbatim, rejecting two asks the same night: "All of these \"d
279
288
  completely disconnected from any discussion of design or trade-offs. This is not a very useful way
280
289
  of having this discussion" (on a report-table shape), and "What's a fenced PutObject or phantom
281
290
  eval_id? What's an R4 model header? What exactly is the question or uncertainty here?" (on a
282
- production import). Every `dispatch_ask` passes three gates first:
291
+ production import). Every `dispatch_ask` passes four gates first:
283
292
 
284
293
  1. **Does it need his authority, taste, or risk appetite?** This is the bar for a decision
285
294
  written as an `:::ask` block in context ([Decision blocks](#decision-blocks)). Technical
@@ -293,11 +302,23 @@ production import). Every `dispatch_ask` passes three gates first:
293
302
  without uncertainty is permission for an action only a human can authorise — a production
294
303
  write, an external send, a console action — and then the question is that action in one
295
304
  sentence, with options that name its outcomes (below).
296
- 3. **Can someone who has not read the code answer it on a phone?** What he can see today, what
297
- changes for a reader, two options with what each costs, your recommendation. No slice or
305
+ 3. **Can someone who has not read the code answer it on a phone?** Write it as
306
+ [Writing for the human](#writing-for-the-human) says — who can do what today and what changes
307
+ for them, then two options with what each costs and your recommendation — and no slice or
298
308
  decision numbers, no coined nouns, no internal identifiers he has never used, no jargon you
299
- would have to define. This is the phone test in [Writing for the human](#writing-for-the-human).
300
- If you cannot write it that way, you do not understand it well enough to ask.
309
+ would have to define. If you cannot write it that way, you do not understand it well enough to ask.
310
+ 4. **Is it outside what he has already told you he wants?** If not, that want is settled, and so
311
+ is every choice inside it his words do not make: build it and ask nothing about it or beside it
312
+ until it is delivered, whatever the ask says about the build. Afterwards, ask only about a choice his own
313
+ message raises and leaves open, quoting his words and naming what you delivered. A cause you
314
+ diagnosed, or a behaviour next to the one he named, is not in his message: neither ask about
315
+ it nor build it — fixing the cause of the thing he named is delivering what he asked for, and
316
+ is not what this forbids; changing something else is. An ask that turns his complaint about
317
+ one control into a choice about another does not address what he asked, and changing that
318
+ other control is a change he never asked for. One thing is still an ask the moment you know
319
+ it, even before delivery: a
320
+ credential, an approval, a setting or a console action only he can take that the delivery
321
+ waits on — see "Anything that needs the human is an ask", further down.
301
322
 
302
323
  The platform PO audits open asks. One that fails a gate — or that points at another message in
303
324
  prose instead of carrying its content (below) — is retracted, with the PO's answer as the record.
@@ -386,7 +407,7 @@ It returns the imported commit, or the recorded error when the model was rejecte
386
407
 
387
408
  Before saying you are waiting for human input, call `dispatch_open_asks`. With no arguments it lists this session's active asks across open issues and project documents, including whether the human or agent owes the next reply. With `dispatch_open_asks({ project })` it lists every open ask in that project — on its issues and on its documents, whoever authored them — which is how you audit what a whole project is waiting on rather than just your own asks.
388
409
 
389
- **Unsettled product shape needs a decision before implementation.** When a page, navigation entry, table key, customer-scoping rule, or persisted sidecar would set product shape that Sami has not already settled, send a one-line ask before the first implementation commit. A lane's schema decision or a platform-PO contract ruling does not settle product shape. This does not turn a user-specified decision or routine implementation into an approval request; it is inferred from AGENTC-186's 2026-09-16 retro (platform PO, 2026-09-17).
410
+ **Unsettled product shape needs a decision before implementation.** When a page, navigation entry, table key, customer-scoping rule, or persisted sidecar would set product shape that Sami has not already settled, send a one-line ask before the first implementation commit. A lane's schema decision or a platform-PO contract ruling does not settle product shape. This does not turn a user-specified decision or routine implementation into an approval request; it is inferred from AGENTC-186's 2026-09-16 retro (platform PO, 2026-09-17). A control or behaviour the human asked for in words is settled by those words, together with every choice inside it that his words do not make (where it sits, its defaults, its options): build it without an ask, as gate 4 of [Before you ask](#before-you-ask) says. This rule covers only product shape outside what he asked for, and its ask comes before the commit that sets that shape.
390
411
 
391
412
  **Anything that needs the human is an ask, or it does not exist.** An approval, a credential,
392
413
  a setting only they can change, a review click, a conflict between two of their own rules - if
@@ -578,11 +599,30 @@ plus `delete` to change the block's kind, level or number. The one exception is
578
599
  marker: `replace(find="## Old", with="## New")` gives `## New`. A different level in `with` applies only when `find` named the
579
600
  heading's actual level — `find="## Old"`, `with="### New"` retitles and makes it an h3 — because `# ` is the level-blind selector,
580
601
  so `find="# Old"` renames the text and keeps whatever level it selected. `with` that forms more than one
581
- paragraph is rejected (`INVALID_OP` on `with`) — delete the block and insert new blocks instead; so is any non-empty `with` that
602
+ paragraph is rejected (`INVALID_OP` on `with`) — see the recipe for a multi-paragraph rewrite below; so is any non-empty `with` that
582
603
  renders to no text, which a line indented four spaces or a tab does (markdown reads that as a code block), as does whitespace
583
604
  alone. An empty `with` is the one that deletes the matched text on purpose. Use zero-based `occurrence` for a
584
605
  repeated target; re-read a missing or ambiguous target before retrying. Pass `summary` to name the version when recording a decision.
585
606
 
607
+ **Rewriting several paragraphs is one `replace` per paragraph, then a read-back.** `replace` is inline:
608
+ each `with` is the new text of one paragraph, and a `with` that forms two paragraphs is refused whatever the
609
+ text says. Give each paragraph you rewrite its own `replace`, which keeps that paragraph's block id and every
610
+ anchor outside the text you rewrite. A comment or ask anchored to the text you rewrite loses its quote but keeps
611
+ its pin to the block, so the dashboard still shows it beside that paragraph; a delete (below) loses both. When
612
+ the new text has more paragraphs than the old, `insert` the extra ones
613
+ with `after` quoting the last paragraph you rewrote exactly as it now reads (the operations in one batch
614
+ apply in order); they land after the top-level block that holds the quote, so beside a paragraph inside a
615
+ list item or a typed block they go after the whole list or block. When it has fewer, `delete` each leftover
616
+ paragraph, with its whole text as `find` or its id as `block`. All of that holds while the new text is
617
+ paragraphs: `replace` keeps a block's kind, so a heading, list, table or code fence cannot be replaced into place —
618
+ a heading, list or table marker is written as literal text, and a code fence becomes an inline code span with the
619
+ fence gone. When the new text adds one beside paragraphs, `insert` it beside the paragraph you replaced, which keeps
620
+ that paragraph's id; only when no paragraph of the new text is left to take the old block's place is it an `insert`
621
+ of the new block plus a `delete` of the old, and a delete is what costs a block its id. Then read the document back with
622
+ `dispatch_doc_read` and read the passage and its neighbours, not a grep for the words you added: an empty
623
+ `with` deletes the matched text on purpose, so a `replace` whose `with` you meant to fill empties that
624
+ paragraph — the block and its id stay, holding nothing — and only a read shows what the document now says.
625
+
586
626
  A batch that leaves the document's semantic identity unchanged — including its inline anchor marks, so an edit that only orphans a
587
627
  comment or ask anchor still mints its version — mints no version, named or not: the response carries
588
628
  `changed: false` with `unchanged_ops` naming each operation that did nothing, and the tool result says nothing changed. A `summary`
@@ -807,6 +847,31 @@ through Envoy or the hub. A bearer that targets over HTTP names its own session
807
847
  target only a session that advertises the mode you want. Sending to a session with no issue
808
848
  (`POST /api/v1/agents/{session_id}/messages`) stays human-only.
809
849
 
850
+ ### Answering a direct message
851
+
852
+ A human can also message you directly from the **Agents** page, with no issue at all. That frame
853
+ names no issue and its `reply_with` hint carries none either; answer it with the message's bare
854
+ id in `in_reply_to`, alone:
855
+
856
+ ```ts
857
+ dispatch_message({
858
+ in_reply_to: "<the direct message's id>",
859
+ body: "The requested answer.",
860
+ })
861
+ ```
862
+
863
+ Leave `issue` out — there is no issue to post into, and naming one would file your answer on
864
+ unrelated work. Dispatch threads the reply under their message in the same conversation, and the
865
+ human sees it on your agent card. Every other message still names its issue, so keep the `issue`
866
+ the frame gave you whenever it gave you one; a `dispatch://KEY/message/<id>` reference names the
867
+ issue its message lives on, so that form is a reply on that issue, not a direct message.
868
+
869
+ One reply per message they send. A second call with the same `in_reply_to` posts nothing:
870
+ Dispatch answers it with the reply already stored, and the tool result says the message was
871
+ already answered rather than reporting a send. That happens most often when your host answered
872
+ the **BTW** automatically before you got here — read the result before writing again, and wait
873
+ for their next message instead.
874
+
810
875
  ## Following
811
876
 
812
877
  An ask has followers: every session that wrote to it — the session that opened it and every session that replied with