@sjawhar/pi-legion-envoy 5.4.1 → 5.4.3

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}`);
@@ -34509,7 +34518,7 @@ var envoyToolSpecs = [
34509
34518
  },
34510
34519
  {
34511
34520
  name: "envoy_whoami",
34512
- description: "Returns this session's Envoy identity: session ID, machine ID, port, and directory.",
34521
+ description: "Returns this session's Envoy identity: session ID, machine ID, port, and directory. session_id is the address a reply reaches. Where a host runs a task subagent inside its parent's process, such a subagent registers no Envoy session of its own, so its session_id is the parent session that spawned it and the result says so.",
34513
34522
  arguments: () => ({}),
34514
34523
  operation: EnvoyToolOperation.whoami,
34515
34524
  requiresSubscriptionCapability: false
@@ -34706,7 +34715,7 @@ function createEnvoyClient(config2) {
34706
34715
  const idempotencyKey = input.idempotencyKey ?? crypto.randomUUID();
34707
34716
  const response = EnvelopeResponseSchema.parse(JSON.parse(await post("/v1/messages/send", {
34708
34717
  source: input.source ?? "agent",
34709
- ...input.sourceSessionID === undefined ? {} : { source_session: input.sourceSessionID },
34718
+ ...sourceSession(input.sourceSessionID),
34710
34719
  target_session: input.targetSessionID,
34711
34720
  message: input.message,
34712
34721
  idempotency_key: idempotencyKey,
@@ -34722,7 +34731,7 @@ function createEnvoyClient(config2) {
34722
34731
  const idempotencyKey = input.idempotencyKey ?? crypto.randomUUID();
34723
34732
  const response = EnvelopeResponseSchema.parse(JSON.parse(await post("/v1/messages/publish", {
34724
34733
  source: input.source ?? "agent",
34725
- ...input.sourceSessionID === undefined ? {} : { source_session: input.sourceSessionID },
34734
+ ...sourceSession(input.sourceSessionID),
34726
34735
  topic: input.topic,
34727
34736
  message: input.message,
34728
34737
  ...input.payload === undefined ? {} : { payload: input.payload },
@@ -34785,11 +34794,56 @@ function messageMetadata(input) {
34785
34794
  ...input.expiresAt === undefined ? {} : { expires_at: input.expiresAt }
34786
34795
  };
34787
34796
  }
34797
+ function sourceSession(sessionID) {
34798
+ return sessionID === undefined || sessionID === "" ? {} : { source_session: sessionID };
34799
+ }
34788
34800
 
34789
34801
  // extensions/envoy.ts
34790
34802
  import { logger } from "@oh-my-pi/pi-utils";
34791
34803
  var import_nats = __toESM(require_mod4(), 1);
34792
34804
 
34805
+ // src/envoy-session.ts
34806
+ import path2 from "path";
34807
+ var ENVOY_SESSIONS = Symbol.for("legion.pi-envoy.envoy-session");
34808
+ var store = globalThis;
34809
+ function recordedSessions() {
34810
+ const existing = store[ENVOY_SESSIONS];
34811
+ if (existing !== undefined)
34812
+ return existing;
34813
+ const created = new Map;
34814
+ store[ENVOY_SESSIONS] = created;
34815
+ return created;
34816
+ }
34817
+ function recordEnvoySession(entry) {
34818
+ const sessions = recordedSessions();
34819
+ const key = entry.sessionFile ?? (entry.sessionID === "" ? undefined : `session:${entry.sessionID}`);
34820
+ if (entry.previousKey !== undefined && entry.previousKey !== key) {
34821
+ sessions.delete(entry.previousKey);
34822
+ }
34823
+ if (key === undefined)
34824
+ return;
34825
+ sessions.set(key, entry.sessionID);
34826
+ return key;
34827
+ }
34828
+ function resolveEnvoySession(sessionFile) {
34829
+ const sessions = recordedSessions();
34830
+ for (let candidate = sessionFile;candidate !== undefined; ) {
34831
+ const directory = path2.dirname(candidate);
34832
+ const parent = `${directory}.jsonl`;
34833
+ if (parent === candidate)
34834
+ break;
34835
+ const recorded = sessions.get(parent);
34836
+ if (recorded !== undefined)
34837
+ return recorded;
34838
+ if (path2.dirname(directory) === directory)
34839
+ break;
34840
+ candidate = parent;
34841
+ }
34842
+ if (sessions.size !== 1)
34843
+ return "";
34844
+ return [...sessions.values()][0] ?? "";
34845
+ }
34846
+
34793
34847
  // src/legion/phase-stall.ts
34794
34848
  var PHASE_STALL_ENTRY = "legion-phase-stall";
34795
34849
  var LOCAL_ENVOY_NOTICE = { localNotice: true };
@@ -34851,8 +34905,8 @@ function restorePhaseStall(branch) {
34851
34905
  // src/legion/role-claim-bridge.ts
34852
34906
  var LEGION_ROLE_CLAIM_BRIDGE = Symbol.for("legion.pi-envoy.role-claim-bridge");
34853
34907
  function legionRoleClaimBridge() {
34854
- const store = globalThis;
34855
- const bridge = store[LEGION_ROLE_CLAIM_BRIDGE];
34908
+ const store2 = globalThis;
34909
+ const bridge = store2[LEGION_ROLE_CLAIM_BRIDGE];
34856
34910
  if (bridge)
34857
34911
  return bridge;
34858
34912
  const createdBridge = {
@@ -34860,7 +34914,7 @@ function legionRoleClaimBridge() {
34860
34914
  managedSessions: new Set,
34861
34915
  regained: undefined
34862
34916
  };
34863
- store[LEGION_ROLE_CLAIM_BRIDGE] = createdBridge;
34917
+ store2[LEGION_ROLE_CLAIM_BRIDGE] = createdBridge;
34864
34918
  return createdBridge;
34865
34919
  }
34866
34920
  async function claimEnvoyRole(sessionID, role, context) {
@@ -34871,12 +34925,12 @@ async function claimEnvoyRole(sessionID, role, context) {
34871
34925
  throw new Error("Envoy has no bound instance for a role claim");
34872
34926
  await instance.claim(sessionID, role, context);
34873
34927
  }
34874
- async function subscribeLegionNotice(sessionID, topic, context) {
34928
+ async function subscribeLegionNotice(sessionID, topic, context, whileHolding) {
34875
34929
  const bridge = legionRoleClaimBridge();
34876
34930
  const instance = bridge.instances.findLast((candidate) => candidate.sessionID() === sessionID) ?? bridge.instances.at(-1);
34877
34931
  if (instance === undefined)
34878
34932
  throw new Error("Envoy has no bound instance for a notice subscription");
34879
- await instance.subscribe(sessionID, topic, context);
34933
+ await instance.subscribe(sessionID, topic, context, whileHolding);
34880
34934
  }
34881
34935
  function onEnvoyRoleRegained(listener) {
34882
34936
  legionRoleClaimBridge().regained = listener;
@@ -34884,7 +34938,7 @@ function onEnvoyRoleRegained(listener) {
34884
34938
 
34885
34939
  // src/subagent-session.ts
34886
34940
  import fs from "fs";
34887
- import path2 from "path";
34941
+ import path3 from "path";
34888
34942
  import * as host from "@oh-my-pi/pi-coding-agent";
34889
34943
  var LEGION_BOOTSTRAPPED_SESSION = Symbol.for("legion.pi-envoy.bootstrapped-session");
34890
34944
  var bootstrappedSessionStore = globalThis;
@@ -34895,7 +34949,7 @@ async function isSubagentSession(context) {
34895
34949
  await context.sessionManager.ensureOnDisk();
34896
34950
  const sessionFile = context.sessionManager.getSessionFile();
34897
34951
  if (sessionFile !== undefined && fs.existsSync(sessionFile)) {
34898
- return fs.existsSync(`${path2.dirname(sessionFile)}.jsonl`);
34952
+ return fs.existsSync(`${path3.dirname(sessionFile)}.jsonl`);
34899
34953
  }
34900
34954
  const bootstrapped = bootstrappedSessionStore[LEGION_BOOTSTRAPPED_SESSION];
34901
34955
  return bootstrapped !== undefined && bootstrapped !== sessionFile;
@@ -34954,11 +35008,11 @@ function registerEnvoyMessageRenderer(pi) {
34954
35008
 
34955
35009
  // extensions/envoy-whoami-command.ts
34956
35010
  import { copyToClipboard } from "@oh-my-pi/pi-coding-agent";
34957
- function registerEnvoyWhoamiCommand(api2, cachedSessionID) {
35011
+ function registerEnvoyWhoamiCommand(api2, replyAddress) {
34958
35012
  api2.registerCommand("whoami", {
34959
35013
  description: "Copy session ID",
34960
35014
  handler: async (_args, context) => {
34961
- const sessionID = context.sessionManager.getSessionId() || cachedSessionID();
35015
+ const sessionID = await replyAddress(context, context.sessionManager.getSessionId());
34962
35016
  if (sessionID === "") {
34963
35017
  context.ui.notify("No active session", "warning");
34964
35018
  return;
@@ -35056,8 +35110,12 @@ function envoyExtension(pi) {
35056
35110
  let connection;
35057
35111
  let sessionDirectory = "";
35058
35112
  let sessionID = "";
35113
+ let recordedSessionKey;
35059
35114
  let heartbeatRegistered = false;
35060
35115
  let claimedRoleTopic;
35116
+ let claimedRoleSessionID;
35117
+ const roleNoticeSubjects = new Set;
35118
+ let roleEnds = 0;
35061
35119
  let activeSessionContext;
35062
35120
  const inbox = [];
35063
35121
  let askAwareness = {
@@ -35122,6 +35180,11 @@ function envoyExtension(pi) {
35122
35180
  const restoreLocalSessionState = (context) => {
35123
35181
  sessionDirectory = context.cwd;
35124
35182
  sessionID = context.sessionManager.getSessionId();
35183
+ recordedSessionKey = recordEnvoySession({
35184
+ previousKey: recordedSessionKey,
35185
+ sessionFile: context.sessionManager.getSessionFile(),
35186
+ sessionID
35187
+ });
35125
35188
  activeSessionContext = context;
35126
35189
  const branch = context.sessionManager.getBranch?.() ?? [];
35127
35190
  if (askAwareness.session_id !== sessionID) {
@@ -35251,6 +35314,7 @@ function envoyExtension(pi) {
35251
35314
  return;
35252
35315
  if (intentionallyClosed.delete(topic))
35253
35316
  return;
35317
+ const snapshot = roleSnapshot(topic);
35254
35318
  const retry = (delayMs) => {
35255
35319
  awaitingRetry.add(topic);
35256
35320
  setTimeout(() => {
@@ -35259,7 +35323,7 @@ function envoyExtension(pi) {
35259
35323
  return;
35260
35324
  if (intentionallyClosed.delete(topic))
35261
35325
  return;
35262
- subscribe(topic).catch(() => retry(NATS_RETRY_INTERVAL_MS));
35326
+ subscribeUnlessRoleEnds(topic, snapshot).catch(() => retry(NATS_RETRY_INTERVAL_MS));
35263
35327
  }, delayMs);
35264
35328
  };
35265
35329
  retry(RESUBSCRIBE_DELAY_MS);
@@ -35277,10 +35341,42 @@ function envoyExtension(pi) {
35277
35341
  }
35278
35342
  return true;
35279
35343
  };
35344
+ const endRole = () => {
35345
+ claimedRoleTopic = undefined;
35346
+ claimedRoleSessionID = undefined;
35347
+ roleEnds += 1;
35348
+ for (const subject2 of roleNoticeSubjects)
35349
+ closeIntentionally(subject2);
35350
+ roleNoticeSubjects.clear();
35351
+ };
35352
+ const endOutgoingRole = () => {
35353
+ if (claimedRoleSessionID !== sessionID)
35354
+ endRole();
35355
+ };
35356
+ const roleSnapshot = (topic) => ({
35357
+ roleBound: expandSubscriptionTopics([topic]).some((subject2) => roleNoticeSubjects.has(subject2)),
35358
+ endsBefore: roleEnds
35359
+ });
35360
+ const subscribeUnlessRoleEnds = async (topic, snapshot) => {
35361
+ const roleEnded = () => snapshot.roleBound && roleEnds !== snapshot.endsBefore;
35362
+ if (roleEnded())
35363
+ return false;
35364
+ await subscribe(topic);
35365
+ if (!roleEnded())
35366
+ return true;
35367
+ for (const subject2 of expandSubscriptionTopics([topic]))
35368
+ closeIntentionally(subject2);
35369
+ return false;
35370
+ };
35280
35371
  const registerSession = () => client.subscribe({
35281
35372
  sessionID,
35282
35373
  directory: sessionDirectory,
35283
- topics: [...new Set([agentSubject(sessionID), ...subscriptions.keys()])],
35374
+ topics: [
35375
+ ...new Set([
35376
+ agentSubject(sessionID),
35377
+ ...[...subscriptions.keys()].filter((subject2) => !roleNoticeSubjects.has(subject2))
35378
+ ])
35379
+ ],
35284
35380
  port: 0,
35285
35381
  title: activeSessionContext?.sessionManager.getSessionName?.() ?? "",
35286
35382
  capabilities: typeof pi.askEphemeral === "function" ? DELIVERY_CAPABILITIES : CAPABILITIES_WITHOUT_BTW,
@@ -35311,7 +35407,7 @@ function envoyExtension(pi) {
35311
35407
  return;
35312
35408
  }
35313
35409
  if (!result.claimed) {
35314
- claimedRoleTopic = undefined;
35410
+ endRole();
35315
35411
  logger.warn("envoy: role re-assertion refused; held by another live session", {
35316
35412
  role,
35317
35413
  sessionID: id,
@@ -35393,10 +35489,13 @@ function envoyExtension(pi) {
35393
35489
  sessionID,
35394
35490
  holder: result.holder
35395
35491
  });
35396
- claimedRoleTopic = undefined;
35492
+ endRole();
35397
35493
  return false;
35398
35494
  }
35495
+ if (previousTopic !== undefined && previousTopic !== topic)
35496
+ endRole();
35399
35497
  claimedRoleTopic = topic;
35498
+ claimedRoleSessionID = sessionID;
35400
35499
  pi.appendEntry(ROLE_CLAIM_ENTRY, { role });
35401
35500
  if (activeSessionContext !== undefined)
35402
35501
  ensureHeartbeat(activeSessionContext);
@@ -35417,10 +35516,10 @@ function envoyExtension(pi) {
35417
35516
  const roleSources = (previousSessionID, carryPreviousSessionRole) => previousSessionID === "" || previousSessionID === sessionID || !carryPreviousSessionRole ? [sessionID] : [sessionID, previousSessionID];
35418
35517
  const reclaimHeldRoles = async (previousSessionID, branch, carryPreviousSessionRole) => {
35419
35518
  if (!carryPreviousSessionRole)
35420
- claimedRoleTopic = undefined;
35519
+ endOutgoingRole();
35421
35520
  const remembered = transcriptClaimedRole(branch);
35422
35521
  if (remembered === null) {
35423
- claimedRoleTopic = undefined;
35522
+ endRole();
35424
35523
  return;
35425
35524
  }
35426
35525
  let role = remembered ?? claimedRoleTopic?.slice(ROLE_TOPIC_PREFIX.length);
@@ -35447,7 +35546,7 @@ function envoyExtension(pi) {
35447
35546
  const establishSessionNow = async (context, options = {}, previousSessionID = sessionID) => {
35448
35547
  const previousTopic = previousSessionID === "" ? undefined : agentSubject(previousSessionID);
35449
35548
  if (options.carryPreviousSessionRole === false)
35450
- claimedRoleTopic = undefined;
35549
+ endOutgoingRole();
35451
35550
  restoreLocalSessionState(context);
35452
35551
  const branch = context.sessionManager.getBranch?.() ?? [];
35453
35552
  const resumed = branch.length > 0;
@@ -35491,14 +35590,22 @@ function envoyExtension(pi) {
35491
35590
  await registerSession();
35492
35591
  await setEnvoyRole(role);
35493
35592
  };
35494
- const subscribeNotice = async (targetSessionID, topic, callerContext) => {
35593
+ const subscribeNotice = async (targetSessionID, topic, callerContext, whileHolding) => {
35495
35594
  const context = callerContext ?? activeSessionContext;
35496
35595
  if (context === undefined || context.sessionManager.getSessionId() !== targetSessionID) {
35497
35596
  throw new Error(`Envoy has no active session for Legion notice subscription: ${targetSessionID}`);
35498
35597
  }
35499
35598
  if (sessionID !== targetSessionID)
35500
35599
  await establishSession(context);
35501
- await subscribe(topic);
35600
+ if (whileHolding !== undefined) {
35601
+ if (claimedRoleTopic !== ROLE_TOPIC_PREFIX + whileHolding) {
35602
+ throw new Error(`Envoy session ${targetSessionID} does not hold role ${whileHolding} for a notice subscription`);
35603
+ }
35604
+ for (const subject2 of expandSubscriptionTopics([topic]))
35605
+ roleNoticeSubjects.add(subject2);
35606
+ }
35607
+ if (!await subscribeUnlessRoleEnds(topic, roleSnapshot(topic)))
35608
+ return;
35502
35609
  await registerSession();
35503
35610
  };
35504
35611
  const claimInstance = {
@@ -35509,6 +35616,13 @@ function envoyExtension(pi) {
35509
35616
  bridge.instances.push(claimInstance);
35510
35617
  const isSubagentTranscript = subagentSessionCheck();
35511
35618
  const isSubagent = async (context) => await isSubagentTranscript(context) || isRegisteredSubagent(context);
35619
+ const replyAddress = async (context, liveSessionID = "") => {
35620
+ if (sessionID !== "")
35621
+ return liveSessionID || sessionID;
35622
+ if (!await isSubagent(context))
35623
+ return liveSessionID;
35624
+ return resolveEnvoySession(context.sessionManager.getSessionFile());
35625
+ };
35512
35626
  pi.on("session_start", async (_event, context) => {
35513
35627
  if (await isSubagent(context))
35514
35628
  return;
@@ -35585,6 +35699,11 @@ function envoyExtension(pi) {
35585
35699
  const bound = bridge.instances.indexOf(claimInstance);
35586
35700
  if (bound !== -1)
35587
35701
  bridge.instances.splice(bound, 1);
35702
+ recordedSessionKey = recordEnvoySession({
35703
+ previousKey: recordedSessionKey,
35704
+ sessionFile: undefined,
35705
+ sessionID: ""
35706
+ });
35588
35707
  const deadline = Promise.withResolvers();
35589
35708
  const timer = setTimeout(deadline.resolve, 1000);
35590
35709
  try {
@@ -35616,7 +35735,7 @@ function envoyExtension(pi) {
35616
35735
  description: spec.description,
35617
35736
  parameters: schemaFor(pi, spec.operation),
35618
35737
  lenientArgValidation: true,
35619
- execute: async (_id, parameters) => execute(spec.operation, parameters)
35738
+ execute: async (_id, parameters, _signal, _onUpdate, context) => execute(spec.operation, parameters, context)
35620
35739
  });
35621
35740
  }
35622
35741
  if (dispatchConfig.enabled) {
@@ -35648,7 +35767,7 @@ function envoyExtension(pi) {
35648
35767
  });
35649
35768
  }
35650
35769
  }
35651
- registerEnvoyWhoamiCommand(pi, () => sessionID);
35770
+ registerEnvoyWhoamiCommand(pi, replyAddress);
35652
35771
  pi.on("before_agent_start", async (event, context) => {
35653
35772
  const id = context.sessionManager.getSessionId();
35654
35773
  if (id === "" || event.prompt.trim() === "" || !context.hasUI || askEphemeral === undefined || legionManaged(id)) {
@@ -35796,7 +35915,7 @@ function envoyExtension(pi) {
35796
35915
  }
35797
35916
  announceFollow(event.details);
35798
35917
  });
35799
- async function execute(operation, rawParameters) {
35918
+ async function execute(operation, rawParameters, context) {
35800
35919
  try {
35801
35920
  const parameters = parseEnvoyToolArguments(operation, rawParameters);
35802
35921
  switch (operation) {
@@ -35849,7 +35968,7 @@ Warnings: ${warnings.join("; ")}`, {
35849
35968
  case EnvoyToolOperation.send: {
35850
35969
  const targetSessionID = stringFor(parameters, "session_id");
35851
35970
  const result = await client.send({
35852
- sourceSessionID: sessionID,
35971
+ sourceSessionID: await replyAddress(context),
35853
35972
  targetSessionID,
35854
35973
  message: stringFor(parameters, "message"),
35855
35974
  ...toMessageMetadata(parameters)
@@ -35862,16 +35981,21 @@ Warnings: ${warnings.join("; ")}`, {
35862
35981
  }
35863
35982
  case EnvoyToolOperation.publish: {
35864
35983
  const topic = stringFor(parameters, "topic");
35984
+ const source = await replyAddress(context);
35865
35985
  const result = await client.publish({
35866
- sourceSessionID: sessionID,
35986
+ sourceSessionID: source,
35867
35987
  topic,
35868
35988
  message: stringFor(parameters, "message"),
35869
35989
  ...toMessageMetadata(parameters)
35870
35990
  });
35871
- return toolSuccess(result.holder === undefined ? `published ${result.envelope.event_id}` : `published ${result.envelope.event_id}; holder ${result.holder}`, {
35991
+ const undelivered = source !== "" && result.holder === source && await isSubagent(context);
35992
+ const published = result.holder === undefined ? `published ${result.envelope.event_id}` : `published ${result.envelope.event_id}; holder ${result.holder}`;
35993
+ return toolSuccess(undelivered ? `${published}
35994
+ Not delivered: that holder is the session this subagent sends as, and the listener drops a message whose source session is its recipient, so the agent that spawned you did not receive it. Reach it over hub instead.` : published, {
35872
35995
  event_id: result.envelope.event_id,
35873
35996
  topic,
35874
- ...result.holder === undefined ? {} : { holder: result.holder }
35997
+ ...result.holder === undefined ? {} : { holder: result.holder },
35998
+ ...undelivered ? { undelivered_echo: true } : {}
35875
35999
  });
35876
36000
  }
35877
36001
  case EnvoyToolOperation.setRole: {
@@ -35884,9 +36008,21 @@ Warnings: ${warnings.join("; ")}`, {
35884
36008
  return toolSuccess(JSON.stringify(role, null, 2), role);
35885
36009
  }
35886
36010
  case EnvoyToolOperation.whoami: {
35887
- return toolSuccess(JSON.stringify({ session_id: sessionID, machine_id: machineID(), dir: sessionDirectory }, null, 2), {
35888
- sessionID,
35889
- topics: [...subscriptions.keys()]
36011
+ const inSubagent = await isSubagent(context);
36012
+ const address = await replyAddress(context);
36013
+ const subagent = inSubagent ? {
36014
+ session_id: context.sessionManager.getSessionId(),
36015
+ note: address === "" ? "This session is a task subagent and registers no Envoy session of its own, and this process records no top-level session its transcript traces back to, so it has no reply address at all: say who you are in the message body, and reach the agent that spawned you over hub." : "This session is a task subagent and registers no Envoy session of its own; a reply to session_id reaches the agent that spawned it, which relays over hub. Your own envoy_publish never reaches that agent \u2014 use hub for that hop."
36016
+ } : undefined;
36017
+ return toolSuccess(JSON.stringify({
36018
+ session_id: address,
36019
+ machine_id: machineID(),
36020
+ dir: sessionDirectory || context.cwd,
36021
+ ...subagent === undefined ? {} : { subagent }
36022
+ }, null, 2), {
36023
+ sessionID: address,
36024
+ topics: [...subscriptions.keys()],
36025
+ ...subagent === undefined ? {} : { subagent }
35890
36026
  });
35891
36027
  }
35892
36028
  case EnvoyToolOperation.listSessions: {
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.1",
16145
+ version: "5.4.3",
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
  };
@@ -106,6 +106,25 @@ envoy_send(
106
106
  )
107
107
  ```
108
108
 
109
+ ### Your own address, and a subagent's
110
+
111
+ `envoy_whoami`'s `session_id` is the address a reply to you reaches. Inside a `task` subagent it
112
+ is the session that spawned you: a subagent registers no Envoy session of its own, so a peer
113
+ answering it reaches that session, which relays to you over hub. The `subagent` field in that
114
+ output carries your own host session id — it is not an address, so never hand it to a peer.
115
+
116
+ `session_id` is empty when nothing can be reached: a process that took no Envoy identity, or a
117
+ subagent whose spawning session this process can no longer place (it forked away, and other
118
+ top-level sessions are running). Then your messages carry no sender either, so say who you are
119
+ in the message body. An empty address is deliberate — being handed an unrelated live session
120
+ would send your peers to an agent that never spawned you.
121
+
122
+ Your own `envoy_publish` never reaches the agent that spawned you. The listener delivers nothing
123
+ to the session a message names as its source, and inside a subagent that source is your parent,
124
+ so a publish to a role it holds — or to any topic it subscribes to — is accepted and delivered to
125
+ nobody. Use hub for that one hop. `envoy_send` to any other session, including a reply, is
126
+ unaffected.
127
+
109
128
  ### Delivery capabilities
110
129
 
111
130
  Each session row from `envoy_sessions` carries `capabilities`, the targeted-delivery modes that
@@ -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.1",
3
+ "version": "5.4.3",
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",