@sjawhar/pi-legion-envoy 1.27.0 → 1.27.2

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
@@ -29883,7 +29883,7 @@ var dispatchToolSpecs = [
29883
29883
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
29884
29884
  project: z.string().describe("Project key owning the document.").optional(),
29885
29885
  artifact: z.string().describe("Project document artifact id, slug, or filename.").optional(),
29886
- ref: z.string().describe("Optional dispatch:// issue or document reference.").optional(),
29886
+ ref: z.string().describe("Optional dispatch:// reference (issue, document, message, or ask); appended to the question and rendered as a link.").optional(),
29887
29887
  question: z.string({ max: 800 }).describe("Decision question, at most 800 characters."),
29888
29888
  kind: z.enum(["action"]).describe("Optional human to-do ask kind.").optional(),
29889
29889
  options: z.array(z.object({
@@ -32015,6 +32015,19 @@ function askKind(args) {
32015
32015
  return value;
32016
32016
  throw new Error("kind must be action");
32017
32017
  }
32018
+ function askQuestionWithRef(args) {
32019
+ const question = stringArg(args, "question");
32020
+ const ref = optionalString(args, "ref");
32021
+ if (ref === undefined || question.includes(ref))
32022
+ return question;
32023
+ const withRef = `${question}
32024
+
32025
+ Ref: ${ref}`;
32026
+ if (withRef.length > 800) {
32027
+ throw new Error("question plus ref must be at most 800 characters");
32028
+ }
32029
+ return withRef;
32030
+ }
32018
32031
  function parseDispatchRef(ref) {
32019
32032
  const projectDocument = ref.match(/^dispatch:\/\/([A-Z][A-Z0-9]{1,9})\/artifact\/([^/@]+)(?:@v(\d+))?(?:\/(ask|comment)\/([^/]+))?$/);
32020
32033
  if (projectDocument) {
@@ -32097,6 +32110,9 @@ async function resolveOwnerArguments(tool, args, cwd, env, exec) {
32097
32110
  if (issueFreeTools[tool] === true)
32098
32111
  return { args, ref: null, owner: null };
32099
32112
  const refArgument = args.ref;
32113
+ if (tool === "dispatch_ask" && typeof refArgument === "string" && !refArgument.startsWith("dispatch://")) {
32114
+ throw new Error("ref must be a dispatch:// reference");
32115
+ }
32100
32116
  const ref = typeof refArgument === "string" ? parseDispatchRef(refArgument) ?? (() => {
32101
32117
  throw new Error("ref must be a valid dispatch:// reference such as dispatch://KEY-1, " + "dispatch://KEY-1/ask/<uuid>, dispatch://KEY-1/comment/<uuid>, " + "dispatch://KEY-1/message/<uuid>, dispatch://KEY-1/artifact/<slug>, or " + "dispatch://PROJECT/artifact/<document-ref> (an artifact id, slug, or filename)");
32102
32118
  })() : null;
@@ -32386,12 +32402,23 @@ async function executeDispatchTool(input) {
32386
32402
  if (!input.config.enabled || !configUrl || !configToken) {
32387
32403
  throw new Error("Dispatch is disabled; resolve both DISPATCH_URL and DISPATCH_TOKEN");
32388
32404
  }
32405
+ const baseFetch = input.fetchImpl ?? fetch;
32406
+ const fetchImpl = Object.assign(async (...args) => {
32407
+ try {
32408
+ return await baseFetch(...args);
32409
+ } catch (error) {
32410
+ if (error instanceof TypeError) {
32411
+ throw new Error(`Dispatch at ${configUrl} is unreachable: ${error.message}. If the Dispatch URL changed, restart this agent process so it picks up the new configuration.`);
32412
+ }
32413
+ throw error;
32414
+ }
32415
+ }, { preconnect: baseFetch.preconnect });
32389
32416
  if (input.tool === "dispatch_open_asks") {
32390
32417
  const sessionId = input.sessionId?.trim();
32391
32418
  if (!sessionId)
32392
32419
  throw new Error("host session id is required for dispatch_open_asks");
32393
32420
  toolSchema(input.tool).parse(input.args);
32394
- const client = new DispatchClient(configUrl, configToken, input.fetchImpl, input.signal);
32421
+ const client = new DispatchClient(configUrl, configToken, fetchImpl, input.signal);
32395
32422
  const response = await client.openAsks(sessionId);
32396
32423
  return { text: formatOpenAsksSummary(response, configUrl), details: { ...response } };
32397
32424
  }
@@ -32400,7 +32427,7 @@ async function executeDispatchTool(input) {
32400
32427
  const ownerArguments = await resolveOwnerArguments(input.tool, input.args, input.cwd, env, exec);
32401
32428
  const args = toolSchema(input.tool).parse(ownerArguments.args);
32402
32429
  const actor = toolActor(await resolveOrigin(env, exec, input.cwd), input);
32403
- const client = new DispatchClient(configUrl, configToken, input.fetchImpl, input.signal);
32430
+ const client = new DispatchClient(configUrl, configToken, fetchImpl, input.signal);
32404
32431
  const owner = ownerArguments.owner?.kind === "issue" ? {
32405
32432
  kind: "issue",
32406
32433
  issue: await ensureIssue(client, ownerArguments.owner.issue, actor)
@@ -32506,7 +32533,7 @@ async function executeDispatchTool(input) {
32506
32533
  const kind = askKind(args);
32507
32534
  const anchored = anchorArgs && resolved ? anchor(resolved.artifact, anchorArgs) : undefined;
32508
32535
  const askInput = {
32509
- question: stringArg(args, "question"),
32536
+ question: askQuestionWithRef(args),
32510
32537
  ...kind === undefined ? {} : { kind },
32511
32538
  ...Array.isArray(options) ? { options } : {},
32512
32539
  ...multiple === undefined ? {} : { multiple },
@@ -33239,59 +33266,7 @@ async function copySessionID(copyToClipboard, sessionID) {
33239
33266
  var codec2 = import_nats.StringCodec();
33240
33267
  var NATS_RETRY_INTERVAL_MS = 15000;
33241
33268
  var ROLE_CLAIM_ENTRY = "envoy-role-claim";
33242
- var ASK_AWARENESS_ENTRY = "dispatch-ask-awareness";
33243
- var LEGION_MANAGED_ENTRY = "legion-managed-session";
33244
33269
  var OPEN_ASKS_TIMEOUT_MS = 3000;
33245
- var OPEN_ASKS_REMINDER = "You have no unanswered asks in Dispatch. If you are waiting for human input, open an ask. Otherwise ignore this reminder and continue with any remaining work. Do not reply just to acknowledge this reminder.";
33246
- function isAskAwarenessEntry(entry) {
33247
- if (typeof entry !== "object" || entry === null)
33248
- return false;
33249
- if (!("type" in entry) || entry.type !== "custom")
33250
- return false;
33251
- if (!("customType" in entry) || entry.customType !== ASK_AWARENESS_ENTRY)
33252
- return false;
33253
- if (!("data" in entry) || typeof entry.data !== "object" || entry.data === null)
33254
- return false;
33255
- const data = entry.data;
33256
- return "session_id" in data && typeof data.session_id === "string" && "period" in data && typeof data.period === "number" && "baseline_as_of" in data && (typeof data.baseline_as_of === "string" || data.baseline_as_of === null) && "checked" in data && typeof data.checked === "boolean" && "fired" in data && typeof data.fired === "boolean" && "saw_ask" in data && typeof data.saw_ask === "boolean";
33257
- }
33258
- function isLegionManagedEntry(entry) {
33259
- if (typeof entry !== "object" || entry === null)
33260
- return false;
33261
- if (!("type" in entry) || entry.type !== "custom")
33262
- return false;
33263
- if (!("customType" in entry) || entry.customType !== LEGION_MANAGED_ENTRY)
33264
- return false;
33265
- if (!("data" in entry) || typeof entry.data !== "object" || entry.data === null)
33266
- return false;
33267
- return "session_id" in entry.data && typeof entry.data.session_id === "string";
33268
- }
33269
- function emptyAskAwareness(sessionID) {
33270
- return {
33271
- session_id: sessionID,
33272
- period: 0,
33273
- baseline_as_of: null,
33274
- checked: false,
33275
- fired: false,
33276
- saw_ask: false
33277
- };
33278
- }
33279
- function transcriptAskAwareness(branch, sessionID) {
33280
- let state;
33281
- for (const entry of branch) {
33282
- if (isAskAwarenessEntry(entry) && entry.data.session_id === sessionID)
33283
- state = entry.data;
33284
- }
33285
- return state;
33286
- }
33287
- function transcriptMarksLegionSession(branch, sessionID) {
33288
- return branch.some((entry) => isLegionManagedEntry(entry) && entry.data.session_id === sessionID);
33289
- }
33290
- function isHumanTurnInitiator(message) {
33291
- if (message.attribution !== "user")
33292
- return false;
33293
- return message.role === "user" || message.role === "custom" && (message.customType === "skill-prompt" || message.customType === "collab-prompt");
33294
- }
33295
33270
  function isRoleClaimEntry(entry) {
33296
33271
  if (typeof entry !== "object" || entry === null)
33297
33272
  return false;
@@ -33311,15 +33286,13 @@ function legionRoleClaimBridge() {
33311
33286
  let bridge = store[LEGION_ROLE_CLAIM_BRIDGE];
33312
33287
  if (bridge === undefined) {
33313
33288
  const ready = Promise.withResolvers();
33314
- bridge = { ready, managedSessions: new Set };
33289
+ bridge = { ready };
33315
33290
  store[LEGION_ROLE_CLAIM_BRIDGE] = bridge;
33316
33291
  }
33317
33292
  return bridge;
33318
33293
  }
33319
33294
  async function claimEnvoyRole(sessionID, role, context) {
33320
33295
  const bridge = legionRoleClaimBridge();
33321
- bridge.managedSessions.add(sessionID);
33322
- bridge.markManagedSession?.(sessionID, context);
33323
33296
  const claim = bridge.claim ?? await bridge.ready.promise;
33324
33297
  await claim(sessionID, role, context);
33325
33298
  }
@@ -33368,87 +33341,26 @@ function envoyExtension(pi) {
33368
33341
  let claimedRoleTopic;
33369
33342
  let activeSessionContext;
33370
33343
  const inbox = [];
33371
- let askAwareness = emptyAskAwareness("");
33372
- let candidateBaseline;
33373
- let awarenessGeneration = 0;
33374
- let availabilityWarningPeriod;
33375
- let agentRunning = false;
33376
- let agentWillContinue = false;
33377
- let legionManagedSessionID = "";
33378
- let terminalInputUnsubscribe;
33379
- const persistAskAwareness = (state) => {
33380
- askAwareness = state;
33381
- pi.appendEntry(ASK_AWARENESS_ENTRY, state);
33382
- };
33344
+ const availabilityWarningSessionIDs = new Set;
33383
33345
  const warnAskAvailability = (context, error) => {
33384
- const key = `${askAwareness.session_id}:${askAwareness.period}`;
33385
- if (availabilityWarningPeriod === key)
33346
+ const sessionID = context.sessionManager.getSessionId();
33347
+ if (availabilityWarningSessionIDs.has(sessionID))
33386
33348
  return;
33387
- availabilityWarningPeriod = key;
33388
- context.ui.notify(`envoy: Dispatch open-ask check unavailable (${messageFor(error)}); reminder skipped`, "warning");
33349
+ availabilityWarningSessionIDs.add(sessionID);
33350
+ context.ui.notify(`envoy: Dispatch open-ask check unavailable (${messageFor(error)}); summary unavailable`, "warning");
33389
33351
  };
33390
- const queryOpenAsks = async (requestedSessionID, since, signal) => {
33352
+ const queryOpenAsks = async (requestedSessionID) => {
33391
33353
  const config = activeDispatchConfig();
33392
33354
  if (config === null)
33393
33355
  return null;
33394
- const deadline = AbortSignal.timeout(OPEN_ASKS_TIMEOUT_MS);
33395
- const requestSignal = signal === undefined ? deadline : AbortSignal.any([signal, deadline]);
33396
- return new DispatchClient(config.url, config.token, fetch, requestSignal).openAsks(requestedSessionID, since);
33397
- };
33398
- const setReminderBaseline = (requestedSessionID, period, asOf) => {
33399
- candidateBaseline = { sessionID: requestedSessionID, asOf };
33400
- if (askAwareness.session_id !== requestedSessionID || askAwareness.period !== period || askAwareness.checked || askAwareness.fired || askAwareness.baseline_as_of !== null) {
33401
- return;
33402
- }
33403
- persistAskAwareness({ ...askAwareness, baseline_as_of: asOf });
33404
- };
33405
- const refreshReminderBaseline = async (requestedSessionID, period, context) => {
33406
- try {
33407
- const snapshot = await queryOpenAsks(requestedSessionID);
33408
- if (snapshot === null)
33409
- return;
33410
- setReminderBaseline(requestedSessionID, period, snapshot.as_of);
33411
- } catch (error) {
33412
- warnAskAvailability(context, error);
33413
- }
33414
- };
33415
- const armAskReminder = async (context, refreshBaseline = false) => {
33416
- const id = context.sessionManager.getSessionId();
33417
- if (id === "" || activeDispatchConfig() === null)
33418
- return;
33419
- awarenessGeneration++;
33420
- availabilityWarningPeriod = undefined;
33421
- const baseline = !agentRunning && !refreshBaseline && candidateBaseline !== undefined && candidateBaseline.sessionID === id ? candidateBaseline.asOf : null;
33422
- const previousPeriod = askAwareness.session_id === id ? askAwareness.period : 0;
33423
- persistAskAwareness({
33424
- session_id: id,
33425
- period: previousPeriod + 1,
33426
- baseline_as_of: baseline,
33427
- checked: false,
33428
- fired: false,
33429
- saw_ask: false
33430
- });
33431
- if (agentRunning || refreshBaseline) {
33432
- await refreshReminderBaseline(id, askAwareness.period, context);
33433
- }
33434
- };
33435
- const cancelPendingAskReminder = () => {
33436
- awarenessGeneration++;
33437
- };
33438
- const installTerminalInputCancellation = (context) => {
33439
- terminalInputUnsubscribe?.();
33440
- terminalInputUnsubscribe = context.ui.onTerminalInput(cancelPendingAskReminder);
33356
+ const snapshot = await new DispatchClient(config.url, config.token, fetch, AbortSignal.timeout(OPEN_ASKS_TIMEOUT_MS)).openAsks(requestedSessionID);
33357
+ availabilityWarningSessionIDs.delete(requestedSessionID);
33358
+ return snapshot;
33441
33359
  };
33442
33360
  const restoreLocalSessionState = (context) => {
33443
33361
  sessionDirectory = context.cwd;
33444
33362
  sessionID = context.sessionManager.getSessionId();
33445
33363
  activeSessionContext = context;
33446
- candidateBaseline = undefined;
33447
- const branch = context.sessionManager.getBranch?.() ?? [];
33448
- askAwareness = transcriptAskAwareness(branch, sessionID) ?? emptyAskAwareness(sessionID);
33449
- const persistedLegionSession = transcriptMarksLegionSession(branch, sessionID);
33450
- const managed = persistedLegionSession || legionRoleClaimBridge().managedSessions.has(sessionID);
33451
- legionManagedSessionID = managed ? sessionID : "";
33452
33364
  };
33453
33365
  pi.on("resources_discover", async () => ({ skillPaths: [SKILLS_DIRECTORY] }));
33454
33366
  registerEnvoyMessageRenderer(pi);
@@ -33489,13 +33401,6 @@ function envoyExtension(pi) {
33489
33401
  const dedupeKey = rendered.envelope?.dedupe_key;
33490
33402
  const duplicate = dedupeKey !== undefined && dedupeKeys.has(dedupeKey);
33491
33403
  if (!duplicate && !rendered.skip) {
33492
- if (rendered.dispatchActor?.kind === "user" && activeSessionContext !== undefined) {
33493
- try {
33494
- await armAskReminder(activeSessionContext, true);
33495
- } catch (error) {
33496
- warnAskAvailability(activeSessionContext, error);
33497
- }
33498
- }
33499
33404
  const envelope = rendered.envelope;
33500
33405
  if (envelope !== undefined) {
33501
33406
  inbox.unshift({
@@ -33815,17 +33720,6 @@ function envoyExtension(pi) {
33815
33720
  return run;
33816
33721
  };
33817
33722
  const bridge = legionRoleClaimBridge();
33818
- bridge.markManagedSession = (targetSessionID, context) => {
33819
- bridge.managedSessions.add(targetSessionID);
33820
- if (targetSessionID !== sessionID)
33821
- return;
33822
- if (legionManagedSessionID === targetSessionID)
33823
- return;
33824
- legionManagedSessionID = targetSessionID;
33825
- if (context !== undefined || activeSessionContext !== undefined) {
33826
- pi.appendEntry(LEGION_MANAGED_ENTRY, { session_id: targetSessionID });
33827
- }
33828
- };
33829
33723
  const claim = async (targetSessionID, role, callerContext) => {
33830
33724
  const context = callerContext ?? activeSessionContext;
33831
33725
  if (context === undefined || context.sessionManager.getSessionId() !== targetSessionID) {
@@ -33839,7 +33733,6 @@ function envoyExtension(pi) {
33839
33733
  bridge.claim = claim;
33840
33734
  bridge.ready.resolve(claim);
33841
33735
  pi.on("session_start", async (_event, context) => {
33842
- installTerminalInputCancellation(context);
33843
33736
  const previousSessionID = sessionID;
33844
33737
  restoreLocalSessionState(context);
33845
33738
  if (dispatchConfig.error !== null) {
@@ -33875,7 +33768,6 @@ function envoyExtension(pi) {
33875
33768
  }
33876
33769
  });
33877
33770
  const rebind = async (reason, context) => {
33878
- installTerminalInputCancellation(context);
33879
33771
  const previousID = sessionID;
33880
33772
  restoreLocalSessionState(context);
33881
33773
  if (defaults.natsUrls.length === 0)
@@ -33925,8 +33817,6 @@ function envoyExtension(pi) {
33925
33817
  ]);
33926
33818
  } finally {
33927
33819
  clearTimeout(timer);
33928
- terminalInputUnsubscribe?.();
33929
- terminalInputUnsubscribe = undefined;
33930
33820
  connection = undefined;
33931
33821
  activeSessionContext = undefined;
33932
33822
  subscriptions.clear();
@@ -33980,7 +33870,6 @@ function envoyExtension(pi) {
33980
33870
  const snapshot = await queryOpenAsks(id);
33981
33871
  if (snapshot === null)
33982
33872
  return;
33983
- setReminderBaseline(id, askAwareness.session_id === id ? askAwareness.period : 0, snapshot.as_of);
33984
33873
  return {
33985
33874
  message: {
33986
33875
  customType: "dispatch-open-asks",
@@ -34002,60 +33891,7 @@ ${formatOpenAsksSummary(snapshot, currentDispatchConfig().url)}`,
34002
33891
  };
34003
33892
  }
34004
33893
  });
34005
- pi.on("agent_start", async () => {
34006
- agentRunning = true;
34007
- agentWillContinue = false;
34008
- });
34009
- pi.on("agent_end", async (event) => {
34010
- agentRunning = false;
34011
- agentWillContinue = event.willContinue === true;
34012
- });
34013
- pi.on("input", async () => {
34014
- cancelPendingAskReminder();
34015
- });
34016
- pi.on("message_start", async (event, context) => {
34017
- if (!isHumanTurnInitiator(event.message))
34018
- return;
34019
- try {
34020
- await armAskReminder(context);
34021
- } catch (error) {
34022
- warnAskAvailability(context, error);
34023
- }
34024
- });
34025
- pi.on("session_stop", async (event, context) => {
34026
- if (event.stop_hook_active || event.signal.aborted || shuttingDown || agentWillContinue || event.last_assistant_message?.role !== "assistant" || event.session_id !== sessionID || legionManagedSessionID === event.session_id || askAwareness.session_id !== event.session_id || askAwareness.period === 0 || askAwareness.checked || askAwareness.fired) {
34027
- return;
34028
- }
34029
- const baseline = askAwareness.baseline_as_of;
34030
- if (baseline === null)
34031
- return;
34032
- if (context.ui.getEditorText().trim() !== "")
34033
- return;
34034
- const claimed = { ...askAwareness, checked: true };
34035
- const generation = awarenessGeneration;
34036
- persistAskAwareness(claimed);
34037
- let snapshot;
34038
- try {
34039
- snapshot = await queryOpenAsks(event.session_id, baseline, event.signal);
34040
- } catch (error) {
34041
- if (!event.signal.aborted && generation === awarenessGeneration) {
34042
- warnAskAvailability(context, error);
34043
- }
34044
- return;
34045
- }
34046
- if (snapshot === null || event.signal.aborted || generation !== awarenessGeneration || shuttingDown || agentWillContinue || context.sessionManager.getSessionId() !== event.session_id || sessionID !== event.session_id || legionManagedSessionID === event.session_id || context.ui.getEditorText().trim() !== "" || askAwareness.session_id !== claimed.session_id || askAwareness.period !== claimed.period || !askAwareness.checked) {
34047
- return;
34048
- }
34049
- if (snapshot.count > 0 || snapshot.opened_since || askAwareness.saw_ask) {
34050
- return;
34051
- }
34052
- persistAskAwareness({ ...askAwareness, fired: true });
34053
- return { continue: true, additionalContext: OPEN_ASKS_REMINDER };
34054
- });
34055
33894
  pi.on("tool_result", async (event) => {
34056
- if (!event.isError && event.toolName === "dispatch_ask" && typeof event.details === "object" && event.details !== null && "ask" in event.details && typeof event.details.ask === "string" && askAwareness.session_id === sessionID && askAwareness.period > 0 && !askAwareness.saw_ask) {
34057
- persistAskAwareness({ ...askAwareness, saw_ask: true });
34058
- }
34059
33895
  if (event.isError)
34060
33896
  return;
34061
33897
  const topic = dispatchSubscriptionTopic(event.details);
package/dist/legion.js CHANGED
@@ -29883,7 +29883,7 @@ var dispatchToolSpecs = [
29883
29883
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
29884
29884
  project: z.string().describe("Project key owning the document.").optional(),
29885
29885
  artifact: z.string().describe("Project document artifact id, slug, or filename.").optional(),
29886
- ref: z.string().describe("Optional dispatch:// issue or document reference.").optional(),
29886
+ ref: z.string().describe("Optional dispatch:// reference (issue, document, message, or ask); appended to the question and rendered as a link.").optional(),
29887
29887
  question: z.string({ max: 800 }).describe("Decision question, at most 800 characters."),
29888
29888
  kind: z.enum(["action"]).describe("Optional human to-do ask kind.").optional(),
29889
29889
  options: z.array(z.object({
@@ -32446,6 +32446,19 @@ function askKind(args) {
32446
32446
  return value;
32447
32447
  throw new Error("kind must be action");
32448
32448
  }
32449
+ function askQuestionWithRef(args) {
32450
+ const question = stringArg(args, "question");
32451
+ const ref = optionalString(args, "ref");
32452
+ if (ref === undefined || question.includes(ref))
32453
+ return question;
32454
+ const withRef = `${question}
32455
+
32456
+ Ref: ${ref}`;
32457
+ if (withRef.length > 800) {
32458
+ throw new Error("question plus ref must be at most 800 characters");
32459
+ }
32460
+ return withRef;
32461
+ }
32449
32462
  function parseDispatchRef(ref) {
32450
32463
  const projectDocument = ref.match(/^dispatch:\/\/([A-Z][A-Z0-9]{1,9})\/artifact\/([^/@]+)(?:@v(\d+))?(?:\/(ask|comment)\/([^/]+))?$/);
32451
32464
  if (projectDocument) {
@@ -32528,6 +32541,9 @@ async function resolveOwnerArguments(tool, args, cwd, env, exec) {
32528
32541
  if (issueFreeTools[tool] === true)
32529
32542
  return { args, ref: null, owner: null };
32530
32543
  const refArgument = args.ref;
32544
+ if (tool === "dispatch_ask" && typeof refArgument === "string" && !refArgument.startsWith("dispatch://")) {
32545
+ throw new Error("ref must be a dispatch:// reference");
32546
+ }
32531
32547
  const ref = typeof refArgument === "string" ? parseDispatchRef(refArgument) ?? (() => {
32532
32548
  throw new Error("ref must be a valid dispatch:// reference such as dispatch://KEY-1, " + "dispatch://KEY-1/ask/<uuid>, dispatch://KEY-1/comment/<uuid>, " + "dispatch://KEY-1/message/<uuid>, dispatch://KEY-1/artifact/<slug>, or " + "dispatch://PROJECT/artifact/<document-ref> (an artifact id, slug, or filename)");
32533
32549
  })() : null;
@@ -32817,12 +32833,23 @@ async function executeDispatchTool(input) {
32817
32833
  if (!input.config.enabled || !configUrl || !configToken) {
32818
32834
  throw new Error("Dispatch is disabled; resolve both DISPATCH_URL and DISPATCH_TOKEN");
32819
32835
  }
32836
+ const baseFetch = input.fetchImpl ?? fetch;
32837
+ const fetchImpl = Object.assign(async (...args) => {
32838
+ try {
32839
+ return await baseFetch(...args);
32840
+ } catch (error) {
32841
+ if (error instanceof TypeError) {
32842
+ throw new Error(`Dispatch at ${configUrl} is unreachable: ${error.message}. If the Dispatch URL changed, restart this agent process so it picks up the new configuration.`);
32843
+ }
32844
+ throw error;
32845
+ }
32846
+ }, { preconnect: baseFetch.preconnect });
32820
32847
  if (input.tool === "dispatch_open_asks") {
32821
32848
  const sessionId = input.sessionId?.trim();
32822
32849
  if (!sessionId)
32823
32850
  throw new Error("host session id is required for dispatch_open_asks");
32824
32851
  toolSchema(input.tool).parse(input.args);
32825
- const client = new DispatchClient(configUrl, configToken, input.fetchImpl, input.signal);
32852
+ const client = new DispatchClient(configUrl, configToken, fetchImpl, input.signal);
32826
32853
  const response = await client.openAsks(sessionId);
32827
32854
  return { text: formatOpenAsksSummary(response, configUrl), details: { ...response } };
32828
32855
  }
@@ -32831,7 +32858,7 @@ async function executeDispatchTool(input) {
32831
32858
  const ownerArguments = await resolveOwnerArguments(input.tool, input.args, input.cwd, env, exec);
32832
32859
  const args = toolSchema(input.tool).parse(ownerArguments.args);
32833
32860
  const actor = toolActor(await resolveOrigin(env, exec, input.cwd), input);
32834
- const client = new DispatchClient(configUrl, configToken, input.fetchImpl, input.signal);
32861
+ const client = new DispatchClient(configUrl, configToken, fetchImpl, input.signal);
32835
32862
  const owner = ownerArguments.owner?.kind === "issue" ? {
32836
32863
  kind: "issue",
32837
32864
  issue: await ensureIssue(client, ownerArguments.owner.issue, actor)
@@ -32937,7 +32964,7 @@ async function executeDispatchTool(input) {
32937
32964
  const kind = askKind(args);
32938
32965
  const anchored = anchorArgs && resolved ? anchor(resolved.artifact, anchorArgs) : undefined;
32939
32966
  const askInput = {
32940
- question: stringArg(args, "question"),
32967
+ question: askQuestionWithRef(args),
32941
32968
  ...kind === undefined ? {} : { kind },
32942
32969
  ...Array.isArray(options) ? { options } : {},
32943
32970
  ...multiple === undefined ? {} : { multiple },
@@ -33662,59 +33689,7 @@ async function copySessionID(copyToClipboard, sessionID) {
33662
33689
  var codec2 = import_nats.StringCodec();
33663
33690
  var NATS_RETRY_INTERVAL_MS = 15000;
33664
33691
  var ROLE_CLAIM_ENTRY = "envoy-role-claim";
33665
- var ASK_AWARENESS_ENTRY = "dispatch-ask-awareness";
33666
- var LEGION_MANAGED_ENTRY = "legion-managed-session";
33667
33692
  var OPEN_ASKS_TIMEOUT_MS = 3000;
33668
- var OPEN_ASKS_REMINDER = "You have no unanswered asks in Dispatch. If you are waiting for human input, open an ask. Otherwise ignore this reminder and continue with any remaining work. Do not reply just to acknowledge this reminder.";
33669
- function isAskAwarenessEntry(entry) {
33670
- if (typeof entry !== "object" || entry === null)
33671
- return false;
33672
- if (!("type" in entry) || entry.type !== "custom")
33673
- return false;
33674
- if (!("customType" in entry) || entry.customType !== ASK_AWARENESS_ENTRY)
33675
- return false;
33676
- if (!("data" in entry) || typeof entry.data !== "object" || entry.data === null)
33677
- return false;
33678
- const data = entry.data;
33679
- return "session_id" in data && typeof data.session_id === "string" && "period" in data && typeof data.period === "number" && "baseline_as_of" in data && (typeof data.baseline_as_of === "string" || data.baseline_as_of === null) && "checked" in data && typeof data.checked === "boolean" && "fired" in data && typeof data.fired === "boolean" && "saw_ask" in data && typeof data.saw_ask === "boolean";
33680
- }
33681
- function isLegionManagedEntry(entry) {
33682
- if (typeof entry !== "object" || entry === null)
33683
- return false;
33684
- if (!("type" in entry) || entry.type !== "custom")
33685
- return false;
33686
- if (!("customType" in entry) || entry.customType !== LEGION_MANAGED_ENTRY)
33687
- return false;
33688
- if (!("data" in entry) || typeof entry.data !== "object" || entry.data === null)
33689
- return false;
33690
- return "session_id" in entry.data && typeof entry.data.session_id === "string";
33691
- }
33692
- function emptyAskAwareness(sessionID) {
33693
- return {
33694
- session_id: sessionID,
33695
- period: 0,
33696
- baseline_as_of: null,
33697
- checked: false,
33698
- fired: false,
33699
- saw_ask: false
33700
- };
33701
- }
33702
- function transcriptAskAwareness(branch, sessionID) {
33703
- let state;
33704
- for (const entry of branch) {
33705
- if (isAskAwarenessEntry(entry) && entry.data.session_id === sessionID)
33706
- state = entry.data;
33707
- }
33708
- return state;
33709
- }
33710
- function transcriptMarksLegionSession(branch, sessionID) {
33711
- return branch.some((entry) => isLegionManagedEntry(entry) && entry.data.session_id === sessionID);
33712
- }
33713
- function isHumanTurnInitiator(message) {
33714
- if (message.attribution !== "user")
33715
- return false;
33716
- return message.role === "user" || message.role === "custom" && (message.customType === "skill-prompt" || message.customType === "collab-prompt");
33717
- }
33718
33693
  function isRoleClaimEntry(entry) {
33719
33694
  if (typeof entry !== "object" || entry === null)
33720
33695
  return false;
@@ -33734,15 +33709,13 @@ function legionRoleClaimBridge() {
33734
33709
  let bridge = store[LEGION_ROLE_CLAIM_BRIDGE];
33735
33710
  if (bridge === undefined) {
33736
33711
  const ready = Promise.withResolvers();
33737
- bridge = { ready, managedSessions: new Set };
33712
+ bridge = { ready };
33738
33713
  store[LEGION_ROLE_CLAIM_BRIDGE] = bridge;
33739
33714
  }
33740
33715
  return bridge;
33741
33716
  }
33742
33717
  async function claimEnvoyRole(sessionID, role, context) {
33743
33718
  const bridge = legionRoleClaimBridge();
33744
- bridge.managedSessions.add(sessionID);
33745
- bridge.markManagedSession?.(sessionID, context);
33746
33719
  const claim = bridge.claim ?? await bridge.ready.promise;
33747
33720
  await claim(sessionID, role, context);
33748
33721
  }
@@ -33791,87 +33764,26 @@ function envoyExtension(pi) {
33791
33764
  let claimedRoleTopic;
33792
33765
  let activeSessionContext;
33793
33766
  const inbox = [];
33794
- let askAwareness = emptyAskAwareness("");
33795
- let candidateBaseline;
33796
- let awarenessGeneration = 0;
33797
- let availabilityWarningPeriod;
33798
- let agentRunning = false;
33799
- let agentWillContinue = false;
33800
- let legionManagedSessionID = "";
33801
- let terminalInputUnsubscribe;
33802
- const persistAskAwareness = (state) => {
33803
- askAwareness = state;
33804
- pi.appendEntry(ASK_AWARENESS_ENTRY, state);
33805
- };
33767
+ const availabilityWarningSessionIDs = new Set;
33806
33768
  const warnAskAvailability = (context, error) => {
33807
- const key = `${askAwareness.session_id}:${askAwareness.period}`;
33808
- if (availabilityWarningPeriod === key)
33769
+ const sessionID = context.sessionManager.getSessionId();
33770
+ if (availabilityWarningSessionIDs.has(sessionID))
33809
33771
  return;
33810
- availabilityWarningPeriod = key;
33811
- context.ui.notify(`envoy: Dispatch open-ask check unavailable (${messageFor(error)}); reminder skipped`, "warning");
33772
+ availabilityWarningSessionIDs.add(sessionID);
33773
+ context.ui.notify(`envoy: Dispatch open-ask check unavailable (${messageFor(error)}); summary unavailable`, "warning");
33812
33774
  };
33813
- const queryOpenAsks = async (requestedSessionID, since, signal) => {
33775
+ const queryOpenAsks = async (requestedSessionID) => {
33814
33776
  const config = activeDispatchConfig();
33815
33777
  if (config === null)
33816
33778
  return null;
33817
- const deadline = AbortSignal.timeout(OPEN_ASKS_TIMEOUT_MS);
33818
- const requestSignal = signal === undefined ? deadline : AbortSignal.any([signal, deadline]);
33819
- return new DispatchClient(config.url, config.token, fetch, requestSignal).openAsks(requestedSessionID, since);
33820
- };
33821
- const setReminderBaseline = (requestedSessionID, period, asOf) => {
33822
- candidateBaseline = { sessionID: requestedSessionID, asOf };
33823
- if (askAwareness.session_id !== requestedSessionID || askAwareness.period !== period || askAwareness.checked || askAwareness.fired || askAwareness.baseline_as_of !== null) {
33824
- return;
33825
- }
33826
- persistAskAwareness({ ...askAwareness, baseline_as_of: asOf });
33827
- };
33828
- const refreshReminderBaseline = async (requestedSessionID, period, context) => {
33829
- try {
33830
- const snapshot = await queryOpenAsks(requestedSessionID);
33831
- if (snapshot === null)
33832
- return;
33833
- setReminderBaseline(requestedSessionID, period, snapshot.as_of);
33834
- } catch (error) {
33835
- warnAskAvailability(context, error);
33836
- }
33837
- };
33838
- const armAskReminder = async (context, refreshBaseline = false) => {
33839
- const id = context.sessionManager.getSessionId();
33840
- if (id === "" || activeDispatchConfig() === null)
33841
- return;
33842
- awarenessGeneration++;
33843
- availabilityWarningPeriod = undefined;
33844
- const baseline = !agentRunning && !refreshBaseline && candidateBaseline !== undefined && candidateBaseline.sessionID === id ? candidateBaseline.asOf : null;
33845
- const previousPeriod = askAwareness.session_id === id ? askAwareness.period : 0;
33846
- persistAskAwareness({
33847
- session_id: id,
33848
- period: previousPeriod + 1,
33849
- baseline_as_of: baseline,
33850
- checked: false,
33851
- fired: false,
33852
- saw_ask: false
33853
- });
33854
- if (agentRunning || refreshBaseline) {
33855
- await refreshReminderBaseline(id, askAwareness.period, context);
33856
- }
33857
- };
33858
- const cancelPendingAskReminder = () => {
33859
- awarenessGeneration++;
33860
- };
33861
- const installTerminalInputCancellation = (context) => {
33862
- terminalInputUnsubscribe?.();
33863
- terminalInputUnsubscribe = context.ui.onTerminalInput(cancelPendingAskReminder);
33779
+ const snapshot = await new DispatchClient(config.url, config.token, fetch, AbortSignal.timeout(OPEN_ASKS_TIMEOUT_MS)).openAsks(requestedSessionID);
33780
+ availabilityWarningSessionIDs.delete(requestedSessionID);
33781
+ return snapshot;
33864
33782
  };
33865
33783
  const restoreLocalSessionState = (context) => {
33866
33784
  sessionDirectory = context.cwd;
33867
33785
  sessionID = context.sessionManager.getSessionId();
33868
33786
  activeSessionContext = context;
33869
- candidateBaseline = undefined;
33870
- const branch = context.sessionManager.getBranch?.() ?? [];
33871
- askAwareness = transcriptAskAwareness(branch, sessionID) ?? emptyAskAwareness(sessionID);
33872
- const persistedLegionSession = transcriptMarksLegionSession(branch, sessionID);
33873
- const managed = persistedLegionSession || legionRoleClaimBridge().managedSessions.has(sessionID);
33874
- legionManagedSessionID = managed ? sessionID : "";
33875
33787
  };
33876
33788
  pi.on("resources_discover", async () => ({ skillPaths: [SKILLS_DIRECTORY] }));
33877
33789
  registerEnvoyMessageRenderer(pi);
@@ -33912,13 +33824,6 @@ function envoyExtension(pi) {
33912
33824
  const dedupeKey = rendered.envelope?.dedupe_key;
33913
33825
  const duplicate = dedupeKey !== undefined && dedupeKeys.has(dedupeKey);
33914
33826
  if (!duplicate && !rendered.skip) {
33915
- if (rendered.dispatchActor?.kind === "user" && activeSessionContext !== undefined) {
33916
- try {
33917
- await armAskReminder(activeSessionContext, true);
33918
- } catch (error) {
33919
- warnAskAvailability(activeSessionContext, error);
33920
- }
33921
- }
33922
33827
  const envelope = rendered.envelope;
33923
33828
  if (envelope !== undefined) {
33924
33829
  inbox.unshift({
@@ -34238,17 +34143,6 @@ function envoyExtension(pi) {
34238
34143
  return run;
34239
34144
  };
34240
34145
  const bridge = legionRoleClaimBridge();
34241
- bridge.markManagedSession = (targetSessionID, context) => {
34242
- bridge.managedSessions.add(targetSessionID);
34243
- if (targetSessionID !== sessionID)
34244
- return;
34245
- if (legionManagedSessionID === targetSessionID)
34246
- return;
34247
- legionManagedSessionID = targetSessionID;
34248
- if (context !== undefined || activeSessionContext !== undefined) {
34249
- pi.appendEntry(LEGION_MANAGED_ENTRY, { session_id: targetSessionID });
34250
- }
34251
- };
34252
34146
  const claim = async (targetSessionID, role, callerContext) => {
34253
34147
  const context = callerContext ?? activeSessionContext;
34254
34148
  if (context === undefined || context.sessionManager.getSessionId() !== targetSessionID) {
@@ -34262,7 +34156,6 @@ function envoyExtension(pi) {
34262
34156
  bridge.claim = claim;
34263
34157
  bridge.ready.resolve(claim);
34264
34158
  pi.on("session_start", async (_event, context) => {
34265
- installTerminalInputCancellation(context);
34266
34159
  const previousSessionID = sessionID;
34267
34160
  restoreLocalSessionState(context);
34268
34161
  if (dispatchConfig.error !== null) {
@@ -34298,7 +34191,6 @@ function envoyExtension(pi) {
34298
34191
  }
34299
34192
  });
34300
34193
  const rebind = async (reason, context) => {
34301
- installTerminalInputCancellation(context);
34302
34194
  const previousID = sessionID;
34303
34195
  restoreLocalSessionState(context);
34304
34196
  if (defaults.natsUrls.length === 0)
@@ -34348,8 +34240,6 @@ function envoyExtension(pi) {
34348
34240
  ]);
34349
34241
  } finally {
34350
34242
  clearTimeout(timer);
34351
- terminalInputUnsubscribe?.();
34352
- terminalInputUnsubscribe = undefined;
34353
34243
  connection = undefined;
34354
34244
  activeSessionContext = undefined;
34355
34245
  subscriptions.clear();
@@ -34403,7 +34293,6 @@ function envoyExtension(pi) {
34403
34293
  const snapshot = await queryOpenAsks(id);
34404
34294
  if (snapshot === null)
34405
34295
  return;
34406
- setReminderBaseline(id, askAwareness.session_id === id ? askAwareness.period : 0, snapshot.as_of);
34407
34296
  return {
34408
34297
  message: {
34409
34298
  customType: "dispatch-open-asks",
@@ -34425,60 +34314,7 @@ ${formatOpenAsksSummary(snapshot, currentDispatchConfig().url)}`,
34425
34314
  };
34426
34315
  }
34427
34316
  });
34428
- pi.on("agent_start", async () => {
34429
- agentRunning = true;
34430
- agentWillContinue = false;
34431
- });
34432
- pi.on("agent_end", async (event) => {
34433
- agentRunning = false;
34434
- agentWillContinue = event.willContinue === true;
34435
- });
34436
- pi.on("input", async () => {
34437
- cancelPendingAskReminder();
34438
- });
34439
- pi.on("message_start", async (event, context) => {
34440
- if (!isHumanTurnInitiator(event.message))
34441
- return;
34442
- try {
34443
- await armAskReminder(context);
34444
- } catch (error) {
34445
- warnAskAvailability(context, error);
34446
- }
34447
- });
34448
- pi.on("session_stop", async (event, context) => {
34449
- if (event.stop_hook_active || event.signal.aborted || shuttingDown || agentWillContinue || event.last_assistant_message?.role !== "assistant" || event.session_id !== sessionID || legionManagedSessionID === event.session_id || askAwareness.session_id !== event.session_id || askAwareness.period === 0 || askAwareness.checked || askAwareness.fired) {
34450
- return;
34451
- }
34452
- const baseline = askAwareness.baseline_as_of;
34453
- if (baseline === null)
34454
- return;
34455
- if (context.ui.getEditorText().trim() !== "")
34456
- return;
34457
- const claimed = { ...askAwareness, checked: true };
34458
- const generation = awarenessGeneration;
34459
- persistAskAwareness(claimed);
34460
- let snapshot;
34461
- try {
34462
- snapshot = await queryOpenAsks(event.session_id, baseline, event.signal);
34463
- } catch (error) {
34464
- if (!event.signal.aborted && generation === awarenessGeneration) {
34465
- warnAskAvailability(context, error);
34466
- }
34467
- return;
34468
- }
34469
- if (snapshot === null || event.signal.aborted || generation !== awarenessGeneration || shuttingDown || agentWillContinue || context.sessionManager.getSessionId() !== event.session_id || sessionID !== event.session_id || legionManagedSessionID === event.session_id || context.ui.getEditorText().trim() !== "" || askAwareness.session_id !== claimed.session_id || askAwareness.period !== claimed.period || !askAwareness.checked) {
34470
- return;
34471
- }
34472
- if (snapshot.count > 0 || snapshot.opened_since || askAwareness.saw_ask) {
34473
- return;
34474
- }
34475
- persistAskAwareness({ ...askAwareness, fired: true });
34476
- return { continue: true, additionalContext: OPEN_ASKS_REMINDER };
34477
- });
34478
34317
  pi.on("tool_result", async (event) => {
34479
- if (!event.isError && event.toolName === "dispatch_ask" && typeof event.details === "object" && event.details !== null && "ask" in event.details && typeof event.details.ask === "string" && askAwareness.session_id === sessionID && askAwareness.period > 0 && !askAwareness.saw_ask) {
34480
- persistAskAwareness({ ...askAwareness, saw_ask: true });
34481
- }
34482
34318
  if (event.isError)
34483
34319
  return;
34484
34320
  const topic = dispatchSubscriptionTopic(event.details);
@@ -111,6 +111,7 @@ dispatch_ask({
111
111
  issue?,
112
112
  project?,
113
113
  artifact?,
114
+ ref?,
114
115
  question,
115
116
  options?: { label, description? }[],
116
117
  multiple?,
@@ -120,6 +121,8 @@ dispatch_ask({
120
121
  ```
121
122
  It returns `details` `{ issue, topic, ask }` for an issue or `{ project, artifact, document, topic, ask }` for a project document.
122
123
 
124
+ References belong in the question text; `ref` is sugar that appends its `dispatch://` value to the question as a rendered link.
125
+
123
126
  An ask is read on a phone by someone who has not read the code. Open with one or two plain
124
127
  sentences: what needs deciding and why it matters now. Each option is a button with a label and
125
128
  one sentence saying what happens if it is chosen; never enumerate choices in prose. Put the
@@ -341,6 +344,24 @@ with your text. Never do that: the spec is edited in place with `dispatch_doc_ed
341
344
  artifact by the slug shown in the upload result or by its filename, and a project document by its artifact id, slug, or filename; the
342
345
  slug also arrives on `artifact.created` events.
343
346
 
347
+ ## Structure over stream
348
+
349
+ Dispatch is a structured workspace, never a message stream (Sami, 2026-09-13, verbatim: "strange
350
+ to me that agents keep trying to use dispatch as a giant stream of messages instead of
351
+ high-signal, structured conversation"). The structure IS the product:
352
+
353
+ - **One issue per piece of work.** A new deliverable — an email to send, a document to review, a
354
+ decision with its own lifecycle — gets its own issue with the content as the issue's document
355
+ (spec artifact), not a message pile on an existing issue. If you are about to post a message
356
+ carrying a draft, a spec, or anything over a couple of paragraphs, stop: that is an issue with a
357
+ document, or an artifact on the issue it belongs to.
358
+ - **Content lives in documents; decisions live in asks; messages only announce.** A draft the
359
+ human must read goes in a document artifact the dashboard renders with versions and margins; the
360
+ ask that needs their word references it (`ref`, or the `dispatch://` link inline) instead of
361
+ restating it. A message never carries a body a human has to scroll.
362
+ - **Never split one deliverable across a message + an ask that points at it.** Ask the question
363
+ with the document reference in the question text; the reader lands on the content in one click.
364
+
344
365
  ## Messages
345
366
 
346
367
  Dispatch is a high-signal record for humans, not a log of what you are doing. A message is a reply to a human's message, or a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion-envoy",
3
- "version": "1.27.0",
3
+ "version": "1.27.2",
4
4
  "type": "module",
5
5
  "omp": {
6
6
  "extensions": [