@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 +167 -31
- package/dist/legion.js +27 -6
- package/dist/skills/envoy/SKILL.md +19 -0
- package/dist/skills/legion-controller/SKILL.md +21 -3
- package/package.json +2 -2
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
|
|
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
|
|
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
|
|
34855
|
-
const 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
|
-
|
|
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
|
|
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(`${
|
|
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,
|
|
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()
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35519
|
+
endOutgoingRole();
|
|
35421
35520
|
const remembered = transcriptClaimedRole(branch);
|
|
35422
35521
|
if (remembered === null) {
|
|
35423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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:
|
|
35986
|
+
sourceSessionID: source,
|
|
35867
35987
|
topic,
|
|
35868
35988
|
message: stringFor(parameters, "message"),
|
|
35869
35989
|
...toMessageMetadata(parameters)
|
|
35870
35990
|
});
|
|
35871
|
-
|
|
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
|
-
|
|
35888
|
-
|
|
35889
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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,
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
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":
|
|
16
|
+
"goDaemonApiVersion": 7
|
|
17
17
|
},
|
|
18
18
|
"repository": {
|
|
19
19
|
"type": "git",
|