@sjawhar/pi-legion-envoy 5.4.2 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/envoy.js CHANGED
@@ -30520,6 +30520,9 @@ var LEGION_NOTICE_TOPIC_PREFIX = "notifications.legion.";
30520
30520
  function legionNoticeSubject(project, issue2) {
30521
30521
  return `${LEGION_NOTICE_TOPIC_PREFIX}${project}.${issue2}`;
30522
30522
  }
30523
+ function legionControllerNoticeSubject(project) {
30524
+ return legionNoticeSubject(project, "controller");
30525
+ }
30523
30526
  var DISPATCH_TOPIC_PREFIX = "notifications.dispatch.";
30524
30527
  var DISPATCH_ISSUE_TOPIC_PREFIX = `${DISPATCH_TOPIC_PREFIX}issue.`;
30525
30528
  var DISPATCH_DOCUMENT_TOPIC_PREFIX = `${DISPATCH_TOPIC_PREFIX}document.`;
@@ -30549,6 +30552,12 @@ function isLegionRole(role) {
30549
30552
  function isLegionProjectToken(project) {
30550
30553
  return PROJECT_TOKEN.test(project);
30551
30554
  }
30555
+ function legionProjectToken(value, field) {
30556
+ const project = value.toLowerCase().replace(/[^a-z0-9]/g, "");
30557
+ if (!project)
30558
+ throw new Error(`${field} must include at least one alphanumeric character`);
30559
+ return project;
30560
+ }
30552
30561
  function assertLegionProjectToken(project) {
30553
30562
  if (!isLegionProjectToken(project)) {
30554
30563
  throw new Error(`Invalid Legion project token: ${project}`);
@@ -34916,12 +34925,12 @@ async function claimEnvoyRole(sessionID, role, context) {
34916
34925
  throw new Error("Envoy has no bound instance for a role claim");
34917
34926
  await instance.claim(sessionID, role, context);
34918
34927
  }
34919
- async function subscribeLegionNotice(sessionID, topic, context) {
34928
+ async function subscribeLegionNotice(sessionID, topic, context, whileHolding) {
34920
34929
  const bridge = legionRoleClaimBridge();
34921
34930
  const instance = bridge.instances.findLast((candidate) => candidate.sessionID() === sessionID) ?? bridge.instances.at(-1);
34922
34931
  if (instance === undefined)
34923
34932
  throw new Error("Envoy has no bound instance for a notice subscription");
34924
- await instance.subscribe(sessionID, topic, context);
34933
+ await instance.subscribe(sessionID, topic, context, whileHolding);
34925
34934
  }
34926
34935
  function onEnvoyRoleRegained(listener) {
34927
34936
  legionRoleClaimBridge().regained = listener;
@@ -35030,13 +35039,45 @@ var ROLE_CLAIM_ENTRY = "envoy-role-claim";
35030
35039
  var OPEN_ASKS_TIMEOUT_MS = 3000;
35031
35040
  var LEGION_MANAGED_ENTRY = "legion-managed-session";
35032
35041
  var ASK_REMINDER_MESSAGE = "dispatch-ask-reminder";
35033
- var ASK_SELF_CHECK_PROMPT = "Your run has just ended. Answer with exactly one word and nothing else: WAITING or PROCEEDING. " + "WAITING \u2014 you stopped because you need a decision, an approval, or information from a human. " + "PROCEEDING \u2014 you finished, you will carry on by yourself, or you are waiting only on tools, " + "subagents, or events.";
35042
+ var ASK_PROMPT_LINE_SEPARATOR = /\r\n|[\r\n\u0085\u2028\u2029]/;
35043
+ function askPromptQuestionHead(question) {
35044
+ const firstLineEnd = question.search(ASK_PROMPT_LINE_SEPARATOR);
35045
+ const firstLine = firstLineEnd === -1 ? question : question.slice(0, firstLineEnd);
35046
+ let normalized;
35047
+ let segmentStart = 0;
35048
+ for (let index = 0;index < firstLine.length; index += 1) {
35049
+ const codePoint = firstLine.charCodeAt(index);
35050
+ if (codePoint > 31 && (codePoint < 127 || codePoint > 159))
35051
+ continue;
35052
+ normalized ??= [];
35053
+ normalized.push(firstLine.slice(segmentStart, index), " ");
35054
+ segmentStart = index + 1;
35055
+ }
35056
+ return (normalized === undefined ? firstLine : `${normalized.join("")}${firstLine.slice(segmentStart)}`).trim();
35057
+ }
35058
+ var ASK_SELF_CHECK_PROMPT = (asks) => {
35059
+ const askLimit = 5;
35060
+ const questionLimit = 120;
35061
+ const promptAsks = asks.length === 0 ? "There are no open asks in Dispatch." : [
35062
+ "Your open asks in Dispatch:",
35063
+ ...asks.slice(0, askLimit).map((ask) => {
35064
+ const firstLine = askPromptQuestionHead(ask.question);
35065
+ const question = firstLine.length <= questionLimit ? firstLine : `${firstLine.slice(0, questionLimit - 1)}\u2026`;
35066
+ return `- ${question}`;
35067
+ }),
35068
+ ...asks.length > askLimit ? [`+${asks.length - askLimit} more`] : []
35069
+ ].join(`
35070
+ `);
35071
+ return "Your run has just ended. " + `${promptAsks}
35072
+
35073
+ ` + "Are you right now waiting on a human for anything none of those asks covers? " + "Answer with exactly one word and nothing else: WAITING or PROCEEDING. " + "WAITING \u2014 you stopped because you need a decision, an approval, or information from a human. " + "PROCEEDING \u2014 you finished, you will carry on by yourself, or you are waiting only on tools, " + "subagents, or events.";
35074
+ };
35034
35075
  var WAITING_VERDICT = /^\W*WAITING\b/;
35035
35076
  var ECHOED_CHOICE = /\bPROCEEDING\b/;
35036
35077
  var isWaitingVerdict = (reply) => WAITING_VERDICT.test(reply) && !ECHOED_CHOICE.test(reply);
35037
35078
  var ASK_SELF_CHECK_TIMEOUT_MS = 60000;
35038
35079
  var ASK_CHECKS_PER_PERIOD = 5;
35039
- var UNASKED_WAIT_REMINDER = "You just said you are waiting on a human, but you have no open ask in Dispatch, so nobody knows you are waiting. Open it now with dispatch_ask \u2014 or dispatch_request_approval when what you need is approval of a document \u2014 naming exactly what you need and from whom. Do not reply just to acknowledge this reminder.";
35080
+ var UNASKED_WAIT_REMINDER = "You just said you are waiting on a human for something no open ask in Dispatch covers. Open an ask for it now with dispatch_ask (or dispatch_request_approval for a document), naming exactly what you need and from whom.";
35040
35081
  var ASK_OPENING_TOOLS = ["dispatch_ask", "dispatch_request_approval"];
35041
35082
  var DISPATCH_TOOL_PREFIX = "dispatch_";
35042
35083
  function isLegionManagedEntry(entry) {
@@ -35104,6 +35145,9 @@ function envoyExtension(pi) {
35104
35145
  let recordedSessionKey;
35105
35146
  let heartbeatRegistered = false;
35106
35147
  let claimedRoleTopic;
35148
+ let claimedRoleSessionID;
35149
+ const roleNoticeSubjects = new Set;
35150
+ let roleEnds = 0;
35107
35151
  let activeSessionContext;
35108
35152
  const inbox = [];
35109
35153
  let askAwareness = {
@@ -35302,6 +35346,7 @@ function envoyExtension(pi) {
35302
35346
  return;
35303
35347
  if (intentionallyClosed.delete(topic))
35304
35348
  return;
35349
+ const snapshot = roleSnapshot(topic);
35305
35350
  const retry = (delayMs) => {
35306
35351
  awaitingRetry.add(topic);
35307
35352
  setTimeout(() => {
@@ -35310,7 +35355,7 @@ function envoyExtension(pi) {
35310
35355
  return;
35311
35356
  if (intentionallyClosed.delete(topic))
35312
35357
  return;
35313
- subscribe(topic).catch(() => retry(NATS_RETRY_INTERVAL_MS));
35358
+ subscribeUnlessRoleEnds(topic, snapshot).catch(() => retry(NATS_RETRY_INTERVAL_MS));
35314
35359
  }, delayMs);
35315
35360
  };
35316
35361
  retry(RESUBSCRIBE_DELAY_MS);
@@ -35328,10 +35373,42 @@ function envoyExtension(pi) {
35328
35373
  }
35329
35374
  return true;
35330
35375
  };
35376
+ const endRole = () => {
35377
+ claimedRoleTopic = undefined;
35378
+ claimedRoleSessionID = undefined;
35379
+ roleEnds += 1;
35380
+ for (const subject2 of roleNoticeSubjects)
35381
+ closeIntentionally(subject2);
35382
+ roleNoticeSubjects.clear();
35383
+ };
35384
+ const endOutgoingRole = () => {
35385
+ if (claimedRoleSessionID !== sessionID)
35386
+ endRole();
35387
+ };
35388
+ const roleSnapshot = (topic) => ({
35389
+ roleBound: expandSubscriptionTopics([topic]).some((subject2) => roleNoticeSubjects.has(subject2)),
35390
+ endsBefore: roleEnds
35391
+ });
35392
+ const subscribeUnlessRoleEnds = async (topic, snapshot) => {
35393
+ const roleEnded = () => snapshot.roleBound && roleEnds !== snapshot.endsBefore;
35394
+ if (roleEnded())
35395
+ return false;
35396
+ await subscribe(topic);
35397
+ if (!roleEnded())
35398
+ return true;
35399
+ for (const subject2 of expandSubscriptionTopics([topic]))
35400
+ closeIntentionally(subject2);
35401
+ return false;
35402
+ };
35331
35403
  const registerSession = () => client.subscribe({
35332
35404
  sessionID,
35333
35405
  directory: sessionDirectory,
35334
- topics: [...new Set([agentSubject(sessionID), ...subscriptions.keys()])],
35406
+ topics: [
35407
+ ...new Set([
35408
+ agentSubject(sessionID),
35409
+ ...[...subscriptions.keys()].filter((subject2) => !roleNoticeSubjects.has(subject2))
35410
+ ])
35411
+ ],
35335
35412
  port: 0,
35336
35413
  title: activeSessionContext?.sessionManager.getSessionName?.() ?? "",
35337
35414
  capabilities: typeof pi.askEphemeral === "function" ? DELIVERY_CAPABILITIES : CAPABILITIES_WITHOUT_BTW,
@@ -35362,7 +35439,7 @@ function envoyExtension(pi) {
35362
35439
  return;
35363
35440
  }
35364
35441
  if (!result.claimed) {
35365
- claimedRoleTopic = undefined;
35442
+ endRole();
35366
35443
  logger.warn("envoy: role re-assertion refused; held by another live session", {
35367
35444
  role,
35368
35445
  sessionID: id,
@@ -35444,10 +35521,13 @@ function envoyExtension(pi) {
35444
35521
  sessionID,
35445
35522
  holder: result.holder
35446
35523
  });
35447
- claimedRoleTopic = undefined;
35524
+ endRole();
35448
35525
  return false;
35449
35526
  }
35527
+ if (previousTopic !== undefined && previousTopic !== topic)
35528
+ endRole();
35450
35529
  claimedRoleTopic = topic;
35530
+ claimedRoleSessionID = sessionID;
35451
35531
  pi.appendEntry(ROLE_CLAIM_ENTRY, { role });
35452
35532
  if (activeSessionContext !== undefined)
35453
35533
  ensureHeartbeat(activeSessionContext);
@@ -35468,10 +35548,10 @@ function envoyExtension(pi) {
35468
35548
  const roleSources = (previousSessionID, carryPreviousSessionRole) => previousSessionID === "" || previousSessionID === sessionID || !carryPreviousSessionRole ? [sessionID] : [sessionID, previousSessionID];
35469
35549
  const reclaimHeldRoles = async (previousSessionID, branch, carryPreviousSessionRole) => {
35470
35550
  if (!carryPreviousSessionRole)
35471
- claimedRoleTopic = undefined;
35551
+ endOutgoingRole();
35472
35552
  const remembered = transcriptClaimedRole(branch);
35473
35553
  if (remembered === null) {
35474
- claimedRoleTopic = undefined;
35554
+ endRole();
35475
35555
  return;
35476
35556
  }
35477
35557
  let role = remembered ?? claimedRoleTopic?.slice(ROLE_TOPIC_PREFIX.length);
@@ -35498,7 +35578,7 @@ function envoyExtension(pi) {
35498
35578
  const establishSessionNow = async (context, options = {}, previousSessionID = sessionID) => {
35499
35579
  const previousTopic = previousSessionID === "" ? undefined : agentSubject(previousSessionID);
35500
35580
  if (options.carryPreviousSessionRole === false)
35501
- claimedRoleTopic = undefined;
35581
+ endOutgoingRole();
35502
35582
  restoreLocalSessionState(context);
35503
35583
  const branch = context.sessionManager.getBranch?.() ?? [];
35504
35584
  const resumed = branch.length > 0;
@@ -35542,14 +35622,22 @@ function envoyExtension(pi) {
35542
35622
  await registerSession();
35543
35623
  await setEnvoyRole(role);
35544
35624
  };
35545
- const subscribeNotice = async (targetSessionID, topic, callerContext) => {
35625
+ const subscribeNotice = async (targetSessionID, topic, callerContext, whileHolding) => {
35546
35626
  const context = callerContext ?? activeSessionContext;
35547
35627
  if (context === undefined || context.sessionManager.getSessionId() !== targetSessionID) {
35548
35628
  throw new Error(`Envoy has no active session for Legion notice subscription: ${targetSessionID}`);
35549
35629
  }
35550
35630
  if (sessionID !== targetSessionID)
35551
35631
  await establishSession(context);
35552
- await subscribe(topic);
35632
+ if (whileHolding !== undefined) {
35633
+ if (claimedRoleTopic !== ROLE_TOPIC_PREFIX + whileHolding) {
35634
+ throw new Error(`Envoy session ${targetSessionID} does not hold role ${whileHolding} for a notice subscription`);
35635
+ }
35636
+ for (const subject2 of expandSubscriptionTopics([topic]))
35637
+ roleNoticeSubjects.add(subject2);
35638
+ }
35639
+ if (!await subscribeUnlessRoleEnds(topic, roleSnapshot(topic)))
35640
+ return;
35553
35641
  await registerSession();
35554
35642
  };
35555
35643
  const claimInstance = {
@@ -35790,19 +35878,11 @@ function envoyExtension(pi) {
35790
35878
  return;
35791
35879
  if (runSeq !== seenRun)
35792
35880
  return;
35793
- if (open.snapshot.count > 0 || open.snapshot.opened_since) {
35794
- askAwareness = {
35795
- ...askAwareness,
35796
- check_due: false,
35797
- baseline_as_of: open.snapshot.as_of
35798
- };
35799
- return;
35800
- }
35801
35881
  const abort = new AbortController;
35802
35882
  askCheckAbort = abort;
35803
35883
  let answered;
35804
35884
  try {
35805
- answered = askEphemeral({ prompt: ASK_SELF_CHECK_PROMPT, signal: abort.signal }).then((reply) => reply.replyText, (error48) => {
35885
+ answered = askEphemeral({ prompt: ASK_SELF_CHECK_PROMPT(open.snapshot.asks), signal: abort.signal }).then((reply) => reply.replyText, (error48) => {
35806
35886
  if (!abort.signal.aborted)
35807
35887
  logSelfCheckFailure(id, error48);
35808
35888
  return;
package/dist/legion.js CHANGED
@@ -16142,7 +16142,7 @@ import { logger } from "@oh-my-pi/pi-utils";
16142
16142
  // package.json
16143
16143
  var package_default = {
16144
16144
  name: "@sjawhar/pi-legion-envoy",
16145
- version: "5.4.2",
16145
+ version: "5.5.0",
16146
16146
  type: "module",
16147
16147
  omp: {
16148
16148
  extensions: [
@@ -16155,7 +16155,7 @@ var package_default = {
16155
16155
  },
16156
16156
  legion: {
16157
16157
  daemonApiVersion: 8,
16158
- goDaemonApiVersion: 6
16158
+ goDaemonApiVersion: 7
16159
16159
  },
16160
16160
  repository: {
16161
16161
  type: "git",
@@ -30593,6 +30593,9 @@ var LEGION_NOTICE_TOPIC_PREFIX = "notifications.legion.";
30593
30593
  function legionNoticeSubject(project, issue2) {
30594
30594
  return `${LEGION_NOTICE_TOPIC_PREFIX}${project}.${issue2}`;
30595
30595
  }
30596
+ function legionControllerNoticeSubject(project) {
30597
+ return legionNoticeSubject(project, "controller");
30598
+ }
30596
30599
  var DISPATCH_TOPIC_PREFIX = "notifications.dispatch.";
30597
30600
  var DISPATCH_ISSUE_TOPIC_PREFIX = `${DISPATCH_TOPIC_PREFIX}issue.`;
30598
30601
  var DISPATCH_DOCUMENT_TOPIC_PREFIX = `${DISPATCH_TOPIC_PREFIX}document.`;
@@ -30622,6 +30625,12 @@ function isLegionRole(role) {
30622
30625
  function isLegionProjectToken(project) {
30623
30626
  return PROJECT_TOKEN.test(project);
30624
30627
  }
30628
+ function legionProjectToken(value, field) {
30629
+ const project = value.toLowerCase().replace(/[^a-z0-9]/g, "");
30630
+ if (!project)
30631
+ throw new Error(`${field} must include at least one alphanumeric character`);
30632
+ return project;
30633
+ }
30625
30634
  function assertLegionProjectToken(project) {
30626
30635
  if (!isLegionProjectToken(project)) {
30627
30636
  throw new Error(`Invalid Legion project token: ${project}`);
@@ -31234,12 +31243,12 @@ async function claimEnvoyRole(sessionID, role, context) {
31234
31243
  throw new Error("Envoy has no bound instance for a role claim");
31235
31244
  await instance.claim(sessionID, role, context);
31236
31245
  }
31237
- async function subscribeLegionNotice(sessionID, topic, context) {
31246
+ async function subscribeLegionNotice(sessionID, topic, context, whileHolding) {
31238
31247
  const bridge = legionRoleClaimBridge();
31239
31248
  const instance = bridge.instances.findLast((candidate) => candidate.sessionID() === sessionID) ?? bridge.instances.at(-1);
31240
31249
  if (instance === undefined)
31241
31250
  throw new Error("Envoy has no bound instance for a notice subscription");
31242
- await instance.subscribe(sessionID, topic, context);
31251
+ await instance.subscribe(sessionID, topic, context, whileHolding);
31243
31252
  }
31244
31253
  function onEnvoyRoleRegained(listener) {
31245
31254
  legionRoleClaimBridge().regained = listener;
@@ -31429,6 +31438,7 @@ var legionGoIssue = exports_external.strictObject({
31429
31438
  generation: exports_external.number().int().nonnegative(),
31430
31439
  phase: exports_external.enum(LEGION_GO_PHASES),
31431
31440
  status: nonEmptyString2,
31441
+ holdReason: exports_external.literal("escalated").optional(),
31432
31442
  architect: legionGoClaimView.optional(),
31433
31443
  workers: exports_external.partialRecord(exports_external.enum(LEGION_ROLES), legionGoPhaseView),
31434
31444
  pullRequest: legionGoPullRequestView.optional(),
@@ -31765,8 +31775,14 @@ async function callGoReadyWithRetry(label, call) {
31765
31775
  function goControllerDaemon(daemon, transcript) {
31766
31776
  return {
31767
31777
  claim: async ({ sessionID, capability, context }) => {
31768
- const { sessionFile, agentId } = await transcript(context);
31778
+ const project = requiredEnvironment(process.env, "LEGION_PROJECT");
31769
31779
  const client = daemon();
31780
+ const { daemon: served } = await client.state();
31781
+ const servedRole = controllerToken(legionProjectToken(served.project, "the daemon's project"));
31782
+ if (controllerToken(project) !== servedRole) {
31783
+ throw new Error(`LEGION_PROJECT ${project} names controller role ${controllerToken(project)}, but the daemon at ${requiredEnvironment(process.env, "LEGION_DAEMON_URL")} serves project ${served.project}, whose controller role is ${servedRole}`);
31784
+ }
31785
+ const { sessionFile, agentId } = await transcript(context);
31770
31786
  const registration = await client.registerController({
31771
31787
  bootToken: capability,
31772
31788
  sessionId: sessionID,
@@ -31779,7 +31795,12 @@ function goControllerDaemon(daemon, transcript) {
31779
31795
  }
31780
31796
  throw error48;
31781
31797
  });
31782
- await claimEnvoyRole(sessionID, registration.claimToken, "setInterval" in context ? context : undefined);
31798
+ if (controllerToken(project) !== registration.claimToken) {
31799
+ throw new Error(`LEGION_PROJECT ${project} names controller role ${controllerToken(project)}, but the daemon registered this controller as ${registration.claimToken}`);
31800
+ }
31801
+ const envoyContext = "setInterval" in context ? context : undefined;
31802
+ await claimEnvoyRole(sessionID, registration.claimToken, envoyContext);
31803
+ await subscribeLegionNotice(sessionID, legionControllerNoticeSubject(project), envoyContext, registration.claimToken);
31783
31804
  return () => client.controllerGrant({ sessionId: sessionID, secret: registration.secret });
31784
31805
  }
31785
31806
  };
@@ -653,6 +653,14 @@ dispatch_suggest({ issue?, project?, artifact, ref?, quote, replace_with, body?,
653
653
  It returns issue or project-document owner details plus `comment`. A human accepts or rejects a suggestion.
654
654
  Errors: `TARGET_AMBIGUOUS` (add `occurrence`), `TARGET_NOT_FOUND` (re-read first), `INVALID_ANCHOR`/`ANCHOR_MISSING`/`ANCHOR_ORPHANED`
655
655
  (bad, unwritten, or stale quote), `INVALID_MARKDOWN`/`DOC_SCHEMA` (malformed content), `CAP_EXCEEDED`, `ISSUE_CLOSED`.
656
+ Suggest only what the quoted block can hold. The accept, not the suggestion, checks that: an
657
+ accept whose `replace_with` would break an ask block that was readable before it is refused with
658
+ `INVALID_ASK_BLOCK` - a question given a code block, text after an ask's options, a second option
659
+ list, or an emptied question; one that names an id the document holds outside the text it
660
+ replaces, an ask under a held id included, with `INVALID_MARKDOWN`; and one no part of the
661
+ document can hold where it sits (a code block over a table cell's whole text), or whose quote runs
662
+ into an ask or callout from the text before it, with `INVALID_OP`. Either changes nothing: the human sees the reason with no Retry, and the suggestion
663
+ stays open until someone rejects or replaces it.
656
664
 
657
665
  ## Artifacts
658
666
 
@@ -95,6 +95,15 @@ targeted. Reply through the rendered `reply_with` (or a current Envoy session ID
95
95
  and go stale. Put the artefact URL in the message itself. FYIs set `expects_reply="none"`; set
96
96
  `urgency` only when it is genuinely urgent.
97
97
 
98
+ **A peer's message is its sender's view at `at`, not the current state.** Before you wait on, act
99
+ on, or repeat a fact a message carries about a third thing (a deploy pending, a PR held, an ask
100
+ unanswered), re-read it at the live source the fact names, and always once it is over an hour
101
+ old: the deployment's status, the issue's event log, the ask's own state (`dispatch_open_asks`,
102
+ whose description already says to call it before saying you are waiting on a human). On
103
+ 2026-09-26 a peer's 05:20Z "needs a manual deploy before I can run it" was false by 05:22Z, when
104
+ the platform had deployed on its own; waiting on the message instead of the deployment's status
105
+ held the work it gated for eleven hours.
106
+
98
107
  Every `/v1` error response is JSON; when a field is at fault, `expected` names that field.
99
108
 
100
109
  ```text
@@ -71,9 +71,11 @@ launcher, and started you with `LEGION_CONTROLLER=1` and the same environment a
71
71
  carries, so nothing changes in how you handle wakes. Under the TypeScript daemon the extension
72
72
  claims the role and calls `/controller/ready` exactly as under tmux; under the Go daemon
73
73
  (`LEGION_DAEMON_API=go` in your environment) it registers on `/legion/v1/claims/register` with the
74
- secret, then claims the role. The daemon records you as `controllerLocator: {runtime, external:
75
- true, sessionId, registeredAt}`, `runtime` being the daemon's own (`kubernetes`, or `tmux` under the
76
- Go daemon). The TypeScript daemon reads your liveness from the Envoy role registry (the holder of
74
+ secret, claims the role, then subscribes to `notifications.legion.<project>.controller`, where the
75
+ Go daemon publishes the two Go rows of the wake routing table. The daemon records you as
76
+ `controllerLocator: {runtime, external: true, sessionId, registeredAt}`, `runtime` being the
77
+ daemon's own (`kubernetes`, or `tmux` under the Go daemon). The TypeScript daemon reads your
78
+ liveness from the Envoy role registry (the holder of
77
79
  `legion-<project>-controller` and its `last_seen`), not from a pane: keep the session running.
78
80
  Exiting it leaves the project without a controller until the operator runs the command again —
79
81
  the TypeScript daemon logs `controller not registered; run legion controller start` once per
@@ -81,6 +83,20 @@ boot-timeout interval and launches nothing itself. `legion state` and `legion st
81
83
  <status>` work here over `LEGION_DAEMON_URL`. A second `legion controller start` replaces you: it
82
84
  mints a new secret, so your grants stop working and the role moves to the new session.
83
85
 
86
+ ### What happened before you started (Go daemon)
87
+
88
+ The Go daemon's controller topic is a wake for a session that is running when it is published.
89
+ Envoy hands an Oh My Pi session no retained copy of a notice published before it subscribed, so a
90
+ hold or a tree architect's failed claim from while no controller ran never arrives as a wake. At
91
+ every start, before anything else, read `legion state --json` and handle each issue whose
92
+ `issues.<KEY>.phase` is `held` (its `issues.<KEY>.holdReason` is `escalated` when its architect
93
+ sent it to you, and absent while the architect is still deciding or while its tree lingers or is
94
+ closed, where the hold waits for the tree's re-admission and needs nothing from you), and each
95
+ tree root whose `issues.<KEY>.architect.state` is `failed` and whose `issues.<KEY>.phase` is not
96
+ `done`, exactly as the matching wake below. A parked tree (root phase `done`: it lingers or is
97
+ closed) needs nothing from you: a failed architect ignores the park and reads `failed` until the
98
+ tree closes. The issue record is the truth; the topic is the wake.
99
+
84
100
  ## Deployment instructions
85
101
 
86
102
  Deployment instructions, when present, are the operator's standing rules for this repository —
@@ -116,6 +132,8 @@ quoted here.
116
132
  | Mention | Slack/GitHub PR @mention text | Answer, or route to the owning issue's architect role |
117
133
  | READY packet seen on a Dispatch issue (via issue subscription) | READY line + gate facts | No action: a human merges; the merger has already notified the queue role if the project has one |
118
134
  | `worker-recovered` (role `architect`) from the daemon | issue, fromRef | A root architect's tree volume was lost; it restarted as a new session. Verify the tree is active in `legion state` and that the architect posts its next step on the issue within one resync interval; otherwise treat it as an anomaly. |
135
+ | `held on <KEY>` from the Go daemon (payload `{kind: "held", phase, role?, reason?}`) | the held issue, the phase it left, and the role whose claim failed, or `reason: "escalated"` | Verify the hold in `legion state` (the issue's phase is `held`). Without `reason`, a phase worker's launches or prompts ran out and the tree's architect decides retry or escalate: no action. With `reason: "escalated"` (on the record, `issues.<KEY>.holdReason` is `escalated`), the architect sent it to you: handle it as an architect escalation below. Parking the tree is `legion status <root> backlog`; setting the root back to `todo` later re-admits it as a new generation, which starts again from its architect |
136
+ | `worker-died on <KEY>` from the Go daemon with `role: "architect"` | the tree root whose architect's claim failed, and the phase the root was in | The tree's architect ran out of launches or prompts and the daemon relaunches nothing; every other notice of the tree goes to that architect, so nobody inside the tree can act. Verify in `legion state` (`issues.<KEY>.architect.state` is `failed`); if the root's phase is `done`, the tree is already parked: no action. Otherwise re-admit the tree (`legion status <root> backlog`, then `todo`: a new generation, whose architect starts again with fresh budgets) or leave it parked and say why on the issue |
119
137
  | Closed-tree activity (comment, review, CI on a closed tree) | issue, root, event summary | Read the artifact; if work should resume, `legion status <root> todo`; otherwise no action — the event is not held or redelivered |
120
138
  | Direct user message | — | Always first |
121
139
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion-envoy",
3
- "version": "5.4.2",
3
+ "version": "5.5.0",
4
4
  "type": "module",
5
5
  "omp": {
6
6
  "extensions": [
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "legion": {
15
15
  "daemonApiVersion": 8,
16
- "goDaemonApiVersion": 6
16
+ "goDaemonApiVersion": 7
17
17
  },
18
18
  "repository": {
19
19
  "type": "git",