@sjawhar/pi-envoy 10.6.2 → 10.7.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/README.md CHANGED
@@ -12,7 +12,8 @@ described in `packages/pi-shared/AGENTS.md`.
12
12
  Normal topic subscriptions are direct NATS subscriptions owned by this extension. A role claim is
13
13
  different: the listener arbitrates the core-NATS role lane for the current live holder, then sends
14
14
  a receipt-backed request with the original role topic to the holder's direct agent subject.
15
- `envoy_unsubscribe` manages only normal topic subscriptions; it does not relinquish a held role.
15
+ `envoy_unsubscribe` manages only normal topic subscriptions; it does not relinquish a held role,
16
+ and it keeps open, listing them as kept, the subscriptions another plugin holds for that role.
16
17
  The agent pump replies after accepting the envelope; without a receipt within two seconds the
17
18
  listener emits a `delivery_failed` exception. Role messages are live only; they are not retained
18
19
  for a later claimant.
package/dist/envoy.js CHANGED
@@ -34054,7 +34054,7 @@ var envoyToolSpecs = [
34054
34054
  },
34055
34055
  {
34056
34056
  name: "envoy_unsubscribe",
34057
- description: "Unsubscribe this session from Envoy topics, or remove all current subscriptions if topics are omitted.",
34057
+ description: "Unsubscribe this session from Envoy topics, or remove all current subscriptions if topics are omitted. A subscription another plugin holds for this session's role stays, and the result lists it as kept.",
34058
34058
  arguments: (schema) => ({ topics: schema.array(schema.string()).optional() }),
34059
34059
  operation: EnvoyToolOperation.unsubscribe
34060
34060
  },
@@ -34087,7 +34087,7 @@ var envoyToolSpecs = [
34087
34087
  },
34088
34088
  {
34089
34089
  name: "envoy_role_set",
34090
- description: "Set the current session as the holder of a named role. Messages published to notifications.role.<role> route to this session.",
34090
+ description: "Set the current session as the holder of a named role. Messages published to notifications.role.<role> route to this session. Moving to a different role is refused while another plugin holds subscriptions that last only as long as this session keeps its current role.",
34091
34091
  arguments: (schema) => ({ role: schema.string() }),
34092
34092
  operation: EnvoyToolOperation.setRole
34093
34093
  },
@@ -35266,6 +35266,19 @@ import("@oh-my-pi/pi-coding-agent/extensibility/extensions/send-user-input-handl
35266
35266
  var codec2 = import_nats2.StringCodec();
35267
35267
  var NATS_RETRY_INTERVAL_MS = 15000;
35268
35268
  var DELIVERY_PICTURES_TIMEOUT_MS = 20000;
35269
+ function serialQueue() {
35270
+ let tail = Promise.resolve();
35271
+ return {
35272
+ run(task) {
35273
+ const next = tail.then(task);
35274
+ tail = next.catch(() => {
35275
+ return;
35276
+ });
35277
+ return next;
35278
+ },
35279
+ settled: () => tail
35280
+ };
35281
+ }
35269
35282
  function timedDispatchClient(timeoutMs) {
35270
35283
  const config2 = activeDispatchSettings(sessionDispatchSettings(CONFIG_FILE, process.env, { cwd: process.cwd() }).dispatch);
35271
35284
  return config2 === null ? undefined : new DispatchClient(config2.url, config2.token, fetch, AbortSignal.timeout(timeoutMs));
@@ -35332,6 +35345,20 @@ function isLegionManagedEntry(entry) {
35332
35345
  return false;
35333
35346
  return "session_id" in entry.data && typeof entry.data.session_id === "string";
35334
35347
  }
35348
+
35349
+ class SessionLeftError extends Error {
35350
+ }
35351
+
35352
+ class RegistrationError extends Error {
35353
+ }
35354
+
35355
+ class InboundError extends Error {
35356
+ decided;
35357
+ constructor(message, decided, options) {
35358
+ super(message, options);
35359
+ this.decided = decided;
35360
+ }
35361
+ }
35335
35362
  function isRoleClaimEntry(entry) {
35336
35363
  if (typeof entry !== "object" || entry === null)
35337
35364
  return false;
@@ -35401,10 +35428,31 @@ function envoyExtension(pi) {
35401
35428
  let sessionID = "";
35402
35429
  let recordedSessionKey;
35403
35430
  let heartbeatRegistered = false;
35431
+ let announced;
35432
+ let runUnderAnnouncement;
35433
+ const continues = new Map;
35434
+ const continuedBy = (id) => {
35435
+ const lineage = [id];
35436
+ for (let at = continues.get(id);at !== undefined && !lineage.includes(at); ) {
35437
+ lineage.push(at);
35438
+ at = continues.get(at);
35439
+ }
35440
+ return lineage;
35441
+ };
35442
+ let establishedSession = "";
35443
+ const notifiedSessions = new Set;
35404
35444
  let claimedRoleTopic;
35405
35445
  let claimedRoleSessionID;
35446
+ let holdUnconfirmed = false;
35447
+ const pendingHandBacks = new Map;
35448
+ const abandonedHolds = new Map;
35449
+ const refusedRoles = new Map;
35450
+ let roleNoticesFor;
35406
35451
  const roleNoticeSubjects = new Set;
35407
35452
  let roleEnds = 0;
35453
+ const roleMoves = serialQueue();
35454
+ const isRoleBound = (subject2) => roleNoticeSubjects.has(subject2);
35455
+ const heldRole = () => claimedRoleTopic?.slice(ROLE_TOPIC_PREFIX.length);
35408
35456
  let activeSessionContext;
35409
35457
  const inbox = [];
35410
35458
  let askAwareness = {
@@ -35741,9 +35789,25 @@ function envoyExtension(pi) {
35741
35789
  subscriptions.delete(topic);
35742
35790
  return true;
35743
35791
  };
35792
+ const lineageOf = (id) => {
35793
+ const lineage = continuedBy(id);
35794
+ return announced !== undefined && announced.from !== id && carriesSession(announced.reason) && !lineage.includes(announced.from) ? [...lineage, announced.from] : lineage;
35795
+ };
35796
+ const replacedSessionTopic = (topic) => {
35797
+ if (!topic.startsWith(agentSubject("")))
35798
+ return false;
35799
+ const host3 = hostSessionID();
35800
+ return host3 !== "" && !lineageOf(host3).some((id) => topic === agentSubject(id));
35801
+ };
35744
35802
  const pump = async (topic, subscription) => {
35745
35803
  try {
35746
35804
  for await (const message of subscription) {
35805
+ if (replacedSessionTopic(topic)) {
35806
+ logger3.warn("envoy: frame for a session a switch replaced left undelivered", {
35807
+ subject: message.subject
35808
+ });
35809
+ continue;
35810
+ }
35747
35811
  try {
35748
35812
  await deliver(message.subject, codec2.decode(message.data), message.reply ?? "");
35749
35813
  } catch {}
@@ -35913,12 +35977,27 @@ function envoyExtension(pi) {
35913
35977
  closeIntentionally(subject2);
35914
35978
  roleNoticeSubjects.clear();
35915
35979
  };
35916
- const endOutgoingRole = () => {
35917
- if (claimedRoleSessionID !== sessionID)
35918
- endRole();
35980
+ const handBackRole = async () => {
35981
+ const role = heldRole();
35982
+ const holdID = claimedRoleSessionID;
35983
+ const topic = claimedRoleTopic;
35984
+ if (role === undefined || topic === undefined)
35985
+ return;
35986
+ const pluginHeld = roleNoticeSubjects.size > 0;
35987
+ endRole();
35988
+ if (holdID !== undefined && holdID !== "") {
35989
+ await requireEnvoy().client.unsubscribe({ sessionID: holdID, topics: [topic] }).catch((error48) => logger3.warn("envoy: a role this process gave up was not handed back", {
35990
+ role,
35991
+ sessionID: holdID,
35992
+ error: messageFor(error48)
35993
+ }));
35994
+ }
35995
+ return { role, pluginHeld };
35919
35996
  };
35997
+ const hostSessionID = () => activeSessionContext?.sessionManager.getSessionId() ?? sessionID;
35998
+ const sessionOpen = (id) => sessionID === id && hostSessionID() === id;
35920
35999
  const roleSnapshot = (topic) => ({
35921
- roleBound: expandSubscriptionTopics([topic]).some((subject2) => roleNoticeSubjects.has(subject2)),
36000
+ roleBound: expandSubscriptionTopics([topic]).some(isRoleBound),
35922
36001
  endsBefore: roleEnds
35923
36002
  });
35924
36003
  const subscribeUnlessRoleEnds = async (topic, snapshot) => {
@@ -35932,54 +36011,58 @@ function envoyExtension(pi) {
35932
36011
  closeIntentionally(subject2);
35933
36012
  return false;
35934
36013
  };
35935
- const registerSession = () => requireEnvoy().client.subscribe({
35936
- sessionID,
35937
- directory: sessionDirectory2,
35938
- topics: [
35939
- ...new Set([
35940
- agentSubject(sessionID),
35941
- ...[...subscriptions.keys()].filter((subject2) => !roleNoticeSubjects.has(subject2))
35942
- ])
35943
- ],
35944
- port: 0,
35945
- title: activeSessionContext?.sessionManager.getSessionName?.() ?? "",
35946
- capabilities: sideTurn(activeSessionContext) === undefined ? CAPABILITIES_WITHOUT_BTW : DELIVERY_CAPABILITIES,
35947
- driving: false,
35948
- selfSubscribed: true
35949
- });
35950
- const reassertRole = async (afterOutage, context) => {
35951
- const topic = claimedRoleTopic;
35952
- if (topic === undefined)
35953
- return;
35954
- const id = sessionID;
35955
- const role = topic.slice(ROLE_TOPIC_PREFIX.length);
36014
+ const registerSession = () => {
36015
+ for (const [topic, hold] of abandonedHolds) {
36016
+ if (hold.id !== sessionID)
36017
+ continue;
36018
+ abandonedHolds.delete(topic);
36019
+ pendingHandBacks.set(`${topic}@${sessionID}`, { topic, id: sessionID });
36020
+ }
36021
+ return requireEnvoy().client.subscribe({
36022
+ sessionID,
36023
+ directory: sessionDirectory2,
36024
+ topics: [
36025
+ ...new Set([
36026
+ agentSubject(sessionID),
36027
+ ...[...subscriptions.keys()].filter((subject2) => !isRoleBound(subject2))
36028
+ ])
36029
+ ],
36030
+ port: 0,
36031
+ title: activeSessionContext?.sessionManager.getSessionName?.() ?? "",
36032
+ capabilities: sideTurn(activeSessionContext) === undefined ? CAPABILITIES_WITHOUT_BTW : DELIVERY_CAPABILITIES,
36033
+ driving: false,
36034
+ selfSubscribed: true
36035
+ });
36036
+ };
36037
+ const reassertRoleNow = async (id, role, afterOutage, context) => {
36038
+ const topic = ROLE_TOPIC_PREFIX + role;
35956
36039
  const { client } = requireEnvoy();
35957
36040
  const holder = await client.getRole(role).then((info) => info.holder, (error48) => {
35958
36041
  if (error48 instanceof EnvoyApiError && error48.details.status === 404)
35959
36042
  return;
35960
36043
  throw error48;
35961
36044
  });
36045
+ if (claimedRoleTopic !== topic || !sessionOpen(id))
36046
+ return false;
35962
36047
  let reason;
35963
36048
  if (holder === id) {
35964
36049
  if (!afterOutage)
35965
- return;
36050
+ return true;
35966
36051
  reason = "reregistered";
35967
36052
  } else {
35968
36053
  const result = await client.setRole({ sessionID: id, role, soft: true });
35969
- if (claimedRoleTopic !== topic || sessionID !== id) {
35970
- if (result.claimed)
35971
- await client.unsubscribe({ sessionID: id, topics: [topic] });
35972
- return;
35973
- }
36054
+ if (claimedRoleTopic !== topic || !sessionOpen(id))
36055
+ return false;
35974
36056
  if (!result.claimed) {
35975
36057
  endRole();
36058
+ refusedRoles.set(id, role);
35976
36059
  logger3.warn("envoy: role re-assertion refused; held by another live session", {
35977
36060
  role,
35978
36061
  sessionID: id,
35979
36062
  holder: result.holder
35980
36063
  });
35981
36064
  context.ui.notify(`envoy: role ${role} is now held by session ${result.holder}; this session no longer holds it`, "warning");
35982
- return;
36065
+ return true;
35983
36066
  }
35984
36067
  logger3.warn("envoy: role re-asserted after the listener lost the claim", {
35985
36068
  role,
@@ -35990,10 +36073,11 @@ function envoyExtension(pi) {
35990
36073
  }
35991
36074
  const regained = legionRoleClaimBridge().regained;
35992
36075
  if (regained === undefined)
35993
- return;
36076
+ return true;
35994
36077
  Promise.resolve().then(() => regained(role, reason)).catch((error48) => {
35995
36078
  logger3.warn("envoy: role regain hook failed", { role, reason, error: messageFor(error48) });
35996
36079
  });
36080
+ return true;
35997
36081
  };
35998
36082
  const ensureHeartbeat = (context) => {
35999
36083
  if (heartbeatRegistered)
@@ -36002,24 +36086,23 @@ function envoyExtension(pi) {
36002
36086
  let registrationFailed = false;
36003
36087
  let healing = false;
36004
36088
  context.setInterval(() => {
36005
- const liveSessionID = context.sessionManager.getSessionId();
36006
- if (liveSessionID === "")
36089
+ if (context.sessionManager.getSessionId() === "")
36007
36090
  return;
36008
- const drifted = liveSessionID !== sessionID;
36009
36091
  if (healing)
36010
36092
  return;
36011
36093
  healing = true;
36012
- if (drifted) {
36013
- bindDispatchSession(liveSessionID);
36014
- procmgr.refreshShellConfigCache();
36015
- }
36016
36094
  const afterOutage = registrationFailed;
36017
- (drifted ? establishSession(context) : registerSession()).then(() => drifted ? undefined : reassertRole(afterOutage, context), (error48) => {
36018
- registrationFailed = true;
36019
- throw error48;
36020
- }).then(() => {
36021
- registrationFailed = false;
36095
+ const settled = (decided) => {
36096
+ if (decided)
36097
+ registrationFailed = false;
36022
36098
  heartbeatOutageNotified = false;
36099
+ };
36100
+ reconcile(context, { heartbeat: true, afterOutage }).then(settled, (error48) => {
36101
+ if (error48 instanceof InboundError)
36102
+ return settled(error48.decided);
36103
+ if (error48 instanceof RegistrationError)
36104
+ registrationFailed = true;
36105
+ throw error48 instanceof RegistrationError ? error48.cause : error48;
36023
36106
  }).catch((error48) => {
36024
36107
  if (heartbeatOutageNotified)
36025
36108
  return;
@@ -36056,34 +36139,75 @@ function envoyExtension(pi) {
36056
36139
  await subscribe(topic);
36057
36140
  }
36058
36141
  };
36059
- const setEnvoyRole = async (role, options = {}) => {
36142
+ const observeAbandonedHold = async (topic, role, holder) => {
36143
+ const abandoned = abandonedHolds.get(topic);
36144
+ if (abandoned === undefined || abandoned.id !== holder)
36145
+ return false;
36146
+ const seen = await requireEnvoy().client.getRole(role).then((info) => info.holder === holder ? info.last_seen : undefined, () => {
36147
+ return;
36148
+ });
36149
+ if (seen === undefined)
36150
+ return true;
36151
+ if (abandoned.lastSeen !== undefined && seen !== abandoned.lastSeen) {
36152
+ abandonedHolds.delete(topic);
36153
+ return false;
36154
+ }
36155
+ abandonedHolds.set(topic, { id: abandoned.id, lastSeen: seen });
36156
+ return true;
36157
+ };
36158
+ const setEnvoyRole = async (role, forSession, options = {}) => {
36159
+ if (!sessionOpen(forSession)) {
36160
+ throw new SessionLeftError(`the session moved from ${forSession} to ${hostSessionID()} before its claim of role ${role} ran; nothing was claimed`);
36161
+ }
36060
36162
  const topic = ROLE_TOPIC_PREFIX + role;
36061
36163
  const previousTopic = claimedRoleTopic;
36164
+ const previousHoldID = claimedRoleSessionID;
36165
+ const id = forSession;
36062
36166
  const { client } = requireEnvoy();
36063
36167
  const result = await client.setRole({
36064
- sessionID,
36168
+ sessionID: id,
36065
36169
  role,
36066
36170
  soft: options.soft,
36067
36171
  previousSessionID: options.previousSessionID
36068
36172
  });
36173
+ if (result.claimed) {
36174
+ refusedRoles.delete(id);
36175
+ holdUnconfirmed = false;
36176
+ abandonedHolds.delete(topic);
36177
+ for (const [key, hold] of pendingHandBacks) {
36178
+ if (hold.topic === topic)
36179
+ pendingHandBacks.delete(key);
36180
+ }
36181
+ }
36182
+ if (!sessionOpen(id)) {
36183
+ if (result.claimed) {
36184
+ if (previousTopic !== undefined && previousTopic !== topic)
36185
+ endRole();
36186
+ claimedRoleTopic = topic;
36187
+ claimedRoleSessionID = id;
36188
+ if (previousTopic !== undefined && previousTopic !== topic && previousHoldID) {
36189
+ await client.unsubscribe({ sessionID: previousHoldID, topics: [previousTopic] });
36190
+ }
36191
+ }
36192
+ throw new SessionLeftError(`the session moved from ${id} to ${hostSessionID()} while its claim of role ${role} was in flight; ${result.claimed ? "the next pass keeps the hold for the session the host is on, or hands it back" : "nothing was claimed"}`);
36193
+ }
36069
36194
  if (!result.claimed) {
36070
- logger3.warn("envoy: role held by another live session; not reclaimed", {
36071
- role,
36072
- sessionID,
36073
- holder: result.holder
36074
- });
36075
36195
  endRole();
36196
+ const waitingOnReap = await observeAbandonedHold(topic, role, result.holder);
36197
+ if (!waitingOnReap)
36198
+ refusedRoles.set(id, role);
36199
+ logger3.warn(waitingOnReap ? "envoy: role still held by a session this process left; claiming again once the listener reaps it" : "envoy: role held by another live session; not reclaimed", { role, sessionID: id, holder: result.holder });
36076
36200
  return false;
36077
36201
  }
36078
36202
  if (previousTopic !== undefined && previousTopic !== topic)
36079
36203
  endRole();
36080
36204
  claimedRoleTopic = topic;
36081
- claimedRoleSessionID = sessionID;
36205
+ claimedRoleSessionID = id;
36082
36206
  pi.appendEntry(ROLE_CLAIM_ENTRY, { role });
36083
36207
  if (activeSessionContext !== undefined)
36084
36208
  ensureHeartbeat(activeSessionContext);
36085
- if (previousTopic !== undefined && previousTopic !== topic) {
36086
- await client.unsubscribe({ sessionID, topics: [previousTopic] });
36209
+ if (previousTopic !== undefined && previousTopic !== topic && previousHoldID) {
36210
+ await client.unsubscribe({ sessionID: previousHoldID, topics: [previousTopic] });
36087
36211
  }
36088
36212
  return true;
36089
36213
  };
@@ -36096,73 +36220,271 @@ function envoyExtension(pi) {
36096
36220
  }
36097
36221
  return role;
36098
36222
  };
36099
- const roleSources = (previousSessionID, carryPreviousSessionRole) => previousSessionID === "" || previousSessionID === sessionID || !carryPreviousSessionRole ? [sessionID] : [sessionID, previousSessionID];
36100
- const reclaimHeldRoles = async (previousSessionID, branch, carryPreviousSessionRole) => {
36101
- if (!carryPreviousSessionRole)
36102
- endOutgoingRole();
36103
- const remembered = transcriptClaimedRole(branch);
36104
- if (remembered === null) {
36105
- endRole();
36223
+ const desiredRole = async (id, branch, readRows) => {
36224
+ const want = (role) => refusedRoles.get(id) === role ? undefined : role;
36225
+ const recorded = transcriptClaimedRole(branch);
36226
+ if (recorded === null)
36106
36227
  return;
36228
+ if (recorded !== undefined)
36229
+ return want(recorded);
36230
+ const held = heldRole();
36231
+ const holdID = claimedRoleSessionID;
36232
+ const lineage = continuedBy(id);
36233
+ if (held !== undefined && holdID !== undefined && lineage.includes(holdID))
36234
+ return want(held);
36235
+ if (!readRows)
36236
+ return;
36237
+ const { client } = requireEnvoy();
36238
+ for (const source of lineage) {
36239
+ const registry2 = await client.getInterest(source).catch(() => {
36240
+ return;
36241
+ });
36242
+ const topic = registry2?.topics.find((candidate) => candidate.startsWith(ROLE_TOPIC_PREFIX));
36243
+ if (topic !== undefined)
36244
+ return want(topic.slice(ROLE_TOPIC_PREFIX.length));
36107
36245
  }
36108
- let role = remembered ?? claimedRoleTopic?.slice(ROLE_TOPIC_PREFIX.length);
36109
- if (role === undefined) {
36110
- const { client } = requireEnvoy();
36111
- for (const source of roleSources(previousSessionID, carryPreviousSessionRole)) {
36112
- const registry2 = await client.getInterest(source).catch(() => {
36113
- return;
36114
- });
36115
- const topic = registry2?.topics.find((candidate) => candidate.startsWith(ROLE_TOPIC_PREFIX));
36116
- if (topic !== undefined) {
36117
- role = topic.slice(ROLE_TOPIC_PREFIX.length);
36118
- break;
36246
+ return;
36247
+ };
36248
+ const endUnwantedRoleNow = (context) => {
36249
+ const host3 = context.sessionManager.getSessionId();
36250
+ const held = heldRole();
36251
+ const topic = claimedRoleTopic;
36252
+ const holdID = claimedRoleSessionID;
36253
+ if (held === undefined || topic === undefined || holdID === undefined)
36254
+ return;
36255
+ const recorded = transcriptClaimedRole(context.sessionManager.getBranch?.() ?? []);
36256
+ const wanted = recorded === undefined ? continuedBy(host3).includes(holdID) : recorded === held;
36257
+ if (wanted)
36258
+ return;
36259
+ endRole();
36260
+ if (holdID !== "")
36261
+ pendingHandBacks.set(`${topic}@${holdID}`, { topic, id: holdID });
36262
+ };
36263
+ const tellIdChanged = (current) => {
36264
+ const previous = continues.get(current);
36265
+ if (previous === undefined || previous === current || notifiedSessions.has(current))
36266
+ return;
36267
+ notifiedSessions.add(current);
36268
+ pi.sendMessage({
36269
+ customType: "envoy-message",
36270
+ content: encode3({
36271
+ envoy: {
36272
+ notice: "session id changed",
36273
+ previous_session_id: previous,
36274
+ session_id: current,
36275
+ detail: "This conversation now lives under a new session id. Any identity you shared earlier (envoy_whoami output, session ids named in messages) is stale, and peers using the old id cannot reach you. Re-run envoy_whoami before identifying yourself and re-announce the new id to peers you already introduced yourself to."
36119
36276
  }
36277
+ }),
36278
+ display: true,
36279
+ details: LOCAL_ENVOY_NOTICE
36280
+ }, { deliverAs: "steer", triggerTurn: false });
36281
+ };
36282
+ const noteMove = (host3, reason) => {
36283
+ if (host3 === "")
36284
+ return;
36285
+ if (announced !== undefined && host3 === announced.from) {
36286
+ if (reason !== undefined)
36287
+ announced = undefined;
36288
+ return;
36289
+ }
36290
+ const from = announced?.from ?? sessionID;
36291
+ const told = announced !== undefined || reason !== undefined;
36292
+ const kind = announced !== undefined ? announced.reason : reason?.kind;
36293
+ announced = undefined;
36294
+ if (from !== "" && from !== host3) {
36295
+ if (told) {
36296
+ if (carriesSession(kind))
36297
+ continues.set(host3, from);
36298
+ else
36299
+ continues.delete(host3);
36300
+ } else if (!knownSessions.has(host3)) {
36301
+ continues.set(host3, from);
36302
+ }
36303
+ knownSessions.add(from);
36304
+ }
36305
+ knownSessions.add(host3);
36306
+ };
36307
+ const knownSessions = new Set;
36308
+ const passes = serialQueue();
36309
+ const reconcile = (context, cause = {}) => passes.run(() => reconcileNow(context, cause));
36310
+ const agentSessions = new Set;
36311
+ let inboundOpenFor = "";
36312
+ const reconcileNow = async (context, cause) => {
36313
+ const host3 = context.sessionManager.getSessionId();
36314
+ noteMove(host3);
36315
+ if (sessionID !== host3)
36316
+ restoreLocalSessionState(context);
36317
+ if (establishedSession !== host3) {
36318
+ if ((process.env.DISPATCH_SESSION_ID ?? "") !== host3) {
36319
+ bindDispatchSession(host3);
36320
+ procmgr.refreshShellConfigCache();
36120
36321
  }
36322
+ tellIdChanged(host3);
36323
+ if (host3 !== lastHeard)
36324
+ lastHeard = "";
36325
+ }
36326
+ for (const other of agentSessions) {
36327
+ if (other === host3)
36328
+ continue;
36329
+ closeIntentionally(agentSubject(other));
36330
+ agentSessions.delete(other);
36331
+ if (inboundOpenFor === other)
36332
+ inboundOpenFor = "";
36121
36333
  }
36122
- if (role === undefined)
36334
+ if (host3 === "") {
36335
+ establishedSession = host3;
36336
+ ensureHeartbeat(context);
36337
+ return true;
36338
+ }
36339
+ let inboundFailure;
36340
+ if (inbound && inboundOpenFor !== host3) {
36341
+ try {
36342
+ agentSessions.add(host3);
36343
+ await ensureConnection();
36344
+ await subscribe(agentSubject(host3));
36345
+ await watchAgentStream(host3);
36346
+ if ((context.sessionManager.getBranch?.() ?? []).length > 0) {
36347
+ await recoverRegisteredInterests();
36348
+ }
36349
+ inboundOpenFor = host3;
36350
+ } catch (error48) {
36351
+ inboundFailure = error48;
36352
+ }
36353
+ }
36354
+ if (hostSessionID() !== host3)
36355
+ return false;
36356
+ const registration = await registerSession().then(() => {
36123
36357
  return;
36124
- const continued = carryPreviousSessionRole && previousSessionID !== "" && previousSessionID !== sessionID ? previousSessionID : undefined;
36125
- await setEnvoyRole(role, { soft: true, previousSessionID: continued }).catch((error48) => {
36126
- logger3.warn("envoy: role reclaim failed", { role, sessionID, error: messageFor(error48) });
36127
- });
36358
+ }, (error48) => ({ error: error48 }));
36359
+ if (registration === undefined) {
36360
+ establishedSession = host3;
36361
+ ensureHeartbeat(context);
36362
+ }
36363
+ const decided = await roleMoves.run(() => settleRole(context, host3, cause, registration === undefined));
36364
+ if (registration !== undefined) {
36365
+ throw new RegistrationError(messageFor(registration.error), { cause: registration.error });
36366
+ }
36367
+ if (inboundFailure !== undefined) {
36368
+ throw new InboundError(messageFor(inboundFailure), decided, { cause: inboundFailure });
36369
+ }
36370
+ return decided;
36371
+ };
36372
+ let roleSettledFor = "";
36373
+ let lastHeard = "";
36374
+ const settleEndedHolds = async (host3, wanted) => {
36375
+ let restored = false;
36376
+ const failed = [];
36377
+ for (const [key, hold] of pendingHandBacks) {
36378
+ pendingHandBacks.delete(key);
36379
+ if (wanted !== undefined && hold.topic === ROLE_TOPIC_PREFIX + wanted) {
36380
+ claimedRoleTopic = hold.topic;
36381
+ claimedRoleSessionID = hold.id;
36382
+ holdUnconfirmed = true;
36383
+ restored = true;
36384
+ continue;
36385
+ }
36386
+ await requireEnvoy().client.unsubscribe({ sessionID: hold.id, topics: [hold.topic] }).catch((error48) => {
36387
+ failed.push({ key, ...hold });
36388
+ logger3.warn("envoy: a role this process gave up was not handed back", {
36389
+ topic: hold.topic,
36390
+ sessionID: hold.id,
36391
+ error: messageFor(error48)
36392
+ });
36393
+ });
36394
+ }
36395
+ for (const { key, topic, id } of failed) {
36396
+ if (id === host3)
36397
+ pendingHandBacks.set(key, { topic, id });
36398
+ else
36399
+ abandonedHolds.set(topic, { id });
36400
+ }
36401
+ return restored;
36128
36402
  };
36129
- let sessionEstablishment = Promise.resolve();
36130
- const establishSessionNow = async (context, options = {}, previousSessionID = sessionID) => {
36131
- const previousTopic = previousSessionID === "" ? undefined : agentSubject(previousSessionID);
36132
- if (options.carryPreviousSessionRole === false)
36133
- endOutgoingRole();
36134
- restoreLocalSessionState(context);
36403
+ const settleRole = async (context, host3, cause, registered) => {
36404
+ if (!sessionOpen(host3))
36405
+ return false;
36135
36406
  const branch = context.sessionManager.getBranch?.() ?? [];
36136
- const resumed = branch.length > 0;
36137
- if (sessionID === "") {
36138
- if (previousTopic !== undefined)
36139
- closeIntentionally(previousTopic);
36140
- ensureHeartbeat(context);
36141
- return;
36407
+ const readRows = roleSettledFor !== host3 && (branch.length > 0 || roleSettledFor !== "");
36408
+ const desired = await desiredRole(host3, branch, readRows);
36409
+ if (!sessionOpen(host3))
36410
+ return false;
36411
+ const unheard = lastHeard !== host3;
36412
+ const held = heldRole();
36413
+ if (held !== undefined && held !== desired) {
36414
+ const ended = await handBackRole();
36415
+ if (ended !== undefined && unheard)
36416
+ warnRoleEnded(context, host3, ended);
36417
+ if (!sessionOpen(host3))
36418
+ return false;
36142
36419
  }
36143
- const currentTopic = agentSubject(sessionID);
36144
- if (previousTopic !== undefined && previousTopic !== currentTopic) {
36145
- closeIntentionally(previousTopic);
36420
+ if (await settleEndedHolds(host3, desired) && unheard && desired !== undefined) {
36421
+ warnNoticesClosed(context, desired);
36146
36422
  }
36147
- await ensureConnection();
36148
- await subscribe(currentTopic);
36149
- await watchAgentStream(sessionID);
36150
- if (resumed) {
36151
- await recoverRegisteredInterests();
36423
+ if (!sessionOpen(host3))
36424
+ return false;
36425
+ if (!registered)
36426
+ return false;
36427
+ if (desired === undefined) {
36428
+ roleSettledFor = host3;
36429
+ return true;
36152
36430
  }
36153
- await registerSession();
36154
- if (resumed || previousSessionID !== "") {
36155
- await reclaimHeldRoles(previousSessionID, branch, options.carryPreviousSessionRole ?? true);
36431
+ if (!holdUnconfirmed && heldRole() === desired && claimedRoleSessionID === host3) {
36432
+ roleSettledFor = host3;
36433
+ return cause.heartbeat === true ? reassertRoleNow(host3, desired, cause.afterOutage === true, context) : true;
36156
36434
  }
36157
- ensureHeartbeat(context);
36435
+ if (!inbound) {
36436
+ roleSettledFor = host3;
36437
+ return true;
36438
+ }
36439
+ let supersedes = heldRole() === desired && claimedRoleSessionID !== undefined && claimedRoleSessionID !== "" ? claimedRoleSessionID : undefined;
36440
+ const lineage = continuedBy(host3);
36441
+ if (supersedes === undefined && lineage.length > 1) {
36442
+ const holder = await requireEnvoy().client.getRole(desired).then((info) => info.holder, () => {
36443
+ return;
36444
+ });
36445
+ if (holder !== undefined && holder !== host3 && lineage.includes(holder))
36446
+ supersedes = holder;
36447
+ if (!sessionOpen(host3))
36448
+ return false;
36449
+ }
36450
+ try {
36451
+ const claimed = await setEnvoyRole(desired, host3, {
36452
+ soft: true,
36453
+ previousSessionID: supersedes
36454
+ });
36455
+ if (claimed && unheard)
36456
+ warnNoticesClosed(context, desired);
36457
+ } catch (error48) {
36458
+ if (error48 instanceof SessionLeftError)
36459
+ return false;
36460
+ logger3.warn("envoy: role reclaim failed", {
36461
+ role: desired,
36462
+ sessionID: host3,
36463
+ error: messageFor(error48)
36464
+ });
36465
+ }
36466
+ roleSettledFor = host3;
36467
+ return true;
36158
36468
  };
36159
- const establishSession = (context, options = {}, previousSessionID = sessionID) => {
36160
- const run = sessionEstablishment.then(() => establishSessionNow(context, options, previousSessionID), () => establishSessionNow(context, options, previousSessionID));
36161
- sessionEstablishment = run.catch(() => {
36162
- return;
36469
+ const warnRoleEnded = (context, host3, ended) => {
36470
+ logger3.warn("envoy: role ended after a move with no session event", {
36471
+ role: ended.role,
36472
+ sessionID: host3
36163
36473
  });
36164
- return run;
36474
+ context.ui.notify(ended.pluginHeld ? `envoy: role ${ended.role} ended: the host moved to session ${host3} with no session event, and that session's records do not name it; the plugin that opened its notices claims it again on its next session switch or restart` : `envoy: role ${ended.role} ended: the host moved to session ${host3} with no session event, and that session's records do not name it; if this session should hold it, run envoy_role_set`, "warning");
36165
36475
  };
36476
+ const warnNoticesClosed = (context, role) => {
36477
+ if (roleNoticesFor !== role || roleNoticeSubjects.size > 0)
36478
+ return;
36479
+ roleNoticesFor = undefined;
36480
+ context.ui.notify(`envoy: role ${role} is held again, but the notices a plugin opened for it stay closed until that plugin claims it again on its next session switch or restart`, "warning");
36481
+ };
36482
+ const establishForBridge = (context) => reconcile(context).then(() => {
36483
+ return;
36484
+ }, (error48) => {
36485
+ if (!(error48 instanceof InboundError))
36486
+ throw error48;
36487
+ });
36166
36488
  const claim = async (targetSessionID, role, callerContext) => {
36167
36489
  const context = callerContext ?? activeSessionContext;
36168
36490
  if (context === undefined || context.sessionManager.getSessionId() !== targetSessionID) {
@@ -36170,33 +36492,49 @@ function envoyExtension(pi) {
36170
36492
  }
36171
36493
  markLegionManagedSession(targetSessionID);
36172
36494
  requireInbound();
36173
- if (sessionID !== targetSessionID)
36174
- await establishSession(context);
36495
+ if (establishedSession !== targetSessionID)
36496
+ await establishForBridge(context);
36175
36497
  await registerSession();
36176
- await setEnvoyRole(role);
36498
+ await roleMoves.run(() => setEnvoyRole(role, targetSessionID));
36177
36499
  };
36178
36500
  const subscribeNotice = async (targetSessionID, topic, callerContext, whileHolding) => {
36179
36501
  const context = callerContext ?? activeSessionContext;
36180
36502
  if (context === undefined || context.sessionManager.getSessionId() !== targetSessionID) {
36181
36503
  throw new Error(`Envoy has no active session for Legion notice subscription: ${targetSessionID}`);
36182
36504
  }
36183
- if (sessionID !== targetSessionID)
36184
- await establishSession(context);
36505
+ if (establishedSession !== targetSessionID)
36506
+ await establishForBridge(context);
36507
+ const marked = [];
36185
36508
  if (whileHolding !== undefined) {
36509
+ await roleMoves.settled();
36186
36510
  if (claimedRoleTopic !== ROLE_TOPIC_PREFIX + whileHolding) {
36187
36511
  throw new Error(`Envoy session ${targetSessionID} does not hold role ${whileHolding} for a notice subscription`);
36188
36512
  }
36189
- for (const subject2 of expandSubscriptionTopics([topic]))
36513
+ for (const subject2 of expandSubscriptionTopics([topic])) {
36514
+ if (isRoleBound(subject2))
36515
+ continue;
36190
36516
  roleNoticeSubjects.add(subject2);
36517
+ marked.push(subject2);
36518
+ }
36519
+ roleNoticesFor = whileHolding;
36520
+ }
36521
+ const snapshot = roleSnapshot(topic);
36522
+ try {
36523
+ if (!await subscribeUnlessRoleEnds(topic, snapshot))
36524
+ return;
36525
+ } catch (error48) {
36526
+ if (roleEnds === snapshot.endsBefore) {
36527
+ for (const subject2 of marked)
36528
+ roleNoticeSubjects.delete(subject2);
36529
+ }
36530
+ throw error48;
36191
36531
  }
36192
- if (!await subscribeUnlessRoleEnds(topic, roleSnapshot(topic)))
36193
- return;
36194
36532
  await registerSession();
36195
36533
  };
36196
36534
  const claimInstance = {
36197
36535
  claim,
36198
36536
  subscribe: subscribeNotice,
36199
- sessionID: () => activeSessionContext?.sessionManager.getSessionId() ?? sessionID
36537
+ sessionID: hostSessionID
36200
36538
  };
36201
36539
  if (publishedInterface !== undefined)
36202
36540
  publishedInterface.roleClaim.instances.push(claimInstance);
@@ -36212,15 +36550,15 @@ function envoyExtension(pi) {
36212
36550
  if (await isSubagent(context))
36213
36551
  return;
36214
36552
  bindDispatchSession(context.sessionManager.getSessionId());
36215
- const previousSessionID = sessionID;
36216
36553
  restoreLocalSessionState(context);
36554
+ lastHeard = context.sessionManager.getSessionId();
36217
36555
  if (startNotice2 !== "")
36218
36556
  context.ui.notify(`envoy: ${startNotice2}`, "warning");
36219
36557
  if (!inbound)
36220
36558
  return;
36221
36559
  let established = false;
36222
36560
  const establish = async () => {
36223
- await establishSession(context, {}, previousSessionID);
36561
+ await reconcile(context);
36224
36562
  established = true;
36225
36563
  };
36226
36564
  try {
@@ -36240,36 +36578,24 @@ function envoyExtension(pi) {
36240
36578
  }, NATS_RETRY_INTERVAL_MS);
36241
36579
  }
36242
36580
  });
36581
+ const carriesSession = (reason) => reason !== "new" && reason !== "resume";
36243
36582
  const rebind = async (reason, context) => {
36244
36583
  if (await isSubagent(context))
36245
36584
  return;
36246
- const previousID = sessionID;
36247
- restoreLocalSessionState(context);
36585
+ const host3 = context.sessionManager.getSessionId();
36586
+ noteMove(host3, { kind: reason });
36587
+ lastHeard = host3;
36588
+ refusedRoles.delete(host3);
36589
+ if (sessionID !== host3)
36590
+ restoreLocalSessionState(context);
36591
+ endUnwantedRoleNow(context);
36248
36592
  if (!inbound)
36249
36593
  return;
36250
36594
  try {
36251
- const carryPreviousSessionRole = reason !== "new" && reason !== "resume";
36252
- await establishSession(context, { carryPreviousSessionRole }, previousID);
36595
+ await reconcile(context);
36253
36596
  } catch (error48) {
36254
36597
  context.ui.notify(`envoy: rebind failed (${messageFor(error48)}); will recover on reconnect`, "warning");
36255
36598
  }
36256
- if (previousID === "" || previousID === sessionID)
36257
- return;
36258
- if (reason === "new" || reason === "resume")
36259
- return;
36260
- pi.sendMessage({
36261
- customType: "envoy-message",
36262
- content: encode3({
36263
- envoy: {
36264
- notice: "session id changed",
36265
- previous_session_id: previousID,
36266
- session_id: sessionID,
36267
- detail: "This conversation now lives under a new session id. Any identity you shared earlier (envoy_whoami output, session ids named in messages) is stale, and peers using the old id cannot reach you. Re-run envoy_whoami before identifying yourself and re-announce the new id to peers you already introduced yourself to."
36268
- }
36269
- }),
36270
- display: true,
36271
- details: LOCAL_ENVOY_NOTICE
36272
- }, { deliverAs: "steer", triggerTurn: false });
36273
36599
  };
36274
36600
  const rebindDispatch = async (reason, context) => {
36275
36601
  if (!await isSubagent(context))
@@ -36279,6 +36605,15 @@ function envoyExtension(pi) {
36279
36605
  pi.on("session_switch", (event, context) => rebindDispatch(event.reason, context));
36280
36606
  pi.on("session_branch", (_event, context) => rebindDispatch(undefined, context));
36281
36607
  pi.on("session_tree", (_event, context) => rebind(undefined, context));
36608
+ const announce = (reason, context) => {
36609
+ const from = context.sessionManager.getSessionId();
36610
+ noteMove(from);
36611
+ if (from === "" || sessionID === "")
36612
+ return;
36613
+ announced = { from, reason };
36614
+ };
36615
+ pi.on("session_before_switch", async (event, context) => announce(event.reason, context));
36616
+ pi.on("session_before_branch", async (_event, context) => announce(undefined, context));
36282
36617
  pi.on("session_shutdown", async (_event, context) => {
36283
36618
  shuttingDown = true;
36284
36619
  if (publishedInterface !== undefined) {
@@ -36358,8 +36693,9 @@ function envoyExtension(pi) {
36358
36693
  }
36359
36694
  return;
36360
36695
  });
36361
- pi.on("agent_start", async () => {
36696
+ pi.on("agent_start", async (_event, context) => {
36362
36697
  runSeq += 1;
36698
+ runUnderAnnouncement = announced?.from === context.sessionManager.getSessionId() ? announced : undefined;
36363
36699
  });
36364
36700
  function armAskAwareness(id, asOf) {
36365
36701
  askAwareness = {
@@ -36374,6 +36710,12 @@ function envoyExtension(pi) {
36374
36710
  const id = context.sessionManager.getSessionId();
36375
36711
  endInjectedUserTurns(sessionID);
36376
36712
  const lastReply = event.messages?.findLast((message) => message.role === "assistant");
36713
+ if (runUnderAnnouncement !== undefined && event.willContinue !== true) {
36714
+ if (announced === runUnderAnnouncement && announced.from === id && lastReply !== undefined && lastReply.stopReason !== "aborted") {
36715
+ announced = undefined;
36716
+ }
36717
+ runUnderAnnouncement = undefined;
36718
+ }
36377
36719
  const ask = sideTurn(context);
36378
36720
  if (event.willContinue === true || lastReply?.stopReason !== "stop" || !context.hasUI || id === "" || ask === undefined || !checkOwed(id)) {
36379
36721
  return;
@@ -36526,14 +36868,18 @@ Warnings: ${warnings.join("; ")}`, {
36526
36868
  }
36527
36869
  case EnvoyToolOperation.unsubscribe: {
36528
36870
  const inbox2 = agentSubject(sessionID);
36529
- const targets = topicsFor(parameters, [...subscriptions.keys()]).filter((topic) => topic !== inbox2);
36530
- const removed = targets.filter(closeIntentionally);
36871
+ const candidates = topicsFor(parameters, [...subscriptions.keys()]).filter((subject2) => subject2 !== inbox2);
36872
+ const kept = candidates.filter(isRoleBound);
36873
+ const removed = candidates.filter((subject2) => !isRoleBound(subject2)).filter(closeIntentionally);
36531
36874
  const registryRemoved = removed.filter((topic) => !topic.startsWith(ROLE_TOPIC_PREFIX));
36532
36875
  const registrationError = registryRemoved.length === 0 ? undefined : await client.unsubscribe({ sessionID, topics: registryRemoved }).then(registerSession).then(() => {
36533
36876
  return;
36534
36877
  }, messageFor);
36535
- return toolSuccess(`Unsubscribed: ${removed.join(", ") || "(none)"}`, {
36878
+ const unsubscribed = `Unsubscribed: ${removed.join(", ") || "(none)"}`;
36879
+ return toolSuccess(kept.length === 0 ? unsubscribed : `${unsubscribed}
36880
+ Kept (they last while this session holds role ${heldRole()}): ${kept.join(", ")}`, {
36536
36881
  removed,
36882
+ ...kept.length === 0 ? {} : { kept },
36537
36883
  ...registrationError === undefined ? {} : { registrationError }
36538
36884
  });
36539
36885
  }
@@ -36582,8 +36928,16 @@ Not delivered: that holder is the session this subagent sends as, and the listen
36582
36928
  }
36583
36929
  case EnvoyToolOperation.setRole: {
36584
36930
  const role = stringFor(parameters, "role");
36585
- await setEnvoyRole(role);
36586
- return toolSuccess(`Now holding role: ${role}`, { role });
36931
+ const released = await roleMoves.run(async () => {
36932
+ const held = heldRole();
36933
+ const moving = held !== undefined && held !== role;
36934
+ if (moving && roleNoticeSubjects.size > 0) {
36935
+ throw new Error(`This session holds role ${held}, and ${[...roleNoticeSubjects].join(", ")} lasts only while it does; envoy_role_set does not move it to ${role}`);
36936
+ }
36937
+ await setEnvoyRole(role, sessionID);
36938
+ return moving ? held : undefined;
36939
+ });
36940
+ return toolSuccess(released === undefined ? `Now holding role: ${role}` : `Now holding role: ${role} (released role ${released})`, { role, ...released === undefined ? {} : { released } });
36587
36941
  }
36588
36942
  case EnvoyToolOperation.getRole: {
36589
36943
  const role = await client.getRole(stringFor(parameters, "role"));
@@ -200,6 +200,9 @@ fork's parent). If a different live session holds it — the parent of a `/fork`
200
200
  the child, or a second process on the same transcript — the listener refuses atomically and the
201
201
  resumed session logs a warning and holds nothing. Only an explicit `envoy_role_set` is
202
202
  last-claim-wins. Re-running `envoy_role_set` for a role this session already holds is harmless.
203
+ Moving to a different role is refused while another plugin holds subscriptions that last only as
204
+ long as this session keeps its current role (the refusal names them), and `envoy_unsubscribe`
205
+ keeps those subscriptions open, listing them as kept.
203
206
 
204
207
  ## Legion role claims
205
208
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-envoy",
3
- "version": "10.6.2",
3
+ "version": "10.7.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "omp": {