@sjawhar/pi-legion-envoy 0.16.1 → 0.18.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
@@ -16117,7 +16117,7 @@ import { dirname, resolve } from "path";
16117
16117
  import { fileURLToPath } from "url";
16118
16118
 
16119
16119
  // ../contracts/src/dispatch-tools.ts
16120
- var ISSUE_REFERENCE = "An issue is a native KEY or external owner/repo#n reference; an external reference creates its native issue on first use when its repository is mapped in DISPATCH_REPO_PROJECTS.";
16120
+ var ISSUE_REFERENCE = "An issue is a native KEY or external owner/repo#n reference; an external reference creates its native issue on first use in the repository's mapped project (DISPATCH_REPO_PROJECTS) or, failing that, the default project (DISPATCH_DEFAULT_PROJECT).";
16121
16121
  var ASK_URGENCIES = ["low", "med", "high", "blocking"];
16122
16122
  var DOC_EDIT_OPS = ["replace", "delete", "insert"];
16123
16123
  var dispatchToolSpecs = [
@@ -31684,7 +31684,7 @@ async function ensureIssue(client, issueReference, actor) {
31684
31684
  } catch (error) {
31685
31685
  if (error instanceof DispatchServiceError && error.code === "PROJECT_UNMAPPED") {
31686
31686
  const repository = issueReference.slice(0, issueReference.lastIndexOf("#"));
31687
- throw new Error(`repository ${repository} is not mapped in DISPATCH_REPO_PROJECTS`);
31687
+ throw new Error(`repository ${repository} is not mapped in DISPATCH_REPO_PROJECTS and no DISPATCH_DEFAULT_PROJECT is configured`);
31688
31688
  }
31689
31689
  throw error;
31690
31690
  }
@@ -31849,6 +31849,11 @@ var RoleWireSchema = object({
31849
31849
  holder: string2(),
31850
31850
  last_seen: number2().int()
31851
31851
  });
31852
+ var RoleHeldWireSchema = object({
31853
+ error: string2(),
31854
+ role: string2(),
31855
+ holder: string2()
31856
+ });
31852
31857
  var ErrorWireSchema = object({
31853
31858
  error: string2(),
31854
31859
  expected: array(string2()).optional()
@@ -31981,7 +31986,28 @@ function createEnvoyClient(config) {
31981
31986
  unregisterSession: async (sessionID) => {
31982
31987
  await request(`/v1/sessions/${encodeURIComponent(sessionID)}`, { method: "DELETE" });
31983
31988
  },
31984
- 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
+ },
31985
32011
  getRole: async (role) => RoleWireSchema.parse(JSON.parse(await request(`/v1/roles/${encodeURIComponent(role)}`, { method: "GET" }))),
31986
32012
  listSessions: async (input = {}) => {
31987
32013
  const search = new URLSearchParams;
@@ -32077,6 +32103,20 @@ async function copySessionID(copyToClipboard, sessionID) {
32077
32103
  // extensions/envoy.ts
32078
32104
  var codec2 = import_nats.StringCodec();
32079
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
+ }
32080
32120
  var LEGION_ROLE_CLAIM_BRIDGE = Symbol.for("legion.pi-envoy.role-claim-bridge");
32081
32121
  function legionRoleClaimBridge() {
32082
32122
  const store = globalThis;
@@ -32274,8 +32314,81 @@ function envoyExtension(pi) {
32274
32314
  await subscribe(topic);
32275
32315
  }
32276
32316
  };
32277
- 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;
32278
32389
  const previousTopic = sessionID === "" ? undefined : agentSubject(sessionID);
32390
+ if (options.carryPreviousSessionRole === false)
32391
+ claimedRoleTopic = undefined;
32279
32392
  sessionDirectory = context.cwd;
32280
32393
  sessionID = context.sessionManager.getSessionId();
32281
32394
  activeSessionContext = context;
@@ -32291,22 +32404,23 @@ function envoyExtension(pi) {
32291
32404
  }
32292
32405
  await ensureConnection();
32293
32406
  await subscribe(currentTopic);
32294
- if ((context.sessionManager.getBranch?.() ?? []).length > 0) {
32407
+ const branch = context.sessionManager.getBranch?.() ?? [];
32408
+ const resumed = branch.length > 0;
32409
+ if (resumed) {
32295
32410
  await recoverRegisteredInterests();
32296
32411
  }
32297
32412
  await registerSession();
32413
+ if (resumed || previousSessionID !== "") {
32414
+ await reclaimHeldRoles(previousSessionID, branch, options.carryPreviousSessionRole ?? true);
32415
+ }
32298
32416
  ensureHeartbeat(context);
32299
32417
  };
32300
- const setEnvoyRole = async (role) => {
32301
- const topic = ROLE_TOPIC_PREFIX + role;
32302
- const previousTopic = claimedRoleTopic;
32303
- await client.setRole({ sessionID, role });
32304
- claimedRoleTopic = topic;
32305
- if (activeSessionContext !== undefined)
32306
- ensureHeartbeat(activeSessionContext);
32307
- if (previousTopic !== undefined && previousTopic !== topic) {
32308
- await client.unsubscribe({ sessionID, topics: [previousTopic] });
32309
- }
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;
32310
32424
  };
32311
32425
  const bridge = legionRoleClaimBridge();
32312
32426
  const claim = async (targetSessionID, role, callerContext) => {
@@ -32316,8 +32430,8 @@ function envoyExtension(pi) {
32316
32430
  }
32317
32431
  if (sessionID !== targetSessionID)
32318
32432
  await establishSession(context);
32319
- await setEnvoyRole(role);
32320
32433
  await registerSession();
32434
+ await setEnvoyRole(role);
32321
32435
  };
32322
32436
  bridge.claim = claim;
32323
32437
  bridge.ready.resolve(claim);
@@ -32359,7 +32473,8 @@ function envoyExtension(pi) {
32359
32473
  return;
32360
32474
  const previousID = sessionID;
32361
32475
  try {
32362
- await establishSession(context);
32476
+ const carryPreviousSessionRole = reason !== "new" && reason !== "resume";
32477
+ await establishSession(context, { carryPreviousSessionRole });
32363
32478
  } catch (error) {
32364
32479
  context.ui.notify(`envoy: rebind failed (${messageFor(error)}); will recover on reconnect`, "warning");
32365
32480
  }
@@ -32493,12 +32608,12 @@ Warnings: ${warnings.join("; ")}`, {
32493
32608
  ...claimedRoleTopic === undefined ? [] : [claimedRoleTopic]
32494
32609
  ]).filter((topic) => topic !== inbox);
32495
32610
  const removed = targets.filter((topic) => closeIntentionally(topic) || topic === claimedRoleTopic);
32496
- if (claimedRoleTopic !== undefined && removed.includes(claimedRoleTopic)) {
32497
- claimedRoleTopic = undefined;
32498
- }
32611
+ const releasingRole = claimedRoleTopic !== undefined && removed.includes(claimedRoleTopic);
32499
32612
  const registrationError = removed.length === 0 ? undefined : await client.unsubscribe({ sessionID, topics: removed }).then(registerSession).then(() => {
32500
32613
  return;
32501
32614
  }, messageFor);
32615
+ if (releasingRole && registrationError === undefined)
32616
+ releaseClaimedRole();
32502
32617
  return toolSuccess(`Unsubscribed: ${removed.join(", ") || "(none)"}`, {
32503
32618
  removed,
32504
32619
  ...registrationError === undefined ? {} : { registrationError }
package/dist/legion.js CHANGED
@@ -16115,7 +16115,7 @@ var require_mod4 = __commonJS(function(exports) {
16115
16115
  import path4 from "path";
16116
16116
 
16117
16117
  // ../contracts/src/dispatch-tools.ts
16118
- var ISSUE_REFERENCE = "An issue is a native KEY or external owner/repo#n reference; an external reference creates its native issue on first use when its repository is mapped in DISPATCH_REPO_PROJECTS.";
16118
+ var ISSUE_REFERENCE = "An issue is a native KEY or external owner/repo#n reference; an external reference creates its native issue on first use in the repository's mapped project (DISPATCH_REPO_PROJECTS) or, failing that, the default project (DISPATCH_DEFAULT_PROJECT).";
16119
16119
  var ASK_URGENCIES = ["low", "med", "high", "blocking"];
16120
16120
  var DOC_EDIT_OPS = ["replace", "delete", "insert"];
16121
16121
  var dispatchToolSpecs = [
@@ -32174,7 +32174,7 @@ async function ensureIssue(client, issueReference, actor) {
32174
32174
  } catch (error) {
32175
32175
  if (error instanceof DispatchServiceError && error.code === "PROJECT_UNMAPPED") {
32176
32176
  const repository = issueReference.slice(0, issueReference.lastIndexOf("#"));
32177
- throw new Error(`repository ${repository} is not mapped in DISPATCH_REPO_PROJECTS`);
32177
+ throw new Error(`repository ${repository} is not mapped in DISPATCH_REPO_PROJECTS and no DISPATCH_DEFAULT_PROJECT is configured`);
32178
32178
  }
32179
32179
  throw error;
32180
32180
  }
@@ -32339,6 +32339,11 @@ var RoleWireSchema = object({
32339
32339
  holder: string2(),
32340
32340
  last_seen: number2().int()
32341
32341
  });
32342
+ var RoleHeldWireSchema = object({
32343
+ error: string2(),
32344
+ role: string2(),
32345
+ holder: string2()
32346
+ });
32342
32347
  var ErrorWireSchema = object({
32343
32348
  error: string2(),
32344
32349
  expected: array(string2()).optional()
@@ -32471,7 +32476,28 @@ function createEnvoyClient(config) {
32471
32476
  unregisterSession: async (sessionID) => {
32472
32477
  await request(`/v1/sessions/${encodeURIComponent(sessionID)}`, { method: "DELETE" });
32473
32478
  },
32474
- 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
+ },
32475
32501
  getRole: async (role) => RoleWireSchema.parse(JSON.parse(await request(`/v1/roles/${encodeURIComponent(role)}`, { method: "GET" }))),
32476
32502
  listSessions: async (input = {}) => {
32477
32503
  const search = new URLSearchParams;
@@ -32559,6 +32585,20 @@ async function copySessionID(copyToClipboard, sessionID) {
32559
32585
  // extensions/envoy.ts
32560
32586
  var codec2 = import_nats.StringCodec();
32561
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
+ }
32562
32602
  var LEGION_ROLE_CLAIM_BRIDGE = Symbol.for("legion.pi-envoy.role-claim-bridge");
32563
32603
  function legionRoleClaimBridge() {
32564
32604
  const store = globalThis;
@@ -32756,8 +32796,81 @@ function envoyExtension(pi) {
32756
32796
  await subscribe(topic);
32757
32797
  }
32758
32798
  };
32759
- 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;
32760
32871
  const previousTopic = sessionID === "" ? undefined : agentSubject(sessionID);
32872
+ if (options.carryPreviousSessionRole === false)
32873
+ claimedRoleTopic = undefined;
32761
32874
  sessionDirectory = context.cwd;
32762
32875
  sessionID = context.sessionManager.getSessionId();
32763
32876
  activeSessionContext = context;
@@ -32773,22 +32886,23 @@ function envoyExtension(pi) {
32773
32886
  }
32774
32887
  await ensureConnection();
32775
32888
  await subscribe(currentTopic);
32776
- if ((context.sessionManager.getBranch?.() ?? []).length > 0) {
32889
+ const branch = context.sessionManager.getBranch?.() ?? [];
32890
+ const resumed = branch.length > 0;
32891
+ if (resumed) {
32777
32892
  await recoverRegisteredInterests();
32778
32893
  }
32779
32894
  await registerSession();
32895
+ if (resumed || previousSessionID !== "") {
32896
+ await reclaimHeldRoles(previousSessionID, branch, options.carryPreviousSessionRole ?? true);
32897
+ }
32780
32898
  ensureHeartbeat(context);
32781
32899
  };
32782
- const setEnvoyRole = async (role) => {
32783
- const topic = ROLE_TOPIC_PREFIX + role;
32784
- const previousTopic = claimedRoleTopic;
32785
- await client.setRole({ sessionID, role });
32786
- claimedRoleTopic = topic;
32787
- if (activeSessionContext !== undefined)
32788
- ensureHeartbeat(activeSessionContext);
32789
- if (previousTopic !== undefined && previousTopic !== topic) {
32790
- await client.unsubscribe({ sessionID, topics: [previousTopic] });
32791
- }
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;
32792
32906
  };
32793
32907
  const bridge = legionRoleClaimBridge();
32794
32908
  const claim = async (targetSessionID, role, callerContext) => {
@@ -32798,8 +32912,8 @@ function envoyExtension(pi) {
32798
32912
  }
32799
32913
  if (sessionID !== targetSessionID)
32800
32914
  await establishSession(context);
32801
- await setEnvoyRole(role);
32802
32915
  await registerSession();
32916
+ await setEnvoyRole(role);
32803
32917
  };
32804
32918
  bridge.claim = claim;
32805
32919
  bridge.ready.resolve(claim);
@@ -32841,7 +32955,8 @@ function envoyExtension(pi) {
32841
32955
  return;
32842
32956
  const previousID = sessionID;
32843
32957
  try {
32844
- await establishSession(context);
32958
+ const carryPreviousSessionRole = reason !== "new" && reason !== "resume";
32959
+ await establishSession(context, { carryPreviousSessionRole });
32845
32960
  } catch (error) {
32846
32961
  context.ui.notify(`envoy: rebind failed (${messageFor(error)}); will recover on reconnect`, "warning");
32847
32962
  }
@@ -32975,12 +33090,12 @@ Warnings: ${warnings.join("; ")}`, {
32975
33090
  ...claimedRoleTopic === undefined ? [] : [claimedRoleTopic]
32976
33091
  ]).filter((topic) => topic !== inbox);
32977
33092
  const removed = targets.filter((topic) => closeIntentionally(topic) || topic === claimedRoleTopic);
32978
- if (claimedRoleTopic !== undefined && removed.includes(claimedRoleTopic)) {
32979
- claimedRoleTopic = undefined;
32980
- }
33093
+ const releasingRole = claimedRoleTopic !== undefined && removed.includes(claimedRoleTopic);
32981
33094
  const registrationError = removed.length === 0 ? undefined : await client.unsubscribe({ sessionID, topics: removed }).then(registerSession).then(() => {
32982
33095
  return;
32983
33096
  }, messageFor);
33097
+ if (releasingRole && registrationError === undefined)
33098
+ releaseClaimedRole();
32984
33099
  return toolSuccess(`Unsubscribed: ${removed.join(", ") || "(none)"}`, {
32985
33100
  removed,
32986
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.1",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "omp": {
6
6
  "extensions": [