@sjawhar/claude-envoy 1.14.1 → 1.16.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.
@@ -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);
@@ -38469,6 +38470,7 @@ function dedupeKeyNamesItsEvent(envelope) {
38469
38470
  }
38470
38471
  // ../contracts/src/envoy-session.ts
38471
38472
  var SESSION_HEARTBEAT_MS = 90000;
38473
+ var SESSION_LIVE_WINDOW_MS = 300000;
38472
38474
  var LISTENER_CALL_TIMEOUT_MS = 5000;
38473
38475
  var LISTENER_CALL_RETRY_DELAY_MS = 250;
38474
38476
  var LISTENER_CALL_ATTEMPTS = 2;
@@ -38586,6 +38588,12 @@ var SLACK_POST_TEXT_MAX = 4000;
38586
38588
  function overCapMessage(length, max) {
38587
38589
  return `is ${length - max} characters over the ${max}-character limit (${length}/${max})`;
38588
38590
  }
38591
+ function literalOfSet(api, values) {
38592
+ const node = api.literal?.(values);
38593
+ if (node === undefined || typeof node.safeParse !== "function")
38594
+ return;
38595
+ return values.every((value) => node.safeParse(value).success) ? node : undefined;
38596
+ }
38589
38597
  function zodSchemaApi(zod) {
38590
38598
  const api = zod;
38591
38599
  return {
@@ -38614,6 +38622,7 @@ function zodSchemaApi(zod) {
38614
38622
  },
38615
38623
  boolean: () => api.boolean(),
38616
38624
  enum: (values) => api.enum(values),
38625
+ literal: (values) => literalOfSet(api, values) ?? api.unknown(),
38617
38626
  unknown: () => api.unknown(),
38618
38627
  array: (item, opts = {}) => {
38619
38628
  let schema = api.array(item);
@@ -43288,6 +43297,7 @@ var InboundSenderSchema = object({
43288
43297
  title: string2().optional(),
43289
43298
  roles: array(string2()).optional()
43290
43299
  });
43300
+ var InboundExpectsReplySchema = literal([...EXPECTS_REPLY_VALUES, "none"]).transform((value) => value === "none" ? false : value);
43291
43301
  var InboundEnvelopeSchema = object({
43292
43302
  event_id: string2().optional(),
43293
43303
  source: string2(),
@@ -43304,7 +43314,7 @@ var InboundEnvelopeSchema = object({
43304
43314
  in_reply_to: string2().optional(),
43305
43315
  supersedes: string2().optional(),
43306
43316
  urgency: string2().optional(),
43307
- expects_reply: string2().optional()
43317
+ expects_reply: InboundExpectsReplySchema.optional()
43308
43318
  }).passthrough();
43309
43319
  function tolerantShape(shape) {
43310
43320
  return Object.fromEntries(Object.entries(shape).map(([key, schema]) => [key, _catch2(schema, undefined)]));
@@ -44011,7 +44021,8 @@ function renderInbound(raw, sessionID, subject, wholeBody) {
44011
44021
  break;
44012
44022
  }
44013
44023
  const role = envelope.sender?.roles?.[0];
44014
- const reply = dispatchReply ?? replyWith(envelope);
44024
+ const wantsNoReply = envelope.expects_reply === false;
44025
+ const reply = wantsNoReply ? undefined : dispatchReply ?? replyWith(envelope);
44015
44026
  const sourceIssue = parseIssues.includes("source") || KNOWN_SOURCES[envelope.source] !== undefined ? undefined : `source=${envelope.source}`;
44016
44027
  const unrecognised = [
44017
44028
  ...parseIssues,
@@ -44030,7 +44041,7 @@ function renderInbound(raw, sessionID, subject, wholeBody) {
44030
44041
  ...inReplyTo === undefined ? {} : { re: inReplyTo },
44031
44042
  ...envelope.supersedes === undefined ? {} : { supersedes: envelope.supersedes },
44032
44043
  ...reply === undefined ? {} : { reply_with: reply },
44033
- ...role === undefined ? {} : {
44044
+ ...role === undefined || wantsNoReply ? {} : {
44034
44045
  reply_role: {
44035
44046
  tool: "envoy_publish",
44036
44047
  args: { topic: `notifications.role.${role}`, message: "..." }
@@ -44804,7 +44815,9 @@ var RoleWireSchema = object({
44804
44815
  host: string2().optional(),
44805
44816
  last_seen: number2().int(),
44806
44817
  state: WireStateSchema,
44807
- holder_state: SessionStateSchema.optional()
44818
+ holder_state: SessionStateSchema.optional(),
44819
+ client_instance_id: string2().optional(),
44820
+ handed_back: boolean2().optional()
44808
44821
  });
44809
44822
  var RoleHeldWireSchema = object({
44810
44823
  error: string2(),
@@ -44883,6 +44896,21 @@ function mergeInterestSources(registryTopics, liveTopics) {
44883
44896
  function holderStateOf(role) {
44884
44897
  return role.holder_state ?? role.state;
44885
44898
  }
44899
+ function namedProcess(id) {
44900
+ return id === "" ? undefined : id;
44901
+ }
44902
+ function roleHeldBy(info, sessionID, clientInstanceID) {
44903
+ if (info === undefined || info.holder !== sessionID || info.handed_back === true)
44904
+ return false;
44905
+ const holding = namedProcess(info.client_instance_id);
44906
+ return holding === undefined || holding === namedProcess(clientInstanceID);
44907
+ }
44908
+ function mayActOnRoleRecord(writer, clientInstanceID, holdsRole) {
44909
+ const wrote = namedProcess(writer);
44910
+ if (wrote === undefined || wrote === namedProcess(clientInstanceID))
44911
+ return true;
44912
+ return !holdsRole;
44913
+ }
44886
44914
  function newClientInstanceID() {
44887
44915
  return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0")).join("");
44888
44916
  }
@@ -45078,7 +45106,8 @@ function createEnvoyClient(config) {
45078
45106
  unsubscribe: async (input) => {
45079
45107
  await post("/v1/interests/unsubscribe", {
45080
45108
  session_id: input.sessionID,
45081
- topics: expandSubscriptionTopics(input.topics)
45109
+ topics: expandSubscriptionTopics(input.topics),
45110
+ ...input.clientInstanceID === undefined ? {} : { client_instance_id: input.clientInstanceID }
45082
45111
  }, { actingSession: input.sessionID });
45083
45112
  },
45084
45113
  getInterest: async (sessionID) => InterestWireSchema.parse(JSON.parse(await request(`/v1/interests/${sessionID}`, {}))),
@@ -45148,7 +45177,8 @@ function createEnvoyClient(config) {
45148
45177
  session_id: input.sessionID,
45149
45178
  role: input.role,
45150
45179
  ...soft ? { soft: true } : {},
45151
- ...previous === "" ? {} : { previous_session_id: previous }
45180
+ ...previous === "" ? {} : { previous_session_id: previous },
45181
+ ...input.clientInstanceID === undefined ? {} : { client_instance_id: input.clientInstanceID }
45152
45182
  };
45153
45183
  let raw;
45154
45184
  try {
@@ -45957,6 +45987,9 @@ function replayLedgerIdentity(frame) {
45957
45987
  ...frame.dedupe_key === undefined || !dedupeKeyNamesItsEvent(frame) ? {} : { dedupe_key: frame.dedupe_key }
45958
45988
  };
45959
45989
  }
45990
+ var StoredEnvelopeSchema = EnvelopeSchema.extend({
45991
+ expects_reply: InboundExpectsReplySchema.optional()
45992
+ });
45960
45993
  function parseEnvelope(data) {
45961
45994
  let parsed;
45962
45995
  try {
@@ -45964,7 +45997,7 @@ function parseEnvelope(data) {
45964
45997
  } catch {
45965
45998
  return;
45966
45999
  }
45967
- const result = EnvelopeSchema.safeParse(parsed);
46000
+ const result = StoredEnvelopeSchema.safeParse(parsed);
45968
46001
  return result.success ? result.data : undefined;
45969
46002
  }
45970
46003
  function createLedger(now) {
@@ -51811,13 +51844,12 @@ var DELIVERY_CONTRACT = "Delivery is at-least-once, possibly out of order across
51811
51844
  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
51845
  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
51846
  var URGENCY_VALUES = EnvelopeSchema.shape.urgency.unwrap().options;
51814
- var EXPECTS_REPLY_VALUES = EnvelopeSchema.shape.expects_reply.unwrap().options;
51815
51847
  function messageMetadataShape(schema) {
51816
51848
  return {
51817
51849
  in_reply_to: schema.string().optional(),
51818
51850
  supersedes: schema.string().optional(),
51819
51851
  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(),
51852
+ expects_reply: schema.literal(EXPECTS_REPLY_VALUES).describe("false | optional | required").optional(),
51821
51853
  expires_at: schema.number({ int: true }).optional()
51822
51854
  };
51823
51855
  }
@@ -55179,7 +55211,7 @@ var import_nats2 = __toESM(require_mod4(), 1);
55179
55211
 
55180
55212
  // package.json
55181
55213
  var name = "@sjawhar/claude-envoy";
55182
- var version2 = "1.14.1";
55214
+ var version2 = "1.16.0";
55183
55215
  var legion = {
55184
55216
  daemonApiVersion: 56
55185
55217
  };
@@ -55868,7 +55900,11 @@ async function pruneStaleSessionTitles(stateDirectory) {
55868
55900
  }
55869
55901
 
55870
55902
  // src/session-identity.ts
55871
- var PersistedRole = object({ session_id: string2().min(1), role: string2().min(1) });
55903
+ var PersistedRole = object({
55904
+ session_id: string2().min(1),
55905
+ role: string2().min(1),
55906
+ client_instance_id: string2().min(1).optional()
55907
+ });
55872
55908
 
55873
55909
  class SessionIdentity {
55874
55910
  directory;
@@ -56111,9 +56147,13 @@ async function waitForSavedTranscript(transcript, signal, pollMs = 100) {
56111
56147
  function isFile(file) {
56112
56148
  return stat2(file).then((info) => info.isFile(), () => false);
56113
56149
  }
56150
+ var ROLE_CLAIM_RETRY_MS = 1e4;
56151
+ var ROLE_CLAIM_WAIT_MS = SESSION_LIVE_WINDOW_MS + ROLE_CLAIM_RETRY_MS;
56114
56152
  function startLegionClaim(deps) {
56115
56153
  let capability;
56116
56154
  const { sessionID } = deps;
56155
+ const roleWaitMs = deps.roleWaitMs ?? ROLE_CLAIM_WAIT_MS;
56156
+ const roleRetryMs = deps.roleRetryMs ?? ROLE_CLAIM_RETRY_MS;
56117
56157
  const boot = async () => {
56118
56158
  const transcript = await waitForTranscript(deps.pane.configDir, sessionID, deps.signal, deps.pollMs);
56119
56159
  if (deps.pane.launcherSaves) {
@@ -56132,16 +56172,39 @@ function startLegionClaim(deps) {
56132
56172
  capability = registered;
56133
56173
  },
56134
56174
  claimRole: async (role) => {
56135
- const claimed = await deps.client.setRole({ sessionID, role, soft: true });
56136
- if (!claimed.claimed) {
56137
- throw new Error(`Envoy role ${role} is held by live session ${claimed.holder}`);
56175
+ const deadline = Date.now() + roleWaitMs;
56176
+ let waited = false;
56177
+ for (;; ) {
56178
+ const claimed = await deps.client.setRole({
56179
+ sessionID,
56180
+ role,
56181
+ soft: true,
56182
+ clientInstanceID: deps.clientInstanceID
56183
+ });
56184
+ if (claimed.claimed)
56185
+ break;
56186
+ if (claimed.holder !== sessionID) {
56187
+ throw new Error(`Envoy role ${role} is held by live session ${claimed.holder}`);
56188
+ }
56189
+ const left = deadline - Date.now();
56190
+ if (left <= 0) {
56191
+ throw new Error(`Envoy role ${role} is still claimed under this session by another process after ${roleWaitMs} ms of asking`);
56192
+ }
56193
+ if (!waited) {
56194
+ waited = true;
56195
+ deps.log(`Envoy role ${role} is claimed under this session by the process this pane replaced; asking again every ${roleRetryMs} ms for up to ${roleWaitMs} ms, until nobody has answered for that claim inside the listener's live window`);
56196
+ }
56197
+ await delay(Math.min(roleRetryMs, left), undefined, { signal: deps.signal });
56138
56198
  }
56139
56199
  await deps.session.rememberRole(role);
56140
56200
  },
56141
56201
  onRoleRegained: (listener) => deps.session.onRoleRegained(listener),
56142
56202
  log: deps.log,
56143
56203
  stopping: "stopping the pane so the daemon launches it again",
56144
- stop: deps.stop
56204
+ stop: () => {
56205
+ if (!deps.signal.aborted)
56206
+ deps.stop();
56207
+ }
56145
56208
  });
56146
56209
  };
56147
56210
  const booted = boot().catch((error) => {
@@ -57511,7 +57574,7 @@ function createChannelDelivery(input) {
57511
57574
  dedupe_key: envelope?.dedupe_key,
57512
57575
  urgency: envelope?.urgency,
57513
57576
  from_session: envelope?.source_session,
57514
- expects_reply: envelope?.expects_reply,
57577
+ expects_reply: envelope?.expects_reply?.toString(),
57515
57578
  in_reply_to: envelope?.in_reply_to
57516
57579
  })
57517
57580
  }
@@ -57849,7 +57912,11 @@ async function startChannelSession(options) {
57849
57912
  relay.delivery.setFollowed(registeredTopics());
57850
57913
  return {
57851
57914
  dropping,
57852
- unsubscribing: serialized(() => options.client.unsubscribe({ sessionID: identity.id, topics }))
57915
+ unsubscribing: serialized(() => options.client.unsubscribe({
57916
+ sessionID: identity.id,
57917
+ topics,
57918
+ clientInstanceID: options.clientInstanceID
57919
+ }))
57853
57920
  };
57854
57921
  }
57855
57922
  function registeredTopics() {
@@ -57902,27 +57969,40 @@ async function startChannelSession(options) {
57902
57969
  const result = await registering;
57903
57970
  takeDeliveryAnswer(sessionID, topics, asking, result);
57904
57971
  };
57905
- const restoreRole = async (previousSessionID = identity.id) => {
57972
+ const writeRoleRecord = (file, role) => writePersistedRole(file, {
57973
+ session_id: identity.id,
57974
+ role,
57975
+ client_instance_id: options.clientInstanceID
57976
+ });
57977
+ let roleTransferFrom;
57978
+ const restoreRole = async (previousSessionID) => {
57906
57979
  const previousFile = roleStateFile(options.stateDirectory, previousSessionID);
57907
57980
  const persisted = await readPersistedRole(previousFile);
57908
57981
  if (persisted === undefined)
57909
- return;
57982
+ return "settled";
57983
+ if (!mayActOnRoleRecord(persisted.client_instance_id, options.clientInstanceID, heldRole !== undefined)) {
57984
+ return "settled";
57985
+ }
57910
57986
  const claim = await options.client.setRole({
57911
57987
  sessionID: identity.id,
57912
57988
  role: persisted.role,
57913
57989
  soft: true,
57914
- previousSessionID: persisted.session_id
57990
+ previousSessionID: persisted.session_id,
57991
+ clientInstanceID: options.clientInstanceID
57915
57992
  });
57916
57993
  if (!claim.claimed) {
57994
+ if (claim.holder === identity.id)
57995
+ return "pending";
57917
57996
  if (claim.holder !== persisted.session_id)
57918
57997
  await rm2(previousFile, { force: true });
57919
- return;
57998
+ return "settled";
57920
57999
  }
57921
58000
  heldRole = persisted.role;
57922
58001
  const currentFile = roleStateFile(options.stateDirectory, identity.id);
57923
58002
  if (previousFile !== currentFile)
57924
58003
  await rm2(previousFile, { force: true });
57925
- await writePersistedRole(currentFile, { session_id: identity.id, role: persisted.role });
58004
+ await writeRoleRecord(currentFile, persisted.role);
58005
+ return "settled";
57926
58006
  };
57927
58007
  let regained;
57928
58008
  const fireRegained = (role, reason) => {
@@ -57952,8 +58032,8 @@ async function startChannelSession(options) {
57952
58032
  if (heldRole === undefined)
57953
58033
  return;
57954
58034
  const role = heldRole;
57955
- const holder = await options.client.getRole(role).then((info) => info.holder, undefinedOnNotFound);
57956
- if (holder === identity.id) {
58035
+ const holding = await options.client.getRole(role).catch(undefinedOnNotFound);
58036
+ if (roleHeldBy(holding, identity.id, options.clientInstanceID)) {
57957
58037
  await refreshRoleRecord();
57958
58038
  if (afterOutage)
57959
58039
  fireRegained(role, "reregistered");
@@ -57962,24 +58042,27 @@ async function startChannelSession(options) {
57962
58042
  const result = await options.client.setRole({
57963
58043
  sessionID: identity.id,
57964
58044
  role,
57965
- soft: true
58045
+ soft: true,
58046
+ clientInstanceID: options.clientInstanceID
57966
58047
  });
57967
58048
  if (!result.claimed) {
57968
- heldRole = undefined;
57969
- await rm2(roleStateFile(options.stateDirectory, identity.id), { force: true });
58049
+ if (result.holder !== identity.id) {
58050
+ heldRole = undefined;
58051
+ await rm2(roleStateFile(options.stateDirectory, identity.id), { force: true });
58052
+ return;
58053
+ }
58054
+ await refreshRoleRecord();
57970
58055
  return;
57971
58056
  }
57972
58057
  fireRegained(role, "reclaimed");
57973
58058
  };
57974
- let roleTransferFrom;
57975
58059
  let roleTransferFailureReported = false;
57976
58060
  const transferRole = async () => {
57977
58061
  const from = roleTransferFrom;
57978
58062
  if (from === undefined)
57979
58063
  return;
57980
58064
  try {
57981
- await restoreRole(from);
57982
- roleTransferFrom = undefined;
58065
+ roleTransferFrom = await restoreRole(from) === "pending" ? from : undefined;
57983
58066
  roleTransferFailureReported = false;
57984
58067
  } catch (error) {
57985
58068
  if (!roleTransferFailureReported) {
@@ -58044,7 +58127,11 @@ async function startChannelSession(options) {
58044
58127
  const drifted = registry.topics.filter((topic) => topic !== directSubject && !topic.startsWith(ROLE_TOPIC_PREFIX) && !followed.has(topic));
58045
58128
  if (drifted.length === 0)
58046
58129
  return;
58047
- await options.client.unsubscribe({ sessionID: identity.id, topics: drifted });
58130
+ await options.client.unsubscribe({
58131
+ sessionID: identity.id,
58132
+ topics: drifted,
58133
+ clientInstanceID: options.clientInstanceID
58134
+ });
58048
58135
  };
58049
58136
  let heartbeatInFlight = false;
58050
58137
  let heartbeatRequested = false;
@@ -58133,17 +58220,19 @@ async function startChannelSession(options) {
58133
58220
  await options.connection.flush();
58134
58221
  await serialized(async () => {
58135
58222
  await register();
58136
- await restoreRole().catch((error) => {
58223
+ try {
58224
+ roleTransferFrom = await restoreRole(identity.id) === "pending" ? identity.id : undefined;
58225
+ } catch (error) {
58137
58226
  if (error instanceof UnparseableRoleRecordError) {
58138
58227
  process.stderr.write(`envoy-channel: ${messageFor(error)}; the session starts without a role
58139
58228
  `);
58140
- return;
58141
- }
58142
- roleTransferFrom = identity.id;
58143
- roleTransferFailureReported = true;
58144
- process.stderr.write(`envoy-channel: could not restore the role (${messageFor(error)}); the next heartbeat retries
58229
+ } else {
58230
+ roleTransferFrom = identity.id;
58231
+ roleTransferFailureReported = true;
58232
+ process.stderr.write(`envoy-channel: could not restore the role (${messageFor(error)}); the next heartbeat retries
58145
58233
  `);
58146
- });
58234
+ }
58235
+ }
58147
58236
  });
58148
58237
  triggerReplay();
58149
58238
  for (const prune of [
@@ -58218,10 +58307,7 @@ async function startChannelSession(options) {
58218
58307
  },
58219
58308
  async rememberRole(role) {
58220
58309
  heldRole = role;
58221
- await writePersistedRole(roleStateFile(options.stateDirectory, identity.id), {
58222
- session_id: identity.id,
58223
- role
58224
- });
58310
+ await writeRoleRecord(roleStateFile(options.stateDirectory, identity.id), role);
58225
58311
  },
58226
58312
  async shutdown() {
58227
58313
  if (shuttingDown)
@@ -58320,7 +58406,11 @@ async function executeEnvoyTool(runtime, name, input) {
58320
58406
  }
58321
58407
  case EnvoyToolOperation.setRole: {
58322
58408
  const args = parseArguments(spec, input);
58323
- const result = await runtime.client.setRole({ sessionID: identity.id, role: args.role });
58409
+ const result = await runtime.client.setRole({
58410
+ sessionID: identity.id,
58411
+ role: args.role,
58412
+ clientInstanceID: runtime.clientInstanceID
58413
+ });
58324
58414
  if (!result.claimed)
58325
58415
  throw new Error(`role ${args.role} is held by ${result.holder}`);
58326
58416
  await runtime.session.rememberRole(args.role);
@@ -58534,13 +58624,14 @@ async function runEnvoyChannelServer() {
58534
58624
  ...configuredValue(overrideSessionId) === undefined ? { handoffPid: process.ppid } : {}
58535
58625
  });
58536
58626
  }
58537
- runtime = { identity, client, session };
58627
+ runtime = { identity, client, session, clientInstanceID };
58538
58628
  if (pane !== undefined && daemon !== undefined && !stopping) {
58539
58629
  claim = startLegionClaim({
58540
58630
  pane,
58541
58631
  sessionID: identity.id,
58542
58632
  daemon,
58543
58633
  client,
58634
+ clientInstanceID,
58544
58635
  session,
58545
58636
  signal: booting.signal,
58546
58637
  log: logLegion,
@@ -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);
@@ -15819,6 +15820,7 @@ function dedupeKeyNamesItsEvent(envelope) {
15819
15820
  }
15820
15821
  // ../contracts/src/envoy-session.ts
15821
15822
  var SESSION_HEARTBEAT_MS = 90000;
15823
+ var SESSION_LIVE_WINDOW_MS = 300000;
15822
15824
  var LISTENER_CALL_TIMEOUT_MS = 5000;
15823
15825
  var LISTENER_CALL_RETRY_DELAY_MS = 250;
15824
15826
  var LISTENER_CALL_ATTEMPTS = 2;
@@ -15936,6 +15938,12 @@ var SLACK_POST_TEXT_MAX = 4000;
15936
15938
  function overCapMessage(length, max) {
15937
15939
  return `is ${length - max} characters over the ${max}-character limit (${length}/${max})`;
15938
15940
  }
15941
+ function literalOfSet(api, values) {
15942
+ const node = api.literal?.(values);
15943
+ if (node === undefined || typeof node.safeParse !== "function")
15944
+ return;
15945
+ return values.every((value) => node.safeParse(value).success) ? node : undefined;
15946
+ }
15939
15947
  function zodSchemaApi(zod) {
15940
15948
  const api = zod;
15941
15949
  return {
@@ -15964,6 +15972,7 @@ function zodSchemaApi(zod) {
15964
15972
  },
15965
15973
  boolean: () => api.boolean(),
15966
15974
  enum: (values) => api.enum(values),
15975
+ literal: (values) => literalOfSet(api, values) ?? api.unknown(),
15967
15976
  unknown: () => api.unknown(),
15968
15977
  array: (item, opts = {}) => {
15969
15978
  let schema = api.array(item);
@@ -18333,6 +18342,7 @@ var InboundSenderSchema = object({
18333
18342
  title: string2().optional(),
18334
18343
  roles: array(string2()).optional()
18335
18344
  });
18345
+ var InboundExpectsReplySchema = literal([...EXPECTS_REPLY_VALUES, "none"]).transform((value) => value === "none" ? false : value);
18336
18346
  var InboundEnvelopeSchema = object({
18337
18347
  event_id: string2().optional(),
18338
18348
  source: string2(),
@@ -18349,7 +18359,7 @@ var InboundEnvelopeSchema = object({
18349
18359
  in_reply_to: string2().optional(),
18350
18360
  supersedes: string2().optional(),
18351
18361
  urgency: string2().optional(),
18352
- expects_reply: string2().optional()
18362
+ expects_reply: InboundExpectsReplySchema.optional()
18353
18363
  }).passthrough();
18354
18364
  function tolerantShape(shape) {
18355
18365
  return Object.fromEntries(Object.entries(shape).map(([key, schema]) => [key, _catch2(schema, undefined)]));
@@ -19056,7 +19066,8 @@ function renderInbound(raw, sessionID, subject, wholeBody) {
19056
19066
  break;
19057
19067
  }
19058
19068
  const role = envelope.sender?.roles?.[0];
19059
- const reply = dispatchReply ?? replyWith(envelope);
19069
+ const wantsNoReply = envelope.expects_reply === false;
19070
+ const reply = wantsNoReply ? undefined : dispatchReply ?? replyWith(envelope);
19060
19071
  const sourceIssue = parseIssues.includes("source") || KNOWN_SOURCES[envelope.source] !== undefined ? undefined : `source=${envelope.source}`;
19061
19072
  const unrecognised = [
19062
19073
  ...parseIssues,
@@ -19075,7 +19086,7 @@ function renderInbound(raw, sessionID, subject, wholeBody) {
19075
19086
  ...inReplyTo === undefined ? {} : { re: inReplyTo },
19076
19087
  ...envelope.supersedes === undefined ? {} : { supersedes: envelope.supersedes },
19077
19088
  ...reply === undefined ? {} : { reply_with: reply },
19078
- ...role === undefined ? {} : {
19089
+ ...role === undefined || wantsNoReply ? {} : {
19079
19090
  reply_role: {
19080
19091
  tool: "envoy_publish",
19081
19092
  args: { topic: `notifications.role.${role}`, message: "..." }
@@ -26862,7 +26873,11 @@ async function pruneStaleSessionTitles(stateDirectory) {
26862
26873
  }
26863
26874
 
26864
26875
  // src/session-identity.ts
26865
- var PersistedRole = object({ session_id: string2().min(1), role: string2().min(1) });
26876
+ var PersistedRole = object({
26877
+ session_id: string2().min(1),
26878
+ role: string2().min(1),
26879
+ client_instance_id: string2().min(1).optional()
26880
+ });
26866
26881
 
26867
26882
  class SessionIdentity {
26868
26883
  directory;
@@ -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`.
@@ -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. |