@sjawhar/pi-legion-envoy 0.16.0 → 0.17.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
@@ -30785,147 +30785,37 @@ var ChildStatusPayloadSchema = object({
30785
30785
  from: string2().optional(),
30786
30786
  to: string2().optional()
30787
30787
  });
30788
- function dispatchActorLabel(actor) {
30789
- return `${actor.kind} ${actor.id ?? "unknown"}`;
30790
- }
30791
- function dispatchBody(event) {
30792
- const lines = [];
30793
- switch (event.type) {
30794
- case "issue.created":
30795
- case "issue.updated":
30796
- case "issue.closed": {
30797
- const parsed = IssuePayloadSchema.safeParse(event.payload);
30798
- if (!parsed.success)
30799
- break;
30800
- if (parsed.data.title)
30801
- lines.push(`Title: ${parsed.data.title}`);
30802
- if (parsed.data.status)
30803
- lines.push(`Status: ${parsed.data.status}`);
30804
- if (parsed.data.route)
30805
- lines.push(`Route: ${parsed.data.route}`);
30806
- break;
30807
- }
30808
- case "artifact.created": {
30809
- const parsed = ArtifactCreatedPayloadSchema.safeParse(event.payload);
30810
- const name = parsed.success ? parsed.data.artifact?.name : undefined;
30811
- if (name)
30812
- lines.push(`Artifact: ${name}`);
30813
- break;
30814
- }
30815
- case "artifact.version": {
30816
- const parsed = ArtifactVersionPayloadSchema.safeParse(event.payload);
30817
- if (!parsed.success)
30818
- break;
30819
- const version = parsed.data.version;
30820
- if (parsed.data.name)
30821
- lines.push(`Artifact: ${parsed.data.name}`);
30822
- if (version?.number !== undefined)
30823
- lines.push(`Version: ${version.number}`);
30824
- if (version?.summary)
30825
- lines.push(`Summary: ${version.summary}`);
30826
- if (parsed.data.diff)
30827
- lines.push("Diff:", parsed.data.diff);
30828
- break;
30829
- }
30830
- case "ask.opened":
30831
- case "ask.answered": {
30832
- const parsed = AskPayloadSchema.safeParse(event.payload);
30833
- if (!parsed.success)
30834
- break;
30835
- if (parsed.data.question)
30836
- lines.push(`Question: ${parsed.data.question}`);
30837
- if (event.type === "ask.opened") {
30838
- const options = (parsed.data.options ?? []).map((option) => option.label).filter((label) => label !== undefined);
30839
- if (options.length > 0)
30840
- lines.push(`Options: ${options.join(", ")}`);
30841
- break;
30842
- }
30843
- const answer = parsed.data.answer;
30844
- lines.push(`Selected: ${answer?.selected?.join(", ") || "none"}`);
30845
- if (answer?.text)
30846
- lines.push(`Text: ${answer.text}`);
30847
- break;
30848
- }
30849
- case "comment.created":
30850
- case "comment.resolved": {
30851
- const parsed = CommentPayloadSchema.safeParse(event.payload);
30852
- if (!parsed.success)
30853
- break;
30854
- const quote = parsed.data.anchor?.quote;
30855
- if (parsed.data.artifact_name)
30856
- lines.push(`Artifact: ${parsed.data.artifact_name}`);
30857
- if (quote)
30858
- lines.push(`> ${quote}`);
30859
- if (parsed.data.reply_to)
30860
- lines.push(`Reply chain: ${parsed.data.reply_to}`);
30861
- if (parsed.data.body)
30862
- lines.push(`Body: ${parsed.data.body}`);
30863
- break;
30864
- }
30865
- case "suggestion.accepted":
30866
- case "suggestion.rejected": {
30867
- const parsed = CommentPayloadSchema.safeParse(event.payload);
30868
- if (!parsed.success)
30869
- break;
30870
- const quote = parsed.data.anchor?.quote;
30871
- const replacement = parsed.data.suggestion?.replace_with;
30872
- if (parsed.data.artifact_name)
30873
- lines.push(`Artifact: ${parsed.data.artifact_name}`);
30874
- if (quote)
30875
- lines.push(`> ${quote}`);
30876
- if (quote && replacement)
30877
- lines.push(`${quote} \u2192 ${replacement}`);
30878
- break;
30879
- }
30880
- case "message.created": {
30881
- const parsed = MessagePayloadSchema.safeParse(event.payload);
30882
- const body = parsed.success ? parsed.data.body : undefined;
30883
- if (body)
30884
- lines.push(`Body: ${body}`);
30885
- break;
30886
- }
30887
- case "child.status": {
30888
- const parsed = ChildStatusPayloadSchema.safeParse(event.payload);
30889
- if (!parsed.success)
30890
- break;
30891
- const { child_key: child, from, to } = parsed.data;
30892
- if (child && from && to)
30893
- lines.push(`Child: ${child} ${from} \u2192 ${to}`);
30894
- break;
30895
- }
30896
- }
30897
- return lines;
30898
- }
30899
- function renderDispatch(envelope, sessionID) {
30900
- const invalid = () => ({
30901
- skip: false,
30902
- content: "dispatch: invalid event",
30903
- envelope
30904
- });
30905
- if (envelope.payload === undefined)
30906
- return invalid();
30907
- let rawEvent;
30788
+ var DISPATCH_PAYLOAD_SCHEMAS = {
30789
+ "issue.created": IssuePayloadSchema,
30790
+ "issue.updated": IssuePayloadSchema,
30791
+ "issue.closed": IssuePayloadSchema,
30792
+ "artifact.created": ArtifactCreatedPayloadSchema,
30793
+ "artifact.version": ArtifactVersionPayloadSchema,
30794
+ "ask.opened": AskPayloadSchema,
30795
+ "ask.answered": AskPayloadSchema,
30796
+ "comment.created": CommentPayloadSchema,
30797
+ "comment.resolved": CommentPayloadSchema,
30798
+ "suggestion.accepted": CommentPayloadSchema,
30799
+ "suggestion.rejected": CommentPayloadSchema,
30800
+ "message.created": MessagePayloadSchema,
30801
+ "child.status": ChildStatusPayloadSchema
30802
+ };
30803
+ function dispatchPayload(event) {
30804
+ const schema = DISPATCH_PAYLOAD_SCHEMAS[event.type];
30805
+ if (schema === undefined)
30806
+ return event.payload;
30807
+ const parsed = schema.safeParse(event.payload);
30808
+ return parsed.success ? parsed.data : event.payload;
30809
+ }
30810
+ function parseDispatchFrame(rawPayload) {
30811
+ let value;
30908
30812
  try {
30909
- rawEvent = JSON.parse(envelope.payload);
30813
+ value = JSON.parse(rawPayload);
30910
30814
  } catch {
30911
- return invalid();
30912
- }
30913
- const parsed = DispatchEventSchema.safeParse(rawEvent);
30914
- if (!parsed.success)
30915
- return invalid();
30916
- const event = parsed.data;
30917
- if (event.actor.kind === "session" && event.actor.id === sessionID) {
30918
- return { skip: true, content: "", envelope };
30815
+ return { raw: rawPayload };
30919
30816
  }
30920
- return {
30921
- skip: false,
30922
- content: [
30923
- `dispatch ${event.issue_key} \xB7 ${event.type} \xB7 by ${dispatchActorLabel(event.actor)}`,
30924
- ...dispatchBody(event)
30925
- ].join(`
30926
- `),
30927
- envelope
30928
- };
30817
+ const parsed = DispatchEventSchema.safeParse(value);
30818
+ return parsed.success ? { event: parsed.data } : { raw: value };
30929
30819
  }
30930
30820
  function inboundTimestamp(milliseconds) {
30931
30821
  if (milliseconds === undefined)
@@ -30961,13 +30851,31 @@ function renderInbound(raw, sessionID, subject) {
30961
30851
  const data = tolerant.data;
30962
30852
  envelope = { ...data, source: data.source ?? "unknown" };
30963
30853
  }
30854
+ let dispatchEvent;
30855
+ let dispatchIssue;
30856
+ const dispatchRendered = envelope.source === "dispatch" && envelope.payload !== undefined;
30964
30857
  if (envelope.source === "dispatch") {
30965
- const renderedDispatch = renderDispatch(envelope, sessionID);
30966
- if (renderedDispatch !== null)
30967
- return renderedDispatch;
30858
+ if (envelope.payload === undefined) {
30859
+ dispatchIssue = "payload";
30860
+ } else {
30861
+ const frame = parseDispatchFrame(envelope.payload);
30862
+ if ("event" in frame) {
30863
+ if (frame.event.actor.kind === "session" && frame.event.actor.id === sessionID) {
30864
+ return { skip: true, content: "", envelope };
30865
+ }
30866
+ dispatchEvent = {
30867
+ issue_key: frame.event.issue_key,
30868
+ type: frame.event.type,
30869
+ actor: frame.event.actor,
30870
+ payload: dispatchPayload(frame.event)
30871
+ };
30872
+ } else {
30873
+ dispatchEvent = frame.raw;
30874
+ }
30875
+ }
30968
30876
  }
30969
30877
  let parsedPayload;
30970
- if (envelope.payload !== undefined && (envelope.source === "github" || envelope.payload !== envelope.payload_summary)) {
30878
+ if (!dispatchRendered && envelope.payload !== undefined && (envelope.source === "github" || envelope.payload !== envelope.payload_summary)) {
30971
30879
  try {
30972
30880
  parsedPayload = JSON.parse(envelope.payload);
30973
30881
  } catch {
@@ -30975,7 +30883,7 @@ function renderInbound(raw, sessionID, subject) {
30975
30883
  }
30976
30884
  }
30977
30885
  const payloadSummary = envelope.payload_summary ?? "unknown";
30978
- const message = envelope.payload !== undefined && envelope.payload !== envelope.payload_summary ? parsedPayload : undefined;
30886
+ const message = !dispatchRendered && envelope.payload !== undefined && envelope.payload !== envelope.payload_summary ? parsedPayload : undefined;
30979
30887
  const summaryIsHead = typeof message === "string" && envelope.payload_summary !== undefined && message.startsWith(envelope.payload_summary.endsWith("\u2026") ? envelope.payload_summary.slice(0, -1) : envelope.payload_summary);
30980
30888
  const body = `${envelope.payload_summary ?? ""}
30981
30889
  ${envelope.payload ?? ""}`;
@@ -30989,7 +30897,11 @@ ${envelope.payload ?? ""}`;
30989
30897
  const role = envelope.sender?.roles?.[0];
30990
30898
  const reply = replyWith(envelope);
30991
30899
  const sourceIssue = parseIssues.includes("source") || KNOWN_SOURCES[envelope.source] !== undefined ? undefined : `source=${envelope.source}`;
30992
- const unrecognised = [...parseIssues, ...sourceIssue === undefined ? [] : [sourceIssue]].join(", ") || undefined;
30900
+ const unrecognised = [
30901
+ ...parseIssues,
30902
+ ...sourceIssue === undefined ? [] : [sourceIssue],
30903
+ ...dispatchIssue === undefined ? [] : [dispatchIssue]
30904
+ ].join(", ") || undefined;
30993
30905
  const rendered = {
30994
30906
  ...envelope.topic === agentSubject(sessionID) ? { to: `you (${sessionID.slice(0, 4)}\u2026)` } : {},
30995
30907
  from: senderLabel(envelope),
@@ -31004,8 +30916,10 @@ ${envelope.payload ?? ""}`;
31004
30916
  ...role === undefined ? {} : {
31005
30917
  reply_role: `envoy_publish(topic="notifications.role.${role}", message="...")`
31006
30918
  },
31007
- ...summaryIsHead ? {} : { summary: payloadSummary },
31008
- ...message === undefined ? {} : { message },
30919
+ ...dispatchRendered ? { dispatch: dispatchEvent } : {
30920
+ ...summaryIsHead ? {} : { summary: payloadSummary },
30921
+ ...message === undefined ? {} : { message }
30922
+ },
31009
30923
  ...foreignSession === undefined ? {} : {
31010
30924
  note: `body names session ${foreignSession}; the sender is ${envelope.source_session ?? "unknown"}`
31011
30925
  },
@@ -31935,6 +31849,11 @@ var RoleWireSchema = object({
31935
31849
  holder: string2(),
31936
31850
  last_seen: number2().int()
31937
31851
  });
31852
+ var RoleHeldWireSchema = object({
31853
+ error: string2(),
31854
+ role: string2(),
31855
+ holder: string2()
31856
+ });
31938
31857
  var ErrorWireSchema = object({
31939
31858
  error: string2(),
31940
31859
  expected: array(string2()).optional()
@@ -32067,7 +31986,28 @@ function createEnvoyClient(config) {
32067
31986
  unregisterSession: async (sessionID) => {
32068
31987
  await request(`/v1/sessions/${encodeURIComponent(sessionID)}`, { method: "DELETE" });
32069
31988
  },
32070
- setRole: async (input) => InterestWireSchema.parse(JSON.parse(await post("/v1/roles/set", { session_id: input.sessionID, role: input.role }))),
31989
+ setRole: async (input) => {
31990
+ const soft = input.soft === true;
31991
+ const previous = soft ? input.previousSessionID ?? "" : "";
31992
+ const body = {
31993
+ session_id: input.sessionID,
31994
+ role: input.role,
31995
+ ...soft ? { soft: true } : {},
31996
+ ...previous === "" ? {} : { previous_session_id: previous }
31997
+ };
31998
+ let raw;
31999
+ try {
32000
+ raw = await post("/v1/roles/set", body);
32001
+ } catch (error) {
32002
+ if (error instanceof EnvoyApiError && error.details.status === 409) {
32003
+ const held = RoleHeldWireSchema.safeParse(JSON.parse(error.details.responseBody));
32004
+ if (held.success)
32005
+ return { claimed: false, holder: held.data.holder };
32006
+ }
32007
+ throw error;
32008
+ }
32009
+ return { claimed: true, interest: InterestWireSchema.parse(JSON.parse(raw)) };
32010
+ },
32071
32011
  getRole: async (role) => RoleWireSchema.parse(JSON.parse(await request(`/v1/roles/${encodeURIComponent(role)}`, { method: "GET" }))),
32072
32012
  listSessions: async (input = {}) => {
32073
32013
  const search = new URLSearchParams;
@@ -32163,6 +32103,20 @@ async function copySessionID(copyToClipboard, sessionID) {
32163
32103
  // extensions/envoy.ts
32164
32104
  var codec2 = import_nats.StringCodec();
32165
32105
  var NATS_RETRY_INTERVAL_MS = 15000;
32106
+ var ROLE_CLAIM_ENTRY = "envoy-role-claim";
32107
+ function isRoleClaimEntry(entry) {
32108
+ if (typeof entry !== "object" || entry === null)
32109
+ return false;
32110
+ if (!("type" in entry) || entry.type !== "custom")
32111
+ return false;
32112
+ if (!("customType" in entry) || entry.customType !== ROLE_CLAIM_ENTRY)
32113
+ return false;
32114
+ if (!("data" in entry) || typeof entry.data !== "object" || entry.data === null)
32115
+ return false;
32116
+ if (!("role" in entry.data))
32117
+ return false;
32118
+ return entry.data.role === null || typeof entry.data.role === "string";
32119
+ }
32166
32120
  var LEGION_ROLE_CLAIM_BRIDGE = Symbol.for("legion.pi-envoy.role-claim-bridge");
32167
32121
  function legionRoleClaimBridge() {
32168
32122
  const store = globalThis;
@@ -32360,8 +32314,81 @@ function envoyExtension(pi) {
32360
32314
  await subscribe(topic);
32361
32315
  }
32362
32316
  };
32363
- const establishSession = async (context) => {
32317
+ const setEnvoyRole = async (role, options = {}) => {
32318
+ const topic = ROLE_TOPIC_PREFIX + role;
32319
+ const previousTopic = claimedRoleTopic;
32320
+ const result = await client.setRole({
32321
+ sessionID,
32322
+ role,
32323
+ soft: options.soft,
32324
+ previousSessionID: options.previousSessionID
32325
+ });
32326
+ if (!result.claimed) {
32327
+ logger.warn("envoy: role held by another live session; not reclaimed", {
32328
+ role,
32329
+ sessionID,
32330
+ holder: result.holder
32331
+ });
32332
+ claimedRoleTopic = undefined;
32333
+ return false;
32334
+ }
32335
+ claimedRoleTopic = topic;
32336
+ pi.appendEntry(ROLE_CLAIM_ENTRY, { role });
32337
+ if (activeSessionContext !== undefined)
32338
+ ensureHeartbeat(activeSessionContext);
32339
+ if (previousTopic !== undefined && previousTopic !== topic) {
32340
+ await client.unsubscribe({ sessionID, topics: [previousTopic] });
32341
+ }
32342
+ return true;
32343
+ };
32344
+ const releaseClaimedRole = () => {
32345
+ claimedRoleTopic = undefined;
32346
+ pi.appendEntry(ROLE_CLAIM_ENTRY, { role: null });
32347
+ };
32348
+ const transcriptClaimedRole = (branch) => {
32349
+ let role;
32350
+ for (const entry of branch) {
32351
+ if (!isRoleClaimEntry(entry))
32352
+ continue;
32353
+ role = entry.data.role;
32354
+ }
32355
+ return role;
32356
+ };
32357
+ const roleSources = (previousSessionID, carryPreviousSessionRole) => previousSessionID === "" || previousSessionID === sessionID || !carryPreviousSessionRole ? [sessionID] : [sessionID, previousSessionID];
32358
+ const reclaimHeldRoles = async (previousSessionID, branch, carryPreviousSessionRole) => {
32359
+ if (!carryPreviousSessionRole)
32360
+ claimedRoleTopic = undefined;
32361
+ const remembered = transcriptClaimedRole(branch);
32362
+ if (remembered === null) {
32363
+ claimedRoleTopic = undefined;
32364
+ return;
32365
+ }
32366
+ let role = remembered ?? claimedRoleTopic?.slice(ROLE_TOPIC_PREFIX.length);
32367
+ if (role === undefined) {
32368
+ for (const source of roleSources(previousSessionID, carryPreviousSessionRole)) {
32369
+ const registry = await client.getInterest(source).catch(() => {
32370
+ return;
32371
+ });
32372
+ const topic = registry?.topics.find((candidate) => candidate.startsWith(ROLE_TOPIC_PREFIX));
32373
+ if (topic !== undefined) {
32374
+ role = topic.slice(ROLE_TOPIC_PREFIX.length);
32375
+ break;
32376
+ }
32377
+ }
32378
+ }
32379
+ if (role === undefined)
32380
+ return;
32381
+ const continued = carryPreviousSessionRole && previousSessionID !== "" && previousSessionID !== sessionID ? previousSessionID : undefined;
32382
+ await setEnvoyRole(role, { soft: true, previousSessionID: continued }).catch((error) => {
32383
+ logger.warn("envoy: role reclaim failed", { role, sessionID, error: messageFor(error) });
32384
+ });
32385
+ };
32386
+ let sessionEstablishment = Promise.resolve();
32387
+ const establishSessionNow = async (context, options = {}) => {
32388
+ const previousSessionID = sessionID;
32364
32389
  const previousTopic = sessionID === "" ? undefined : agentSubject(sessionID);
32390
+ if (options.carryPreviousSessionRole === false)
32391
+ claimedRoleTopic = undefined;
32365
32392
  sessionDirectory = context.cwd;
32366
32393
  sessionID = context.sessionManager.getSessionId();
32367
32394
  activeSessionContext = context;
@@ -32377,22 +32404,23 @@ function envoyExtension(pi) {
32377
32404
  }
32378
32405
  await ensureConnection();
32379
32406
  await subscribe(currentTopic);
32380
- if ((context.sessionManager.getBranch?.() ?? []).length > 0) {
32407
+ const branch = context.sessionManager.getBranch?.() ?? [];
32408
+ const resumed = branch.length > 0;
32409
+ if (resumed) {
32381
32410
  await recoverRegisteredInterests();
32382
32411
  }
32383
32412
  await registerSession();
32413
+ if (resumed || previousSessionID !== "") {
32414
+ await reclaimHeldRoles(previousSessionID, branch, options.carryPreviousSessionRole ?? true);
32415
+ }
32384
32416
  ensureHeartbeat(context);
32385
32417
  };
32386
- const setEnvoyRole = async (role) => {
32387
- const topic = ROLE_TOPIC_PREFIX + role;
32388
- const previousTopic = claimedRoleTopic;
32389
- await client.setRole({ sessionID, role });
32390
- claimedRoleTopic = topic;
32391
- if (activeSessionContext !== undefined)
32392
- ensureHeartbeat(activeSessionContext);
32393
- if (previousTopic !== undefined && previousTopic !== topic) {
32394
- await client.unsubscribe({ sessionID, topics: [previousTopic] });
32395
- }
32418
+ const establishSession = (context, options = {}) => {
32419
+ const run = sessionEstablishment.then(() => establishSessionNow(context, options), () => establishSessionNow(context, options));
32420
+ sessionEstablishment = run.catch(() => {
32421
+ return;
32422
+ });
32423
+ return run;
32396
32424
  };
32397
32425
  const bridge = legionRoleClaimBridge();
32398
32426
  const claim = async (targetSessionID, role, callerContext) => {
@@ -32402,8 +32430,8 @@ function envoyExtension(pi) {
32402
32430
  }
32403
32431
  if (sessionID !== targetSessionID)
32404
32432
  await establishSession(context);
32405
- await setEnvoyRole(role);
32406
32433
  await registerSession();
32434
+ await setEnvoyRole(role);
32407
32435
  };
32408
32436
  bridge.claim = claim;
32409
32437
  bridge.ready.resolve(claim);
@@ -32445,7 +32473,8 @@ function envoyExtension(pi) {
32445
32473
  return;
32446
32474
  const previousID = sessionID;
32447
32475
  try {
32448
- await establishSession(context);
32476
+ const carryPreviousSessionRole = reason !== "new" && reason !== "resume";
32477
+ await establishSession(context, { carryPreviousSessionRole });
32449
32478
  } catch (error) {
32450
32479
  context.ui.notify(`envoy: rebind failed (${messageFor(error)}); will recover on reconnect`, "warning");
32451
32480
  }
@@ -32579,12 +32608,12 @@ Warnings: ${warnings.join("; ")}`, {
32579
32608
  ...claimedRoleTopic === undefined ? [] : [claimedRoleTopic]
32580
32609
  ]).filter((topic) => topic !== inbox);
32581
32610
  const removed = targets.filter((topic) => closeIntentionally(topic) || topic === claimedRoleTopic);
32582
- if (claimedRoleTopic !== undefined && removed.includes(claimedRoleTopic)) {
32583
- claimedRoleTopic = undefined;
32584
- }
32611
+ const releasingRole = claimedRoleTopic !== undefined && removed.includes(claimedRoleTopic);
32585
32612
  const registrationError = removed.length === 0 ? undefined : await client.unsubscribe({ sessionID, topics: removed }).then(registerSession).then(() => {
32586
32613
  return;
32587
32614
  }, messageFor);
32615
+ if (releasingRole && registrationError === undefined)
32616
+ releaseClaimedRole();
32588
32617
  return toolSuccess(`Unsubscribed: ${removed.join(", ") || "(none)"}`, {
32589
32618
  removed,
32590
32619
  ...registrationError === undefined ? {} : { registrationError }
package/dist/legion.js CHANGED
@@ -31282,147 +31282,37 @@ var ChildStatusPayloadSchema = object({
31282
31282
  from: string2().optional(),
31283
31283
  to: string2().optional()
31284
31284
  });
31285
- function dispatchActorLabel(actor) {
31286
- return `${actor.kind} ${actor.id ?? "unknown"}`;
31287
- }
31288
- function dispatchBody(event) {
31289
- const lines = [];
31290
- switch (event.type) {
31291
- case "issue.created":
31292
- case "issue.updated":
31293
- case "issue.closed": {
31294
- const parsed = IssuePayloadSchema.safeParse(event.payload);
31295
- if (!parsed.success)
31296
- break;
31297
- if (parsed.data.title)
31298
- lines.push(`Title: ${parsed.data.title}`);
31299
- if (parsed.data.status)
31300
- lines.push(`Status: ${parsed.data.status}`);
31301
- if (parsed.data.route)
31302
- lines.push(`Route: ${parsed.data.route}`);
31303
- break;
31304
- }
31305
- case "artifact.created": {
31306
- const parsed = ArtifactCreatedPayloadSchema.safeParse(event.payload);
31307
- const name = parsed.success ? parsed.data.artifact?.name : undefined;
31308
- if (name)
31309
- lines.push(`Artifact: ${name}`);
31310
- break;
31311
- }
31312
- case "artifact.version": {
31313
- const parsed = ArtifactVersionPayloadSchema.safeParse(event.payload);
31314
- if (!parsed.success)
31315
- break;
31316
- const version = parsed.data.version;
31317
- if (parsed.data.name)
31318
- lines.push(`Artifact: ${parsed.data.name}`);
31319
- if (version?.number !== undefined)
31320
- lines.push(`Version: ${version.number}`);
31321
- if (version?.summary)
31322
- lines.push(`Summary: ${version.summary}`);
31323
- if (parsed.data.diff)
31324
- lines.push("Diff:", parsed.data.diff);
31325
- break;
31326
- }
31327
- case "ask.opened":
31328
- case "ask.answered": {
31329
- const parsed = AskPayloadSchema.safeParse(event.payload);
31330
- if (!parsed.success)
31331
- break;
31332
- if (parsed.data.question)
31333
- lines.push(`Question: ${parsed.data.question}`);
31334
- if (event.type === "ask.opened") {
31335
- const options = (parsed.data.options ?? []).map((option) => option.label).filter((label) => label !== undefined);
31336
- if (options.length > 0)
31337
- lines.push(`Options: ${options.join(", ")}`);
31338
- break;
31339
- }
31340
- const answer = parsed.data.answer;
31341
- lines.push(`Selected: ${answer?.selected?.join(", ") || "none"}`);
31342
- if (answer?.text)
31343
- lines.push(`Text: ${answer.text}`);
31344
- break;
31345
- }
31346
- case "comment.created":
31347
- case "comment.resolved": {
31348
- const parsed = CommentPayloadSchema.safeParse(event.payload);
31349
- if (!parsed.success)
31350
- break;
31351
- const quote = parsed.data.anchor?.quote;
31352
- if (parsed.data.artifact_name)
31353
- lines.push(`Artifact: ${parsed.data.artifact_name}`);
31354
- if (quote)
31355
- lines.push(`> ${quote}`);
31356
- if (parsed.data.reply_to)
31357
- lines.push(`Reply chain: ${parsed.data.reply_to}`);
31358
- if (parsed.data.body)
31359
- lines.push(`Body: ${parsed.data.body}`);
31360
- break;
31361
- }
31362
- case "suggestion.accepted":
31363
- case "suggestion.rejected": {
31364
- const parsed = CommentPayloadSchema.safeParse(event.payload);
31365
- if (!parsed.success)
31366
- break;
31367
- const quote = parsed.data.anchor?.quote;
31368
- const replacement = parsed.data.suggestion?.replace_with;
31369
- if (parsed.data.artifact_name)
31370
- lines.push(`Artifact: ${parsed.data.artifact_name}`);
31371
- if (quote)
31372
- lines.push(`> ${quote}`);
31373
- if (quote && replacement)
31374
- lines.push(`${quote} \u2192 ${replacement}`);
31375
- break;
31376
- }
31377
- case "message.created": {
31378
- const parsed = MessagePayloadSchema.safeParse(event.payload);
31379
- const body = parsed.success ? parsed.data.body : undefined;
31380
- if (body)
31381
- lines.push(`Body: ${body}`);
31382
- break;
31383
- }
31384
- case "child.status": {
31385
- const parsed = ChildStatusPayloadSchema.safeParse(event.payload);
31386
- if (!parsed.success)
31387
- break;
31388
- const { child_key: child, from, to } = parsed.data;
31389
- if (child && from && to)
31390
- lines.push(`Child: ${child} ${from} \u2192 ${to}`);
31391
- break;
31392
- }
31393
- }
31394
- return lines;
31395
- }
31396
- function renderDispatch(envelope, sessionID) {
31397
- const invalid = () => ({
31398
- skip: false,
31399
- content: "dispatch: invalid event",
31400
- envelope
31401
- });
31402
- if (envelope.payload === undefined)
31403
- return invalid();
31404
- let rawEvent;
31285
+ var DISPATCH_PAYLOAD_SCHEMAS = {
31286
+ "issue.created": IssuePayloadSchema,
31287
+ "issue.updated": IssuePayloadSchema,
31288
+ "issue.closed": IssuePayloadSchema,
31289
+ "artifact.created": ArtifactCreatedPayloadSchema,
31290
+ "artifact.version": ArtifactVersionPayloadSchema,
31291
+ "ask.opened": AskPayloadSchema,
31292
+ "ask.answered": AskPayloadSchema,
31293
+ "comment.created": CommentPayloadSchema,
31294
+ "comment.resolved": CommentPayloadSchema,
31295
+ "suggestion.accepted": CommentPayloadSchema,
31296
+ "suggestion.rejected": CommentPayloadSchema,
31297
+ "message.created": MessagePayloadSchema,
31298
+ "child.status": ChildStatusPayloadSchema
31299
+ };
31300
+ function dispatchPayload(event) {
31301
+ const schema = DISPATCH_PAYLOAD_SCHEMAS[event.type];
31302
+ if (schema === undefined)
31303
+ return event.payload;
31304
+ const parsed = schema.safeParse(event.payload);
31305
+ return parsed.success ? parsed.data : event.payload;
31306
+ }
31307
+ function parseDispatchFrame(rawPayload) {
31308
+ let value;
31405
31309
  try {
31406
- rawEvent = JSON.parse(envelope.payload);
31310
+ value = JSON.parse(rawPayload);
31407
31311
  } catch {
31408
- return invalid();
31409
- }
31410
- const parsed = DispatchEventSchema.safeParse(rawEvent);
31411
- if (!parsed.success)
31412
- return invalid();
31413
- const event = parsed.data;
31414
- if (event.actor.kind === "session" && event.actor.id === sessionID) {
31415
- return { skip: true, content: "", envelope };
31312
+ return { raw: rawPayload };
31416
31313
  }
31417
- return {
31418
- skip: false,
31419
- content: [
31420
- `dispatch ${event.issue_key} \xB7 ${event.type} \xB7 by ${dispatchActorLabel(event.actor)}`,
31421
- ...dispatchBody(event)
31422
- ].join(`
31423
- `),
31424
- envelope
31425
- };
31314
+ const parsed = DispatchEventSchema.safeParse(value);
31315
+ return parsed.success ? { event: parsed.data } : { raw: value };
31426
31316
  }
31427
31317
  function inboundTimestamp(milliseconds) {
31428
31318
  if (milliseconds === undefined)
@@ -31458,13 +31348,31 @@ function renderInbound(raw, sessionID, subject) {
31458
31348
  const data = tolerant.data;
31459
31349
  envelope = { ...data, source: data.source ?? "unknown" };
31460
31350
  }
31351
+ let dispatchEvent;
31352
+ let dispatchIssue;
31353
+ const dispatchRendered = envelope.source === "dispatch" && envelope.payload !== undefined;
31461
31354
  if (envelope.source === "dispatch") {
31462
- const renderedDispatch = renderDispatch(envelope, sessionID);
31463
- if (renderedDispatch !== null)
31464
- return renderedDispatch;
31355
+ if (envelope.payload === undefined) {
31356
+ dispatchIssue = "payload";
31357
+ } else {
31358
+ const frame = parseDispatchFrame(envelope.payload);
31359
+ if ("event" in frame) {
31360
+ if (frame.event.actor.kind === "session" && frame.event.actor.id === sessionID) {
31361
+ return { skip: true, content: "", envelope };
31362
+ }
31363
+ dispatchEvent = {
31364
+ issue_key: frame.event.issue_key,
31365
+ type: frame.event.type,
31366
+ actor: frame.event.actor,
31367
+ payload: dispatchPayload(frame.event)
31368
+ };
31369
+ } else {
31370
+ dispatchEvent = frame.raw;
31371
+ }
31372
+ }
31465
31373
  }
31466
31374
  let parsedPayload;
31467
- if (envelope.payload !== undefined && (envelope.source === "github" || envelope.payload !== envelope.payload_summary)) {
31375
+ if (!dispatchRendered && envelope.payload !== undefined && (envelope.source === "github" || envelope.payload !== envelope.payload_summary)) {
31468
31376
  try {
31469
31377
  parsedPayload = JSON.parse(envelope.payload);
31470
31378
  } catch {
@@ -31472,7 +31380,7 @@ function renderInbound(raw, sessionID, subject) {
31472
31380
  }
31473
31381
  }
31474
31382
  const payloadSummary = envelope.payload_summary ?? "unknown";
31475
- const message = envelope.payload !== undefined && envelope.payload !== envelope.payload_summary ? parsedPayload : undefined;
31383
+ const message = !dispatchRendered && envelope.payload !== undefined && envelope.payload !== envelope.payload_summary ? parsedPayload : undefined;
31476
31384
  const summaryIsHead = typeof message === "string" && envelope.payload_summary !== undefined && message.startsWith(envelope.payload_summary.endsWith("\u2026") ? envelope.payload_summary.slice(0, -1) : envelope.payload_summary);
31477
31385
  const body = `${envelope.payload_summary ?? ""}
31478
31386
  ${envelope.payload ?? ""}`;
@@ -31486,7 +31394,11 @@ ${envelope.payload ?? ""}`;
31486
31394
  const role = envelope.sender?.roles?.[0];
31487
31395
  const reply = replyWith(envelope);
31488
31396
  const sourceIssue = parseIssues.includes("source") || KNOWN_SOURCES[envelope.source] !== undefined ? undefined : `source=${envelope.source}`;
31489
- const unrecognised = [...parseIssues, ...sourceIssue === undefined ? [] : [sourceIssue]].join(", ") || undefined;
31397
+ const unrecognised = [
31398
+ ...parseIssues,
31399
+ ...sourceIssue === undefined ? [] : [sourceIssue],
31400
+ ...dispatchIssue === undefined ? [] : [dispatchIssue]
31401
+ ].join(", ") || undefined;
31490
31402
  const rendered = {
31491
31403
  ...envelope.topic === agentSubject(sessionID) ? { to: `you (${sessionID.slice(0, 4)}\u2026)` } : {},
31492
31404
  from: senderLabel(envelope),
@@ -31501,8 +31413,10 @@ ${envelope.payload ?? ""}`;
31501
31413
  ...role === undefined ? {} : {
31502
31414
  reply_role: `envoy_publish(topic="notifications.role.${role}", message="...")`
31503
31415
  },
31504
- ...summaryIsHead ? {} : { summary: payloadSummary },
31505
- ...message === undefined ? {} : { message },
31416
+ ...dispatchRendered ? { dispatch: dispatchEvent } : {
31417
+ ...summaryIsHead ? {} : { summary: payloadSummary },
31418
+ ...message === undefined ? {} : { message }
31419
+ },
31506
31420
  ...foreignSession === undefined ? {} : {
31507
31421
  note: `body names session ${foreignSession}; the sender is ${envelope.source_session ?? "unknown"}`
31508
31422
  },
@@ -32425,6 +32339,11 @@ var RoleWireSchema = object({
32425
32339
  holder: string2(),
32426
32340
  last_seen: number2().int()
32427
32341
  });
32342
+ var RoleHeldWireSchema = object({
32343
+ error: string2(),
32344
+ role: string2(),
32345
+ holder: string2()
32346
+ });
32428
32347
  var ErrorWireSchema = object({
32429
32348
  error: string2(),
32430
32349
  expected: array(string2()).optional()
@@ -32557,7 +32476,28 @@ function createEnvoyClient(config) {
32557
32476
  unregisterSession: async (sessionID) => {
32558
32477
  await request(`/v1/sessions/${encodeURIComponent(sessionID)}`, { method: "DELETE" });
32559
32478
  },
32560
- setRole: async (input) => InterestWireSchema.parse(JSON.parse(await post("/v1/roles/set", { session_id: input.sessionID, role: input.role }))),
32479
+ setRole: async (input) => {
32480
+ const soft = input.soft === true;
32481
+ const previous = soft ? input.previousSessionID ?? "" : "";
32482
+ const body = {
32483
+ session_id: input.sessionID,
32484
+ role: input.role,
32485
+ ...soft ? { soft: true } : {},
32486
+ ...previous === "" ? {} : { previous_session_id: previous }
32487
+ };
32488
+ let raw;
32489
+ try {
32490
+ raw = await post("/v1/roles/set", body);
32491
+ } catch (error) {
32492
+ if (error instanceof EnvoyApiError && error.details.status === 409) {
32493
+ const held = RoleHeldWireSchema.safeParse(JSON.parse(error.details.responseBody));
32494
+ if (held.success)
32495
+ return { claimed: false, holder: held.data.holder };
32496
+ }
32497
+ throw error;
32498
+ }
32499
+ return { claimed: true, interest: InterestWireSchema.parse(JSON.parse(raw)) };
32500
+ },
32561
32501
  getRole: async (role) => RoleWireSchema.parse(JSON.parse(await request(`/v1/roles/${encodeURIComponent(role)}`, { method: "GET" }))),
32562
32502
  listSessions: async (input = {}) => {
32563
32503
  const search = new URLSearchParams;
@@ -32645,6 +32585,20 @@ async function copySessionID(copyToClipboard, sessionID) {
32645
32585
  // extensions/envoy.ts
32646
32586
  var codec2 = import_nats.StringCodec();
32647
32587
  var NATS_RETRY_INTERVAL_MS = 15000;
32588
+ var ROLE_CLAIM_ENTRY = "envoy-role-claim";
32589
+ function isRoleClaimEntry(entry) {
32590
+ if (typeof entry !== "object" || entry === null)
32591
+ return false;
32592
+ if (!("type" in entry) || entry.type !== "custom")
32593
+ return false;
32594
+ if (!("customType" in entry) || entry.customType !== ROLE_CLAIM_ENTRY)
32595
+ return false;
32596
+ if (!("data" in entry) || typeof entry.data !== "object" || entry.data === null)
32597
+ return false;
32598
+ if (!("role" in entry.data))
32599
+ return false;
32600
+ return entry.data.role === null || typeof entry.data.role === "string";
32601
+ }
32648
32602
  var LEGION_ROLE_CLAIM_BRIDGE = Symbol.for("legion.pi-envoy.role-claim-bridge");
32649
32603
  function legionRoleClaimBridge() {
32650
32604
  const store = globalThis;
@@ -32842,8 +32796,81 @@ function envoyExtension(pi) {
32842
32796
  await subscribe(topic);
32843
32797
  }
32844
32798
  };
32845
- const establishSession = async (context) => {
32799
+ const setEnvoyRole = async (role, options = {}) => {
32800
+ const topic = ROLE_TOPIC_PREFIX + role;
32801
+ const previousTopic = claimedRoleTopic;
32802
+ const result = await client.setRole({
32803
+ sessionID,
32804
+ role,
32805
+ soft: options.soft,
32806
+ previousSessionID: options.previousSessionID
32807
+ });
32808
+ if (!result.claimed) {
32809
+ logger.warn("envoy: role held by another live session; not reclaimed", {
32810
+ role,
32811
+ sessionID,
32812
+ holder: result.holder
32813
+ });
32814
+ claimedRoleTopic = undefined;
32815
+ return false;
32816
+ }
32817
+ claimedRoleTopic = topic;
32818
+ pi.appendEntry(ROLE_CLAIM_ENTRY, { role });
32819
+ if (activeSessionContext !== undefined)
32820
+ ensureHeartbeat(activeSessionContext);
32821
+ if (previousTopic !== undefined && previousTopic !== topic) {
32822
+ await client.unsubscribe({ sessionID, topics: [previousTopic] });
32823
+ }
32824
+ return true;
32825
+ };
32826
+ const releaseClaimedRole = () => {
32827
+ claimedRoleTopic = undefined;
32828
+ pi.appendEntry(ROLE_CLAIM_ENTRY, { role: null });
32829
+ };
32830
+ const transcriptClaimedRole = (branch) => {
32831
+ let role;
32832
+ for (const entry of branch) {
32833
+ if (!isRoleClaimEntry(entry))
32834
+ continue;
32835
+ role = entry.data.role;
32836
+ }
32837
+ return role;
32838
+ };
32839
+ const roleSources = (previousSessionID, carryPreviousSessionRole) => previousSessionID === "" || previousSessionID === sessionID || !carryPreviousSessionRole ? [sessionID] : [sessionID, previousSessionID];
32840
+ const reclaimHeldRoles = async (previousSessionID, branch, carryPreviousSessionRole) => {
32841
+ if (!carryPreviousSessionRole)
32842
+ claimedRoleTopic = undefined;
32843
+ const remembered = transcriptClaimedRole(branch);
32844
+ if (remembered === null) {
32845
+ claimedRoleTopic = undefined;
32846
+ return;
32847
+ }
32848
+ let role = remembered ?? claimedRoleTopic?.slice(ROLE_TOPIC_PREFIX.length);
32849
+ if (role === undefined) {
32850
+ for (const source of roleSources(previousSessionID, carryPreviousSessionRole)) {
32851
+ const registry = await client.getInterest(source).catch(() => {
32852
+ return;
32853
+ });
32854
+ const topic = registry?.topics.find((candidate) => candidate.startsWith(ROLE_TOPIC_PREFIX));
32855
+ if (topic !== undefined) {
32856
+ role = topic.slice(ROLE_TOPIC_PREFIX.length);
32857
+ break;
32858
+ }
32859
+ }
32860
+ }
32861
+ if (role === undefined)
32862
+ return;
32863
+ const continued = carryPreviousSessionRole && previousSessionID !== "" && previousSessionID !== sessionID ? previousSessionID : undefined;
32864
+ await setEnvoyRole(role, { soft: true, previousSessionID: continued }).catch((error) => {
32865
+ logger.warn("envoy: role reclaim failed", { role, sessionID, error: messageFor(error) });
32866
+ });
32867
+ };
32868
+ let sessionEstablishment = Promise.resolve();
32869
+ const establishSessionNow = async (context, options = {}) => {
32870
+ const previousSessionID = sessionID;
32846
32871
  const previousTopic = sessionID === "" ? undefined : agentSubject(sessionID);
32872
+ if (options.carryPreviousSessionRole === false)
32873
+ claimedRoleTopic = undefined;
32847
32874
  sessionDirectory = context.cwd;
32848
32875
  sessionID = context.sessionManager.getSessionId();
32849
32876
  activeSessionContext = context;
@@ -32859,22 +32886,23 @@ function envoyExtension(pi) {
32859
32886
  }
32860
32887
  await ensureConnection();
32861
32888
  await subscribe(currentTopic);
32862
- if ((context.sessionManager.getBranch?.() ?? []).length > 0) {
32889
+ const branch = context.sessionManager.getBranch?.() ?? [];
32890
+ const resumed = branch.length > 0;
32891
+ if (resumed) {
32863
32892
  await recoverRegisteredInterests();
32864
32893
  }
32865
32894
  await registerSession();
32895
+ if (resumed || previousSessionID !== "") {
32896
+ await reclaimHeldRoles(previousSessionID, branch, options.carryPreviousSessionRole ?? true);
32897
+ }
32866
32898
  ensureHeartbeat(context);
32867
32899
  };
32868
- const setEnvoyRole = async (role) => {
32869
- const topic = ROLE_TOPIC_PREFIX + role;
32870
- const previousTopic = claimedRoleTopic;
32871
- await client.setRole({ sessionID, role });
32872
- claimedRoleTopic = topic;
32873
- if (activeSessionContext !== undefined)
32874
- ensureHeartbeat(activeSessionContext);
32875
- if (previousTopic !== undefined && previousTopic !== topic) {
32876
- await client.unsubscribe({ sessionID, topics: [previousTopic] });
32877
- }
32900
+ const establishSession = (context, options = {}) => {
32901
+ const run = sessionEstablishment.then(() => establishSessionNow(context, options), () => establishSessionNow(context, options));
32902
+ sessionEstablishment = run.catch(() => {
32903
+ return;
32904
+ });
32905
+ return run;
32878
32906
  };
32879
32907
  const bridge = legionRoleClaimBridge();
32880
32908
  const claim = async (targetSessionID, role, callerContext) => {
@@ -32884,8 +32912,8 @@ function envoyExtension(pi) {
32884
32912
  }
32885
32913
  if (sessionID !== targetSessionID)
32886
32914
  await establishSession(context);
32887
- await setEnvoyRole(role);
32888
32915
  await registerSession();
32916
+ await setEnvoyRole(role);
32889
32917
  };
32890
32918
  bridge.claim = claim;
32891
32919
  bridge.ready.resolve(claim);
@@ -32927,7 +32955,8 @@ function envoyExtension(pi) {
32927
32955
  return;
32928
32956
  const previousID = sessionID;
32929
32957
  try {
32930
- await establishSession(context);
32958
+ const carryPreviousSessionRole = reason !== "new" && reason !== "resume";
32959
+ await establishSession(context, { carryPreviousSessionRole });
32931
32960
  } catch (error) {
32932
32961
  context.ui.notify(`envoy: rebind failed (${messageFor(error)}); will recover on reconnect`, "warning");
32933
32962
  }
@@ -33061,12 +33090,12 @@ Warnings: ${warnings.join("; ")}`, {
33061
33090
  ...claimedRoleTopic === undefined ? [] : [claimedRoleTopic]
33062
33091
  ]).filter((topic) => topic !== inbox);
33063
33092
  const removed = targets.filter((topic) => closeIntentionally(topic) || topic === claimedRoleTopic);
33064
- if (claimedRoleTopic !== undefined && removed.includes(claimedRoleTopic)) {
33065
- claimedRoleTopic = undefined;
33066
- }
33093
+ const releasingRole = claimedRoleTopic !== undefined && removed.includes(claimedRoleTopic);
33067
33094
  const registrationError = removed.length === 0 ? undefined : await client.unsubscribe({ sessionID, topics: removed }).then(registerSession).then(() => {
33068
33095
  return;
33069
33096
  }, messageFor);
33097
+ if (releasingRole && registrationError === undefined)
33098
+ releaseClaimedRole();
33070
33099
  return toolSuccess(`Unsubscribed: ${removed.join(", ") || "(none)"}`, {
33071
33100
  removed,
33072
33101
  ...registrationError === undefined ? {} : { registrationError }
@@ -123,6 +123,21 @@ Publish to a role; do not subscribe as its holder. A successful `envoy_publish`
123
123
  its live `holder`; an unheld role returns an error. Use `envoy_role_get(role="reviewer")` to find
124
124
  the live holder first when you need one.
125
125
 
126
+ A claim belongs to the session, not the process. `omp --resume` re-claims the role recorded in
127
+ the session transcript, including after the listener's stale-interest reaper has removed the old
128
+ row. A `/fork`, `/branch`, `/handoff`, or other transcript-carrying switch checks the
129
+ previous id and moves any held role to the new id while establishing the new session. A `/new`
130
+ or `/resume` switch installs an unrelated transcript and carries nothing. A process that dies and
131
+ is never resumed does not stay a live holder; once liveness expires, role publishes return
132
+ `no holder` until that transcript resumes or another session claims the role.
133
+
134
+ Automatic reclaim never steals. It is a *soft* claim, which the listener grants only when the role
135
+ is unheld, held by a session that is no longer live, or held by the id this session continues (a
136
+ fork's parent). If a different live session holds it — the parent of a `/fork` whose role moved to
137
+ the child, or a second process on the same transcript — the listener refuses atomically and the
138
+ resumed session logs a warning and holds nothing. Only an explicit `envoy_role_set` is
139
+ last-claim-wins. Re-running `envoy_role_set` for a role this session already holds is harmless.
140
+
126
141
  ## Legion role claims
127
142
 
128
143
  Legion agents receive through a daemon-minted role token. Claim the assigned role with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion-envoy",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "omp": {
6
6
  "extensions": [