@sjawhar/claude-envoy 1.14.0 → 1.15.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.
@@ -2,5 +2,5 @@
2
2
  "name": "claude-envoy",
3
3
  "description": "Deliver Envoy events as Claude Code channel notifications and expose Envoy MCP tools.",
4
4
  "mcpServers": "./.mcp.json",
5
- "version": "1.14.0"
5
+ "version": "1.15.0"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-envoy",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Deliver Envoy events as Claude Code channel notifications and expose Envoy MCP tools.",
5
5
  "mcpServers": {}
6
6
  }
package/README.md CHANGED
@@ -234,7 +234,7 @@ Claude Code drops invalid meta keys, so the server filters them before writing t
234
234
  | `dedupe_key` | Identity a re-send shares with the first delivery; the channel uses it to drop the re-send. |
235
235
  | `urgency` | Optional: how soon the sender asks the reader to act. Informational only: it does not change when or in what order deliveries arrive. |
236
236
  | `from_session` | Optional source session identifier. |
237
- | `expects_reply` | Optional Envoy reply expectation. |
237
+ | `expects_reply` | Optional: what the sender wants back, as a string: `false` (nothing, so do not reply and do not acknowledge), `optional` or `required`. The old `none` reaches the reader as `false`. |
238
238
  | `in_reply_to` | Optional correlated inbound event id. |
239
239
 
240
240
  ## Envoy tools
package/dist/dispatch.js CHANGED
@@ -15439,7 +15439,7 @@ var dispatchToolSpecs = [
15439
15439
  {
15440
15440
  name: "dispatch_message",
15441
15441
  example: { issue: "DSP-1", body: "Implementation started." },
15442
- description: "Post a note humans must read now: a reply to a human's message or a deliverable that landed. A to-do only a human can " + "complete is an ask (dispatch ask), so it reaches their inbox. Never progress or status updates - Dispatch is a high-signal " + "record, not a log. Not a design decision (write it as a decision block in the document) 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. Another call with the same in_reply_to and new text posts a follow-up, " + "threaded under this session's first reply; the same text again posts nothing. dispatch read --message <id> reads " + "that conversation back. " + "Every other message names its issue; keep --issue whenever the frame you answer gave one. A BTW, Aside or Steer " + "frame carries a reply_with command: answer with it, on that issue. On Oh My Pi an Agents-page Send or Aside " + "arrives as a user message: answer in the conversation, with no dispatch message. " + `${BODY_BLOCK_RULE} The lines images appends count toward it. ${ISSUE_REFERENCE}`,
15442
+ description: "Post a note humans must read now: a reply to a human's message or a deliverable that landed. A to-do only a human can " + "complete is an ask (dispatch ask), so it reaches their inbox. Never progress or status updates - Dispatch is a high-signal " + "record, not a log. Not a design decision (write it as a decision block in the document) 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. Another call with the same in_reply_to and new text posts a follow-up, " + "threaded under this session's first reply; the same text again posts nothing. dispatch read --message <id> reads " + "that conversation back. " + "Every other message names its issue; keep --issue whenever the frame you answer gave one. A frame reading expects_reply: optional or " + "required carries a reply_with command: answer with it, on that issue. One reading false carries none and asks for no answer; where " + "you answer one anyway, its card names what to answer. On Oh My Pi an Agents-page Send or Aside " + "arrives as a user message: answer in the conversation, with no dispatch message. " + `${BODY_BLOCK_RULE} The lines images appends count toward it. ${ISSUE_REFERENCE}`,
15443
15443
  arguments: (z) => ({
15444
15444
  issue: z.string().describe(`${ISSUE_REFERENCE} Omit it only when in_reply_to answers a human's direct message to this session.`).optional(),
15445
15445
  body: z.string().describe(`Update text. ${BODY_BLOCK_RULE}`),
@@ -16766,6 +16766,7 @@ var DISPATCH_TEXT_REFERENCES = [
16766
16766
  ];
16767
16767
  // ../contracts/src/envelope.ts
16768
16768
  var isSubject = (value) => typeof value === "string" && value.length > 0;
16769
+ var EXPECTS_REPLY_VALUES = [false, "optional", "required"];
16769
16770
  var EnvelopeSchema = object({
16770
16771
  event_id: string2().min(1),
16771
16772
  source: _enum2([
@@ -16799,7 +16800,7 @@ var EnvelopeSchema = object({
16799
16800
  in_reply_to: string2().min(1).optional(),
16800
16801
  supersedes: string2().min(1).optional(),
16801
16802
  urgency: _enum2(["low", "med", "high", "blocking"]).optional(),
16802
- expects_reply: _enum2(["none", "optional", "required"]).optional()
16803
+ expects_reply: literal(EXPECTS_REPLY_VALUES).optional()
16803
16804
  });
16804
16805
  var MINTED_DEDUPE_KEY_PATTERN = "^(?:envoy\\.role\\.forward\\.)?(?:publish|agent\\.[^.]+)\\.(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$";
16805
16806
  var mintedDedupeKey = new RegExp(MINTED_DEDUPE_KEY_PATTERN);
@@ -16937,6 +16938,12 @@ var SLACK_POST_TEXT_MAX = 4000;
16937
16938
  function overCapMessage(length, max) {
16938
16939
  return `is ${length - max} characters over the ${max}-character limit (${length}/${max})`;
16939
16940
  }
16941
+ function literalOfSet(api, values) {
16942
+ const node = api.literal?.(values);
16943
+ if (node === undefined || typeof node.safeParse !== "function")
16944
+ return;
16945
+ return values.every((value) => node.safeParse(value).success) ? node : undefined;
16946
+ }
16940
16947
  function zodSchemaApi(zod) {
16941
16948
  const api = zod;
16942
16949
  return {
@@ -16965,6 +16972,7 @@ function zodSchemaApi(zod) {
16965
16972
  },
16966
16973
  boolean: () => api.boolean(),
16967
16974
  enum: (values) => api.enum(values),
16975
+ literal: (values) => literalOfSet(api, values) ?? api.unknown(),
16968
16976
  unknown: () => api.unknown(),
16969
16977
  array: (item, opts = {}) => {
16970
16978
  let schema = api.array(item);
@@ -19329,6 +19337,7 @@ var InboundSenderSchema = object({
19329
19337
  title: string2().optional(),
19330
19338
  roles: array(string2()).optional()
19331
19339
  });
19340
+ var InboundExpectsReplySchema = literal([...EXPECTS_REPLY_VALUES, "none"]).transform((value) => value === "none" ? false : value);
19332
19341
  var InboundEnvelopeSchema = object({
19333
19342
  event_id: string2().optional(),
19334
19343
  source: string2(),
@@ -19345,7 +19354,7 @@ var InboundEnvelopeSchema = object({
19345
19354
  in_reply_to: string2().optional(),
19346
19355
  supersedes: string2().optional(),
19347
19356
  urgency: string2().optional(),
19348
- expects_reply: string2().optional()
19357
+ expects_reply: InboundExpectsReplySchema.optional()
19349
19358
  }).passthrough();
19350
19359
  function tolerantShape(shape) {
19351
19360
  return Object.fromEntries(Object.entries(shape).map(([key, schema]) => [key, _catch2(schema, undefined)]));
@@ -20052,7 +20061,8 @@ function renderInbound(raw, sessionID, subject, wholeBody) {
20052
20061
  break;
20053
20062
  }
20054
20063
  const role = envelope.sender?.roles?.[0];
20055
- const reply = dispatchReply ?? replyWith(envelope);
20064
+ const wantsNoReply = envelope.expects_reply === false;
20065
+ const reply = wantsNoReply ? undefined : dispatchReply ?? replyWith(envelope);
20056
20066
  const sourceIssue = parseIssues.includes("source") || KNOWN_SOURCES[envelope.source] !== undefined ? undefined : `source=${envelope.source}`;
20057
20067
  const unrecognised = [
20058
20068
  ...parseIssues,
@@ -20071,7 +20081,7 @@ function renderInbound(raw, sessionID, subject, wholeBody) {
20071
20081
  ...inReplyTo === undefined ? {} : { re: inReplyTo },
20072
20082
  ...envelope.supersedes === undefined ? {} : { supersedes: envelope.supersedes },
20073
20083
  ...reply === undefined ? {} : { reply_with: reply },
20074
- ...role === undefined ? {} : {
20084
+ ...role === undefined || wantsNoReply ? {} : {
20075
20085
  reply_role: {
20076
20086
  tool: "envoy_publish",
20077
20087
  args: { topic: `notifications.role.${role}`, message: "..." }
@@ -37088,7 +37088,7 @@ var dispatchToolSpecs = [
37088
37088
  {
37089
37089
  name: "dispatch_message",
37090
37090
  example: { issue: "DSP-1", body: "Implementation started." },
37091
- description: "Post a note humans must read now: a reply to a human's message or a deliverable that landed. A to-do only a human can " + "complete is an ask (dispatch ask), so it reaches their inbox. Never progress or status updates - Dispatch is a high-signal " + "record, not a log. Not a design decision (write it as a decision block in the document) 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. Another call with the same in_reply_to and new text posts a follow-up, " + "threaded under this session's first reply; the same text again posts nothing. dispatch read --message <id> reads " + "that conversation back. " + "Every other message names its issue; keep --issue whenever the frame you answer gave one. A BTW, Aside or Steer " + "frame carries a reply_with command: answer with it, on that issue. On Oh My Pi an Agents-page Send or Aside " + "arrives as a user message: answer in the conversation, with no dispatch message. " + `${BODY_BLOCK_RULE} The lines images appends count toward it. ${ISSUE_REFERENCE}`,
37091
+ description: "Post a note humans must read now: a reply to a human's message or a deliverable that landed. A to-do only a human can " + "complete is an ask (dispatch ask), so it reaches their inbox. Never progress or status updates - Dispatch is a high-signal " + "record, not a log. Not a design decision (write it as a decision block in the document) 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. Another call with the same in_reply_to and new text posts a follow-up, " + "threaded under this session's first reply; the same text again posts nothing. dispatch read --message <id> reads " + "that conversation back. " + "Every other message names its issue; keep --issue whenever the frame you answer gave one. A frame reading expects_reply: optional or " + "required carries a reply_with command: answer with it, on that issue. One reading false carries none and asks for no answer; where " + "you answer one anyway, its card names what to answer. On Oh My Pi an Agents-page Send or Aside " + "arrives as a user message: answer in the conversation, with no dispatch message. " + `${BODY_BLOCK_RULE} The lines images appends count toward it. ${ISSUE_REFERENCE}`,
37092
37092
  arguments: (z) => ({
37093
37093
  issue: z.string().describe(`${ISSUE_REFERENCE} Omit it only when in_reply_to answers a human's direct message to this session.`).optional(),
37094
37094
  body: z.string().describe(`Update text. ${BODY_BLOCK_RULE}`),
@@ -38415,6 +38415,7 @@ var DISPATCH_TEXT_REFERENCES = [
38415
38415
  ];
38416
38416
  // ../contracts/src/envelope.ts
38417
38417
  var isSubject = (value) => typeof value === "string" && value.length > 0;
38418
+ var EXPECTS_REPLY_VALUES = [false, "optional", "required"];
38418
38419
  var EnvelopeSchema = object({
38419
38420
  event_id: string2().min(1),
38420
38421
  source: _enum2([
@@ -38448,7 +38449,7 @@ var EnvelopeSchema = object({
38448
38449
  in_reply_to: string2().min(1).optional(),
38449
38450
  supersedes: string2().min(1).optional(),
38450
38451
  urgency: _enum2(["low", "med", "high", "blocking"]).optional(),
38451
- expects_reply: _enum2(["none", "optional", "required"]).optional()
38452
+ expects_reply: literal(EXPECTS_REPLY_VALUES).optional()
38452
38453
  });
38453
38454
  var MINTED_DEDUPE_KEY_PATTERN = "^(?:envoy\\.role\\.forward\\.)?(?:publish|agent\\.[^.]+)\\.(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$";
38454
38455
  var mintedDedupeKey = new RegExp(MINTED_DEDUPE_KEY_PATTERN);
@@ -38586,6 +38587,12 @@ var SLACK_POST_TEXT_MAX = 4000;
38586
38587
  function overCapMessage(length, max) {
38587
38588
  return `is ${length - max} characters over the ${max}-character limit (${length}/${max})`;
38588
38589
  }
38590
+ function literalOfSet(api, values) {
38591
+ const node = api.literal?.(values);
38592
+ if (node === undefined || typeof node.safeParse !== "function")
38593
+ return;
38594
+ return values.every((value) => node.safeParse(value).success) ? node : undefined;
38595
+ }
38589
38596
  function zodSchemaApi(zod) {
38590
38597
  const api = zod;
38591
38598
  return {
@@ -38614,6 +38621,7 @@ function zodSchemaApi(zod) {
38614
38621
  },
38615
38622
  boolean: () => api.boolean(),
38616
38623
  enum: (values) => api.enum(values),
38624
+ literal: (values) => literalOfSet(api, values) ?? api.unknown(),
38617
38625
  unknown: () => api.unknown(),
38618
38626
  array: (item, opts = {}) => {
38619
38627
  let schema = api.array(item);
@@ -43288,6 +43296,7 @@ var InboundSenderSchema = object({
43288
43296
  title: string2().optional(),
43289
43297
  roles: array(string2()).optional()
43290
43298
  });
43299
+ var InboundExpectsReplySchema = literal([...EXPECTS_REPLY_VALUES, "none"]).transform((value) => value === "none" ? false : value);
43291
43300
  var InboundEnvelopeSchema = object({
43292
43301
  event_id: string2().optional(),
43293
43302
  source: string2(),
@@ -43304,7 +43313,7 @@ var InboundEnvelopeSchema = object({
43304
43313
  in_reply_to: string2().optional(),
43305
43314
  supersedes: string2().optional(),
43306
43315
  urgency: string2().optional(),
43307
- expects_reply: string2().optional()
43316
+ expects_reply: InboundExpectsReplySchema.optional()
43308
43317
  }).passthrough();
43309
43318
  function tolerantShape(shape) {
43310
43319
  return Object.fromEntries(Object.entries(shape).map(([key, schema]) => [key, _catch2(schema, undefined)]));
@@ -44011,7 +44020,8 @@ function renderInbound(raw, sessionID, subject, wholeBody) {
44011
44020
  break;
44012
44021
  }
44013
44022
  const role = envelope.sender?.roles?.[0];
44014
- const reply = dispatchReply ?? replyWith(envelope);
44023
+ const wantsNoReply = envelope.expects_reply === false;
44024
+ const reply = wantsNoReply ? undefined : dispatchReply ?? replyWith(envelope);
44015
44025
  const sourceIssue = parseIssues.includes("source") || KNOWN_SOURCES[envelope.source] !== undefined ? undefined : `source=${envelope.source}`;
44016
44026
  const unrecognised = [
44017
44027
  ...parseIssues,
@@ -44030,7 +44040,7 @@ function renderInbound(raw, sessionID, subject, wholeBody) {
44030
44040
  ...inReplyTo === undefined ? {} : { re: inReplyTo },
44031
44041
  ...envelope.supersedes === undefined ? {} : { supersedes: envelope.supersedes },
44032
44042
  ...reply === undefined ? {} : { reply_with: reply },
44033
- ...role === undefined ? {} : {
44043
+ ...role === undefined || wantsNoReply ? {} : {
44034
44044
  reply_role: {
44035
44045
  tool: "envoy_publish",
44036
44046
  args: { topic: `notifications.role.${role}`, message: "..." }
@@ -45957,6 +45967,9 @@ function replayLedgerIdentity(frame) {
45957
45967
  ...frame.dedupe_key === undefined || !dedupeKeyNamesItsEvent(frame) ? {} : { dedupe_key: frame.dedupe_key }
45958
45968
  };
45959
45969
  }
45970
+ var StoredEnvelopeSchema = EnvelopeSchema.extend({
45971
+ expects_reply: InboundExpectsReplySchema.optional()
45972
+ });
45960
45973
  function parseEnvelope(data) {
45961
45974
  let parsed;
45962
45975
  try {
@@ -45964,7 +45977,7 @@ function parseEnvelope(data) {
45964
45977
  } catch {
45965
45978
  return;
45966
45979
  }
45967
- const result = EnvelopeSchema.safeParse(parsed);
45980
+ const result = StoredEnvelopeSchema.safeParse(parsed);
45968
45981
  return result.success ? result.data : undefined;
45969
45982
  }
45970
45983
  function createLedger(now) {
@@ -51811,13 +51824,12 @@ var DELIVERY_CONTRACT = "Delivery is at-least-once, possibly out of order across
51811
51824
  var STORED_DELIVERY = "Stored topics (agent, dispatch, github, slack, legion, ghostwispr, whatsapp; a role message " + "reaches its holder as a stored copy on the holder's agent topic) reach each subscribed session " + "once from the durable consumer its listener relays to it, also one that was away or restarted. " + "Team and other ad-hoc topics are live only.";
51812
51825
  var TOPIC_GUIDE = "Topic guide (all are under notifications.): agent.<session_id> (subscribe: own inbox); role.<role> " + "(publish-to; holders claim via envoy_role_set). > matches one or more trailing tokens and does not " + "match the base subject. Envoy registers the concrete base when you subscribe to <subject>.>, so " + "the recommended default remains github.<owner>.<repo>.pr.<n>.>. <owner> and <repo> are one token " + "each, with every dot in the name written _ (sjawhar/.github is github.sjawhar._github; " + "acme/site.io is github.acme.site_io), and a topic spelled with the dot receives nothing. " + "Default PR subscription: " + "github.<owner>.<repo>.pr.<n>.> (it receives the quiet PR family): pr.<n> (lifecycle: " + "opened/synchronize/closed; closed carries merged, merge_commit_sha, merged_by, head_sha), " + "pr.<n>.comment, pr.<n>.review, pr.<n>.mention, pr.<n>.checks (one settlement event per commit whose checks settle, the head or not, naming its sha: " + 'passed/failed/cancelled/skipped with failing names and URLs; re-fires with superseded_settlement: "true" ' + "when new runs appear for the same commit). Other GitHub: issue.<n>, issue.<n>.comment, " + "issue.<n>.mention, mention, push.branch.<name>, push.tag.<name>, workflow.<file>.<action> (only " + "runs without an associated PR); slack.<team>.<channel>.message|mention and " + "slack.<team>.<channel>.thread.<ts>.message|mention; ghostwispr.<session>.<kind>; " + "whatsapp.<phone>.<jid>.<kind>; envoy.exceptions.<original-topic>.";
51813
51826
  var URGENCY_VALUES = EnvelopeSchema.shape.urgency.unwrap().options;
51814
- var EXPECTS_REPLY_VALUES = EnvelopeSchema.shape.expects_reply.unwrap().options;
51815
51827
  function messageMetadataShape(schema) {
51816
51828
  return {
51817
51829
  in_reply_to: schema.string().optional(),
51818
51830
  supersedes: schema.string().optional(),
51819
51831
  urgency: schema.enum(URGENCY_VALUES).describe("low | med | high | blocking: how soon the recipient should act, shown on its card. It does not deliver the message sooner or ahead of others.").optional(),
51820
- expects_reply: schema.enum(EXPECTS_REPLY_VALUES).describe("none | optional | required").optional(),
51832
+ expects_reply: schema.literal(EXPECTS_REPLY_VALUES).describe("false | optional | required").optional(),
51821
51833
  expires_at: schema.number({ int: true }).optional()
51822
51834
  };
51823
51835
  }
@@ -52131,7 +52143,7 @@ var legionPendingStatusWrite = object({
52131
52143
  var legionStrandedClaim = object({
52132
52144
  issue: issueKey,
52133
52145
  role: _enum2(LEGION_ROLES),
52134
- state: _enum2(["suspended", "failed"])
52146
+ state: _enum2(["suspended", "failed", "retired"])
52135
52147
  });
52136
52148
  var legionHeldOutboxRow = object({
52137
52149
  id: number2().int().positive(),
@@ -52365,7 +52377,10 @@ var legionDeliveryView = object({
52365
52377
  queuedAt: timestamp,
52366
52378
  deliveredAt: timestamp.optional(),
52367
52379
  confirmedAt: timestamp.optional(),
52368
- interrupted: literal(true).optional()
52380
+ interrupted: literal(true).optional(),
52381
+ sendFailures: number2().int().nonnegative().optional(),
52382
+ lastSendFailureAt: timestamp.optional(),
52383
+ lastSendError: nonEmptyString.optional()
52369
52384
  });
52370
52385
  var LegionOperatorClaimResponse = object({
52371
52386
  token: nonEmptyString,
@@ -55176,9 +55191,9 @@ var import_nats2 = __toESM(require_mod4(), 1);
55176
55191
 
55177
55192
  // package.json
55178
55193
  var name = "@sjawhar/claude-envoy";
55179
- var version2 = "1.14.0";
55194
+ var version2 = "1.15.0";
55180
55195
  var legion = {
55181
- daemonApiVersion: 55
55196
+ daemonApiVersion: 56
55182
55197
  };
55183
55198
 
55184
55199
  // src/channel-forwarder.ts
@@ -57508,7 +57523,7 @@ function createChannelDelivery(input) {
57508
57523
  dedupe_key: envelope?.dedupe_key,
57509
57524
  urgency: envelope?.urgency,
57510
57525
  from_session: envelope?.source_session,
57511
- expects_reply: envelope?.expects_reply,
57526
+ expects_reply: envelope?.expects_reply?.toString(),
57512
57527
  in_reply_to: envelope?.in_reply_to
57513
57528
  })
57514
57529
  }
@@ -14438,7 +14438,7 @@ var dispatchToolSpecs = [
14438
14438
  {
14439
14439
  name: "dispatch_message",
14440
14440
  example: { issue: "DSP-1", body: "Implementation started." },
14441
- description: "Post a note humans must read now: a reply to a human's message or a deliverable that landed. A to-do only a human can " + "complete is an ask (dispatch ask), so it reaches their inbox. Never progress or status updates - Dispatch is a high-signal " + "record, not a log. Not a design decision (write it as a decision block in the document) 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. Another call with the same in_reply_to and new text posts a follow-up, " + "threaded under this session's first reply; the same text again posts nothing. dispatch read --message <id> reads " + "that conversation back. " + "Every other message names its issue; keep --issue whenever the frame you answer gave one. A BTW, Aside or Steer " + "frame carries a reply_with command: answer with it, on that issue. On Oh My Pi an Agents-page Send or Aside " + "arrives as a user message: answer in the conversation, with no dispatch message. " + `${BODY_BLOCK_RULE} The lines images appends count toward it. ${ISSUE_REFERENCE}`,
14441
+ description: "Post a note humans must read now: a reply to a human's message or a deliverable that landed. A to-do only a human can " + "complete is an ask (dispatch ask), so it reaches their inbox. Never progress or status updates - Dispatch is a high-signal " + "record, not a log. Not a design decision (write it as a decision block in the document) 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. Another call with the same in_reply_to and new text posts a follow-up, " + "threaded under this session's first reply; the same text again posts nothing. dispatch read --message <id> reads " + "that conversation back. " + "Every other message names its issue; keep --issue whenever the frame you answer gave one. A frame reading expects_reply: optional or " + "required carries a reply_with command: answer with it, on that issue. One reading false carries none and asks for no answer; where " + "you answer one anyway, its card names what to answer. On Oh My Pi an Agents-page Send or Aside " + "arrives as a user message: answer in the conversation, with no dispatch message. " + `${BODY_BLOCK_RULE} The lines images appends count toward it. ${ISSUE_REFERENCE}`,
14442
14442
  arguments: (z) => ({
14443
14443
  issue: z.string().describe(`${ISSUE_REFERENCE} Omit it only when in_reply_to answers a human's direct message to this session.`).optional(),
14444
14444
  body: z.string().describe(`Update text. ${BODY_BLOCK_RULE}`),
@@ -15765,6 +15765,7 @@ var DISPATCH_TEXT_REFERENCES = [
15765
15765
  ];
15766
15766
  // ../contracts/src/envelope.ts
15767
15767
  var isSubject = (value) => typeof value === "string" && value.length > 0;
15768
+ var EXPECTS_REPLY_VALUES = [false, "optional", "required"];
15768
15769
  var EnvelopeSchema = object({
15769
15770
  event_id: string2().min(1),
15770
15771
  source: _enum2([
@@ -15798,7 +15799,7 @@ var EnvelopeSchema = object({
15798
15799
  in_reply_to: string2().min(1).optional(),
15799
15800
  supersedes: string2().min(1).optional(),
15800
15801
  urgency: _enum2(["low", "med", "high", "blocking"]).optional(),
15801
- expects_reply: _enum2(["none", "optional", "required"]).optional()
15802
+ expects_reply: literal(EXPECTS_REPLY_VALUES).optional()
15802
15803
  });
15803
15804
  var MINTED_DEDUPE_KEY_PATTERN = "^(?:envoy\\.role\\.forward\\.)?(?:publish|agent\\.[^.]+)\\.(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$";
15804
15805
  var mintedDedupeKey = new RegExp(MINTED_DEDUPE_KEY_PATTERN);
@@ -15936,6 +15937,12 @@ var SLACK_POST_TEXT_MAX = 4000;
15936
15937
  function overCapMessage(length, max) {
15937
15938
  return `is ${length - max} characters over the ${max}-character limit (${length}/${max})`;
15938
15939
  }
15940
+ function literalOfSet(api, values) {
15941
+ const node = api.literal?.(values);
15942
+ if (node === undefined || typeof node.safeParse !== "function")
15943
+ return;
15944
+ return values.every((value) => node.safeParse(value).success) ? node : undefined;
15945
+ }
15939
15946
  function zodSchemaApi(zod) {
15940
15947
  const api = zod;
15941
15948
  return {
@@ -15964,6 +15971,7 @@ function zodSchemaApi(zod) {
15964
15971
  },
15965
15972
  boolean: () => api.boolean(),
15966
15973
  enum: (values) => api.enum(values),
15974
+ literal: (values) => literalOfSet(api, values) ?? api.unknown(),
15967
15975
  unknown: () => api.unknown(),
15968
15976
  array: (item, opts = {}) => {
15969
15977
  let schema = api.array(item);
@@ -18333,6 +18341,7 @@ var InboundSenderSchema = object({
18333
18341
  title: string2().optional(),
18334
18342
  roles: array(string2()).optional()
18335
18343
  });
18344
+ var InboundExpectsReplySchema = literal([...EXPECTS_REPLY_VALUES, "none"]).transform((value) => value === "none" ? false : value);
18336
18345
  var InboundEnvelopeSchema = object({
18337
18346
  event_id: string2().optional(),
18338
18347
  source: string2(),
@@ -18349,7 +18358,7 @@ var InboundEnvelopeSchema = object({
18349
18358
  in_reply_to: string2().optional(),
18350
18359
  supersedes: string2().optional(),
18351
18360
  urgency: string2().optional(),
18352
- expects_reply: string2().optional()
18361
+ expects_reply: InboundExpectsReplySchema.optional()
18353
18362
  }).passthrough();
18354
18363
  function tolerantShape(shape) {
18355
18364
  return Object.fromEntries(Object.entries(shape).map(([key, schema]) => [key, _catch2(schema, undefined)]));
@@ -19056,7 +19065,8 @@ function renderInbound(raw, sessionID, subject, wholeBody) {
19056
19065
  break;
19057
19066
  }
19058
19067
  const role = envelope.sender?.roles?.[0];
19059
- const reply = dispatchReply ?? replyWith(envelope);
19068
+ const wantsNoReply = envelope.expects_reply === false;
19069
+ const reply = wantsNoReply ? undefined : dispatchReply ?? replyWith(envelope);
19060
19070
  const sourceIssue = parseIssues.includes("source") || KNOWN_SOURCES[envelope.source] !== undefined ? undefined : `source=${envelope.source}`;
19061
19071
  const unrecognised = [
19062
19072
  ...parseIssues,
@@ -19075,7 +19085,7 @@ function renderInbound(raw, sessionID, subject, wholeBody) {
19075
19085
  ...inReplyTo === undefined ? {} : { re: inReplyTo },
19076
19086
  ...envelope.supersedes === undefined ? {} : { supersedes: envelope.supersedes },
19077
19087
  ...reply === undefined ? {} : { reply_with: reply },
19078
- ...role === undefined ? {} : {
19088
+ ...role === undefined || wantsNoReply ? {} : {
19079
19089
  reply_role: {
19080
19090
  tool: "envoy_publish",
19081
19091
  args: { topic: `notifications.role.${role}`, message: "..." }
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: design-outsider
3
+ description: |
4
+ Outside reader of a design problem. Reads no file of the repository. Use before you write a
5
+ design: it says where the proposal is stupid, whether the thing should exist at all, what the
6
+ smallest thing that gives the person what they asked for is, and what to delete.
7
+ tools: WebSearch
8
+ color: magenta
9
+ ---
10
+
11
+ You are an outsider to this codebase. The caller gives you a problem statement and the
12
+ repository's design principles, and nothing else. You answer the problem, not the caller.
13
+
14
+ ## What you answer
15
+
16
+ Four questions, in this order, each in your own words:
17
+
18
+ 1. Where is the proposal stupid?
19
+ 2. Should the thing in question exist at all?
20
+ 3. What is the smallest thing that gives the person what they asked for?
21
+ 4. What should be deleted?
22
+
23
+ ## How you answer
24
+
25
+ - Answer from the problem statement and the principles the caller gave you. Say plainly what
26
+ you cannot judge from them, and name what you would need to judge it.
27
+ - Where a standard solution to this problem exists, name it. A web search for how two or three
28
+ well-regarded projects solve the same problem is the one source you can read.
29
+ - Do not agree. A reply that gives the caller's own design back to it is worth nothing, and a
30
+ verdict with no reasoning behind it is worth less.
31
+ - You are a challenge, never a gate and never an approval. The caller answers you in writing,
32
+ and it may decide against you and give its reason. You decide nothing and you block nothing.
33
+
34
+ ## What your tool list does not prevent
35
+
36
+ You hold no tool that reads a file, by design: your value is that you read the problem instead
37
+ of the code that already exists. Two things still reach you, so do not claim otherwise:
38
+
39
+ - A plugin device of the pod stays available, so an indexed workspace can still be read through
40
+ one.
41
+ - A subagent inherits the caller's repository guide, which in this repository imports the whole
42
+ of `AGENTS.md`. So you may already hold that guide and the design principles in it.
43
+
44
+ Where you used either, say so in your answer.
@@ -16,7 +16,7 @@ You own one issue tree from its first decision to the merge that closes it. The
16
16
  - Before you end a turn that waits on a human, open what they must answer: a decision block, a `dispatch ask` or `dispatch request-approval`. A turn that ends with nothing open asks nobody, and the tree stops. Open an ordinary ask on the root issue itself, never a child: only a question a session of this tree opens on the root starts a wait on a person, so an ask on a child starts no wait and leaves the tree standing still. Once you have handed a root question to the human, end your turn: when nothing else of the tree is at work — no other issue of the tree in a phase, the `legion` label still on, and no person or outside session holding the root's Dispatch claim — the root returns its admission slot (`awaiting_person`) while this session stays live. Read the human's comments and replies as they arrive. Their answer gives this live claim a task naming what answered and what is still with them. The same holds once you request approval and `register_gate` that version: the registration hands the spec to the human, so end your turn there too, and the human's review, or a newer version once Dispatch shows the request back on your turn, gives you that task.
17
17
  - Deliver every criterion of the root spec in this tree. Work leaves it only when a root splits, as a `dispatch ask` for the rest: work outside has no owner.
18
18
  - A problem outside the tree is a `dispatch ask`, never an issue you file, because a person decides whether it needs one (`skill://legion-worker/references/findings.md`). A clear defect is the exception. Fix one in code this tree touches or depends on in this tree's pull request. Send any other to the controller, which gives it to the tree that owns that code.
19
- - Before you write a spec or ask a person anything, read the repository's `STRATEGY.md` when it has one, then use a standard best-practice solution; ask only what neither settles, with one recommendation. A design that crosses a Boundary in that file goes to its owner before it is built. Find the standard with a web search and with how two or three well-regarded projects solve the problem. Cite them in a few lines of the spec's `Design`. A design far from the standard is a sign to fix the underlying problem, not to add a workaround.
19
+ - Before you write a spec or ask a person anything, read the repository's `STRATEGY.md` when it has one and the design principles where it states them (`AGENTS.md`, in this repository), then use a standard best-practice solution; ask only what neither settles, with one recommendation. A design that crosses a Boundary in `STRATEGY.md` goes to its owner before it is built. Find the standard with a web search and with how two or three well-regarded projects solve the problem. Cite them in a few lines of the spec's `Design`. A design far from the standard is a sign to fix the underlying problem, not to add a workaround.
20
20
  - Write lifecycle changes with the `legion` tool, which takes Dispatch issue keys (`LEGION-41`). Never apply a label: Legion has none.
21
21
  - Message a phase worker with `envoy_publish` to `notifications.role.<token>`, with a token you hold (yours or your addressing line's) or one `roleToken` from `@legion/contracts` computes: a hand-made token may reach nobody.
22
22
  - Deployment instructions override this skill where they conflict: they are the operator's rules (required checks, deploy commands, code owners, standing roles you may consult, the merge credential).
@@ -31,6 +31,7 @@ You own one issue tree from its first decision to the merge that closes it. The
31
31
  - A child that duplicates another issue and is not yet released is never released: fold it by the duplicate rule of `skill://dispatch-first`. The daemon records a child at its release and re-asserts a status only on an issue it has recorded, so that close stands. A released duplicate goes out with `park_child` instead.
32
32
  - Every child names the running system its criteria are proven on (the command, service or page the tester drives) and the repository skill that drives it. If the repository cannot exercise a criterion end to end, building that path is work of this tree.
33
33
  - The issue's primary document is the root spec: extend it in place, because a second "spec" artifact replaces the human's document. Mark each requirement of it as a `requirement` block, and write the issue's Verification document (`verification.md`) beside it: one `check` block per thing that must be proved, each with `serves` naming the requirements it serves, its `surface` (`browser`, `cli` or `api`), its `run`, its `when` (`before_merge`, `after_merge` or `default_branch`) and its `negative_control`. The checks are the gate every later role reads: the implementer and the tester record a result against each, and the reviewer approves only once every `before_merge` check of the issue has a passing result current at the head. The decomposition's order and the plan's tasks go in the children and the planner's `plan` handoff, not the root spec.
34
+ - Before you write a design, state the problem to `task(agent="design-outsider")` and read its answer with `wait`. The brief holds the problem statement and the design principles, and no design of your own. The four questions are where the proposal is stupid, whether the thing in question should exist at all, what the smallest thing that gives the person what they asked for is, and what to delete. Record the answer in a comment on the issue, or in the issue's `plan.md` where one exists, before any action that states or favours a design, and give your reason where you do not follow it. Run the step once per design: at the first design of an issue, and again when a push-back or a review changes what the design is for. It is a challenge, never a gate.
34
35
  - Read `skill://legion-architect/references/design-gate.md` before you write the root spec and before any edit to it after approval, because any new version closes the gate. It also names the design gate steps your `Legion addressing` line declares, the document each step records, and says that a sub-architect runs none.
35
36
  - File every child of your decomposition before `dispatch request-approval`, and before `register_gate` where your line gates no step: the gate's opening starts your root's own first phase unless Dispatch shows an open child under it, so a child filed after it opens leaves the root working beside its children.
36
37
  - A root architect then ends its turn and releases no child until every gated step is open: a delivered wake shows `design-approved` on the root, and under more than one step `gatedSteps` in your record (the `legion` tool's `read_record`) shows each step `approved`. The daemon starts no phase in the tree before that. A root that waited on a person's answer gets no `design-approved` notice: a resume task saying "this resume is your design-approved wake" is that wake. A sub-architect waits for none and releases its children with `release_children`.
@@ -204,7 +204,10 @@ child that never started is handed over too, carrying no run, whatever held it a
204
204
  waits in the line for a slot and for any unfinished blocker, and its architect starts as a fresh
205
205
  root and registers its own spec. Only a child that finished, was parked or was never released
206
206
  stays. The child stays its own tree; reopening the old root does not take it back. Park a child
207
- with `park_child` before then when its work should stop with the root.
207
+ with `park_child` before then when its work should stop with the root. A root that is parked
208
+ rather than closed hands over only a child at `awaiting_merge` whose pull request is still open,
209
+ since the daemon re-admits no parked root and only that child's own new READY arms the merge the
210
+ park disarmed; park that child too when its merge should wait with the root.
208
211
 
209
212
  If you are that child's architect:
210
213
 
@@ -36,7 +36,7 @@ and 7 (a conflict, the hand-over, the close) is in
36
36
  | `merge-stalled` | GitHub shows the pull request closed, or at another head, while the record still awaits READY's head, and that change's webhook never reached the daemon: read the pull request on GitHub and act on what it shows (`skill://legion-architect/references/merge-notices.md#merge-stalled`). |
37
37
  | `merge-removed` | A person, or GitHub on its own, took the pull request out of the merge queue or turned its auto-merge off at READY's head after the daemon submitted the merge: read the pull request on GitHub and act on the removal's reason (`skill://legion-architect/references/merge-notices.md#merge-removed`). |
38
38
  | `merge-waiting` | GitHub has not completed the merge the daemon submitted within an hour of READY's acceptance, and nothing has been withdrawn: read the pull request on GitHub for what GitHub waits on and ask a person for what no worker can do (`skill://legion-architect/references/merge-notices.md#merge-waiting`). |
39
- | A reply on an ask you follow | Interpret the reply in the issue's design context. Answer in its thread (`dispatch comment --reply-to <comment id>`; under an open ask whose next move is yours, such as the approval request you must revise or hand back, `--reply-to-ask <ask id> --turn agent`, since a default-turn reply hands that request back to the human and a corrected `--summary` is then refused), then adjust the plan or relay it via `envoy_publish` to the responsible worker's role token; scope and product decisions remain with you. |
39
+ | A reply on an ask you follow | Interpret the reply in the issue's design context. Answer in its thread (`dispatch comment --reply-to <comment id>`; under an open ask whose next move is yours, such as the approval request you must revise or hand back, `--reply-to-ask <ask id> --turn agent`, since a default-turn reply hands that request back to the human and a corrected `--summary` is then refused) where the reply carries a question, a disagreement, a correction or a fact you do not have, and whenever you are unsure; an approval, or an answer that settles the question with nothing left to say, draws no comment and no message back to the person who answered. Then adjust the plan or relay the reply via `envoy_publish` to the responsible worker's role token, which an answer needing nothing from you still needs; scope and product decisions remain with you. |
40
40
  | `worker-died` | Its `role` and `phase`. That role's claim failed: its launches or prompts ran out. For a phase worker the daemon holds the issue (phase `held`) and starts nothing more on it. Reassess the work, then decide with `retry_or_escalate`: `retry` when the failure looks agent-specific or transient, `escalate` to hand the held issue to the controller when it looks environmental. |
41
41
  | `held` | Its `role` and `phase` (the phase the issue left), or `phase` with `reason: "escalated"`. Without `reason`, that phase's worker ran out of launches or prompts: the issue is held (phase `held`), the `worker-died` that comes with it is yours to answer with `retry_or_escalate` as that row says, and the controller hears of the hold too. With `reason: "escalated"`, it records your own `escalate`: the controller has the issue now, and you start nothing for it. |
42
42
  | `ready-refused` | Its `version` and `reason`, and the `reason` names the gated step the READY waits on. The merger's READY was refused, and its packet is kept with the completion so no second READY is ever needed. With `version` greater than 0, `reason` reads `READY refused: approve design version <N> of the tree's <step> document before requesting READY.`: that step's document is already registered at that version, so get it approved (`skill://legion-architect/references/design-gate.md`), and the daemon advances the merge and posts the kept packet the moment every gated step is open. With `version` 0, `reason` reads `READY refused: no design version of the tree's <step> document is approved; register and approve it before requesting READY.`: that step is not registered yet, so register it and get it approved (`skill://legion-architect/references/design-gate.md`) exactly as above; the daemon releases the kept packet the same way — at registration itself when that opens the step (a deployment that gates no step, or Dispatch already shows the version approved), otherwise the moment a human approves the version you registered. The refusal names the first closed step, in the order the deployment gates them, and no second READY is asked for however many steps were closed: read `gatedSteps` in your own record (the `legion` tool's `read_record`) for every step still closed, and open each one. Nothing else is needed once every gated step is open. On a root whose catch-up said `carried`, the gate is a copy on the old root's spec document: run nothing of `skill://legion-architect/references/design-gate.md` for that step; tell the controller the READY waits on a human's approval of that document, and `carried-gate-opened` follows the approval. A `reason` starting `READY_PACKET_MISSING` names a READY refused before the daemon kept packets: that READY is void and the issue stays in merging, so start it over (`park_child` then `rerun_child`, for a child) or end it. |
@@ -19,7 +19,7 @@ You work one phase of one issue in a jj workspace every role of the issue shares
19
19
 
20
20
  ## Stop rules
21
21
 
22
- - **A refused completion is information, not a retry loop.** Quote the answer verbatim to the architect, so "my work is lost" and "my work belongs to an ended run" read differently. `HANDOFF_STALE_GENERATION` (the issue is on a newer run) or `HANDOFF_NO_RUN` (your claim holds no task): push nothing further, end the turn without completing, and tell the architect; if the task then arrives and its work and handoff are already done, call `handoff_complete` again in that task's turn, and never redo the work or write a second handoff. `HANDOFF_STALE_ASSIGNMENT` (the issue left your phase and came back while your turn ran, so your work belongs to the assignment before the one the phase hands you now): end the turn without completing and tell the architect, and report that work and that verdict no further. The phase was entered again, so the task it hands you now is work of its own — a testing assignment after a red is for the head the fix pushed, which your verdict on the head before it does not answer for: do that work at the head the task names, write a handoff of its own for it, and complete in its turn. `HANDOFF_NOT_CURRENT_PHASE`: the issue left your phase; report, never complete again. `HANDOFF_NO_PULL_REQUEST` (the implementer's completion found no open pull request on `legion/<KEY>`, and none recorded merged): open it and complete again at the same commit; when the answer names a closed pull request, the architect decides the phase: tell it, and open or reopen nothing until it answers. `HANDOFF_ALREADY_RECORDED`: the first answer stands; if you did not see it, tell the architect. `HANDOFF_SESSION_MISMATCH` names a record another session wrote: read it, record it again from your session with `dispatch handoff-write`, and complete. A reminder is not a task: after an accepted completion, a second call is refused.
22
+ - **A refused completion is information, not a retry loop.** Quote the answer verbatim to the architect, so "my work is lost" and "my work belongs to an ended run" read differently. `HANDOFF_STALE_GENERATION` (the issue is on a newer run) or `HANDOFF_NO_RUN` (the daemon can attribute your completion to no run: your claim holds no task of this issue's current run and phase — no task at all, or one of another run, of another phase, or of no phase — and the answer says which of those it holds): push nothing further, end the turn without completing, and tell the architect; if the task then arrives and its work and handoff are already done, call `handoff_complete` again in that task's turn, and never redo the work or write a second handoff. `HANDOFF_STALE_ASSIGNMENT` (the issue left your phase and came back while your turn ran, so your work belongs to the assignment before the one the phase hands you now): end the turn without completing and tell the architect, and report that work and that verdict no further. The phase was entered again, so the task it hands you now is work of its own — a testing assignment after a red is for the head the fix pushed, which your verdict on the head before it does not answer for: do that work at the head the task names, write a handoff of its own for it, and complete in its turn. `HANDOFF_NOT_CURRENT_PHASE`: the issue left your phase; report, never complete again. `HANDOFF_NO_PULL_REQUEST` (the implementer's completion found no open pull request on `legion/<KEY>`, and none recorded merged): open it and complete again at the same commit; when the answer names a closed pull request, the architect decides the phase: tell it, and open or reopen nothing until it answers. `HANDOFF_ALREADY_RECORDED`: the first answer stands; if you did not see it, tell the architect. `HANDOFF_SESSION_MISMATCH` names a record another session wrote: read it, record it again from your session with `dispatch handoff-write`, and complete. A reminder is not a task: after an accepted completion, a second call is refused.
23
23
  - **A turn that ends with your phase open is reminded**, at the end of every turn for as long as the phase stays open. Answer by completing, or with a WAITING line naming what you wait on, which buys a quiet wait; a turn that ends while background work of your own is still running buys the same quiet with no line at all, since that work wakes you when it finishes. A run of reminders that change nothing tells your architect. Under `runtime: kubernetes` that quiet wait also lets the daemon release your issue's pod, while background work of yours keeps the pod, because your session reports that work to the daemon.
24
24
  - **A released pod is not a stop.** Under `runtime: kubernetes` the daemon releases your issue's pod once no role of it has a turn in progress or a turn due for ten minutes. Your session, your phase, your place in it and the task you hold are all kept, and your unpushed commits and working-copy change are restored into the next pod, so a release costs a pod start and nothing else. A wait that raises no event of its own wakes you at the backstop alone, 60 minutes after the release, so name the wait in your WAITING line and expect that hour. A release keeps nothing else of the disk — `$TMPDIR`, every ignored file, each role's home, a test database and the CodeGraph index all go with the pod — so put work you must keep on the branch, in a tracked file or in Dispatch. The backstop runs only while your phase is open.
25
25
  - **The workspace is read-only for you from the moment your phase ends**, whether you ended it by completing or a CI red took it back while your turn ran: no commit, no `jj new`, no edit to a tracked file, in the turn still running when the message reaches you and in every turn after it, until a new task names your phase. Nothing stops that turn for you, so stopping is yours to do: drop the work in hand and leave whatever you have uncommitted. The active phase owns the workspace, and your session is kept, until your issue leaves the workflow, to answer questions and to take your next assignment.
@@ -56,7 +56,7 @@ Post the evidence first as a `dispatch comment` on the report issue ([Where it g
56
56
  says which), its first line exactly `Clear defect from <your role token>`. It prints
57
57
  `Posted comment <id>`. Then send the controller one message with
58
58
  `envoy_publish` to the controller topic your `Legion addressing` line names, with
59
- `expects_reply: none`. It holds:
59
+ `expects_reply: false`. It holds:
60
60
 
61
61
  - your role token, from your `Legion addressing` line
62
62
  - the defect: what went wrong, where and to whom, with the numbers, and the issue it came from
@@ -139,7 +139,7 @@ answers (`409 ISSUE_CLOSED`). The question names the issue the finding came from
139
139
  When the project has no report issue yet, or a person closed it (`dispatch read` shows `Status:
140
140
  done`, and Dispatch refuses the ask with `409 ISSUE_CLOSED`), do not create or reopen one yourself
141
141
  (the controller does): send the finding to the controller as text, with `envoy_publish` to the
142
- controller topic your `Legion addressing` line names (`expects_reply: none`), holding the problem,
142
+ controller topic your `Legion addressing` line names (`expects_reply: false`), holding the problem,
143
143
  the issue it came from, the evidence and the decision it needs. The controller creates or reopens
144
144
  the report issue and opens the ask. When that publish fails because no controller holds the role,
145
145
  take step 2 or 3 of [When no controller takes it](#when-no-controller-takes-it). A production
@@ -193,7 +193,7 @@ Legion refuses to record an issue on a repository the project does not map, henc
193
193
  The person answers in Dispatch, and the answer reaches only the ask's followers. So once the ask is
194
194
  open, `envoy_publish` its reference, `dispatch://<REPORT KEY>/ask/<id>` (the id from the
195
195
  `Asked <id> on …` line), to the controller topic your `Legion addressing` line names, with
196
- `expects_reply: none`. The controller follows the ask and files what the person's answer asks for;
196
+ `expects_reply: false`. The controller follows the ask and files what the person's answer asks for;
197
197
  you file nothing, whatever the answer says. The controller itself skips this step, since it follows
198
198
  each ask it opens. When the listener kept that publish rather than a holder taking it, do nothing
199
199
  more for an ask on the report issue: the controller's catch-up follows it at its next start.
@@ -313,7 +313,7 @@ reviewing until you finish.
313
313
  turn of the look's task.
314
314
  - **Each look does three things.** Do what the deployment instructions say to reach the deploy:
315
315
  when they name an agent or role for it, `envoy_publish` the merge commit to that role's topic
316
- with `expects_reply: none`, and await no reply. Read whether production carries the merge. Then
316
+ with `expects_reply: false`, and await no reply. Read whether production carries the merge. Then
317
317
  report one of three outcomes:
318
318
  - `pass`, once you drove the changed path in production through the user's own access path and
319
319
  recorded the result: `dispatch result` against the issue's `after_merge` check, at the merge
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/claude-envoy",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Claude Code channel plugin for Legion's Envoy bus",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  "AGENTS.md"
20
20
  ],
21
21
  "legion": {
22
- "daemonApiVersion": 55
22
+ "daemonApiVersion": 56
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",
package/scripts/build.ts CHANGED
@@ -46,12 +46,15 @@ export const BUNDLE_ENTRYPOINTS = {
46
46
  /** Oh My Pi's tool names, as pi-legion's agents list them, to Claude Code's. A tool this Claude
47
47
  * Code build lacks maps to nothing: it has no Glob or Grep tool (an agent searches with `rg` or
48
48
  * `fd` through Bash where it has Bash), and its own task list stands in for `todo`. A name in
49
- * neither list is refused, so a new tool is mapped on purpose. */
49
+ * neither list is refused, so a new tool is mapped on purpose. `web_search` reads no file, and
50
+ * it is here because an agent that must read no file of the repository still needs one tool
51
+ * Claude Code has: `claudeAgent` refuses a definition whose every tool maps to nothing. */
50
52
  export const CLAUDE_TOOL_NAMES: Readonly<Record<string, string | null>> = {
51
53
  read: "Read",
52
54
  bash: "Bash",
53
55
  edit: "Edit",
54
56
  write: "Write",
57
+ web_search: "WebSearch",
55
58
  glob: null,
56
59
  grep: null,
57
60
  todo: null,
@@ -11,15 +11,15 @@ Envoy delivers external events and messages between sessions. Delivery is at-lea
11
11
 
12
12
  ## Send a message to another session
13
13
 
14
- - Take the target id from the delivery's `reply_with`, or from `envoy_sessions` (filter by `machine`, `dir` or `title`). Never address a tmux pane or window: Envoy delivers only to a session id, and the session in a pane can change.
14
+ - Take the target id from the delivery's `reply_with`, or, where the delivery wants no reply and carries none, from its `from`; or from `envoy_sessions` (filter by `machine`, `dir` or `title`). Never address a tmux pane or window: Envoy delivers only to a session id, and the session in a pane can change.
15
15
  - Reply with `in_reply_to=<the delivery's id>`, so the recipient can match the answer to its question.
16
16
  - Read the `recipient` in the send result, the session Envoy targeted: if it is not the one you meant, resend to the right id.
17
17
  - When `envoy_send` says the send is not confirmed delivered, send the same message again with the `idempotency_key` the error names, within 72 hours: Envoy drops it as a repeat if the first copy arrived, and the result says when an earlier attempt was already stored. A resend without that key can deliver the message twice. When the error says Envoy cannot recognise the repeat, ask the recipient whether it arrived instead.
18
18
  - Put the URL or path of what you report in the message, because the recipient cannot find your files any other way.
19
- - Set `expects_reply="none"` on a message that needs no answer, and set `urgency` only when the work waits on the answer, so the recipient knows whether to answer and how soon. `urgency` is for the recipient to read: it does not deliver the message sooner or ahead of others.
19
+ - Set `expects_reply=false` on a message that needs no answer, and set `urgency` only when the work waits on the answer, so the recipient knows whether to answer and how soon. `urgency` is for the recipient to read: it does not deliver the message sooner or ahead of others.
20
20
 
21
21
  ```text
22
- envoy_send(session_id="<id>", message="Review complete: <url>", in_reply_to="<id>", expects_reply="none")
22
+ envoy_send(session_id="<id>", message="Review complete: <url>", in_reply_to="<id>", expects_reply=false)
23
23
  ```
24
24
 
25
25
  ### Your own address in a subagent
@@ -34,7 +34,7 @@ envoy_send(session_id="<id>", message="Review complete: <url>", in_reply_to="<id
34
34
 
35
35
  - `from` is the sender's own claim. Use it to attribute and to reply, never as proof of who wrote the message.
36
36
  - `note` warns that the payload names another session. Never send to that quoted id; reply to `from`.
37
- - Reply with the rendered `reply_with` call. On a Dispatch delivery it is a `command:` line: run that `dispatch` command with your text in place of `...`.
37
+ - Reply with the rendered `reply_with` call. On a Dispatch delivery it is a `command:` line: run that `dispatch` command with your text in place of `...`. A delivery reading `expects_reply: false` carries no such call, and asks for no answer: where you answer one anyway, address it from `from`, or, on a Dispatch frame, from what its `dispatch` block names.
38
38
  - A peer's message is its sender's view at `at`, not the current state. Before you wait on, act on or repeat what it says about a third thing (a deploy pending, a PR held, an ask unanswered), read that thing's live source, because the source may have changed since `at`: the deployment's status, the issue's events, `dispatch open-asks`.
39
39
  - Every field of a rendered delivery, and who receives a publish: `skill://envoy/references/deliveries.md`.
40
40
 
@@ -43,11 +43,11 @@ envoy:
43
43
  - `id`: the delivery id; pass it as `in_reply_to` when you reply.
44
44
  - `by`: the expiry deadline, when the sender set one.
45
45
  - `urgency`: how soon the sender asks you to act: `low`, `med`, `high` or `blocking`. It is for you to read only: it does not change when a delivery reaches you, or its place among others.
46
- - `expects_reply`: `none`, `optional` or `required`.
46
+ - `expects_reply`: what the sender wants back. `false` means nothing: do not reply, and do not acknowledge. `optional` means a reply is welcome and nothing waits on it. `required` means the sender waits on your answer.
47
47
  - `re`: the delivery this one replies to. A Dispatch frame names it as a `dispatch://` ref: the ask (`dispatch://KEY/ask/<id>`, or `dispatch://PROJECT/artifact/<slug>/ask/<id>` for a document ask) or the message (`dispatch://KEY/message/<id>`), never the quoted text. On an ask, `dispatch.question` holds the start of the question's text; `dispatch read` gives the whole.
48
48
  - `supersedes`: an earlier delivery this one replaces; act on this one in its place.
49
- - `reply_with`: the direct-reply call to the sender. On a Dispatch frame it is `command:`, the `dispatch` command line that answers it, with `...` where your text goes.
50
- - `reply_role`: the role-publish reply call, when the sender holds a role.
49
+ - `reply_with`: the direct-reply call to the sender. On a Dispatch frame it is `command:`, the `dispatch` command line that answers it, with `...` where your text goes. It is absent when `expects_reply` is `false`: a delivery that wants nothing back offers no way to send something. Where you answer one anyway, the card still names what you need — a Dispatch frame names its comment, message or ask, and an agent's delivery names its `id` and its sender.
50
+ - `reply_role`: the role-publish reply call, when the sender holds a role. It is absent under `false` for the same reason.
51
51
  - `summary`: the one-line source summary.
52
52
  - `message`: the complete payload; it can hold several paragraphs.
53
53
  - `note`: the payload names another session; never use that quoted id as the recipient.
@@ -476,7 +476,9 @@ export function createChannelDelivery(input: {
476
476
  dedupe_key: envelope?.dedupe_key,
477
477
  urgency: envelope?.urgency,
478
478
  from_session: envelope?.source_session,
479
- expects_reply: envelope?.expects_reply,
479
+ // One vocabulary for the inbox: the inbound schema reads a frame old enough to carry
480
+ // the string "none" as `false`, and the metadata carries that one spelling.
481
+ expects_reply: envelope?.expects_reply?.toString(),
480
482
  in_reply_to: envelope?.in_reply_to,
481
483
  }),
482
484
  },