@sjawhar/pi-envoy 10.15.0 → 10.16.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
@@ -183,9 +183,10 @@ comment with `--comment`. No result carries a subscription topic: opening an ask
183
183
  one with `dispatch comment --reply-to-ask`, makes the session a follower of that ask (its answer
184
184
  and replies reach the session directly, server-side), and the command prints
185
185
  `Following ask <id> on <owner>: …` after its result the first time the session follows that ask.
186
- Whole-issue or whole-document subscription is the model's own `envoy_subscribe` of the topic every
186
+ Whole-issue or whole-document subscription is the model's own `envoy_subscribe` of the topic a
187
187
  write result names (`notifications.dispatch.issue.<KEY>.>` or
188
- `notifications.dispatch.document.<PROJECT>.<SLUG>.>`).
188
+ `notifications.dispatch.document.<PROJECT>.<SLUG>.>`), which it names only when none of the
189
+ session's subscriptions, as the Envoy listener records them, covers that owner.
189
190
 
190
191
  The shared specs in `@legion/contracts` supply each command's description and flags:
191
192
  `@legion/envoy-client`'s `dispatch-command.ts` derives one command per spec and one flag per field.
package/dist/dispatch.js CHANGED
@@ -16066,6 +16066,45 @@ async function readPictures(client, addresses, shown) {
16066
16066
  return { lines, images, shown: showing };
16067
16067
  }
16068
16068
 
16069
+ // ../envoy-client/src/dispatch-subscribe.ts
16070
+ function followedAsk(details) {
16071
+ if (typeof details !== "object" || details === null)
16072
+ return null;
16073
+ const { follows, issue: issue2, document } = details;
16074
+ if (typeof follows !== "object" || follows === null)
16075
+ return null;
16076
+ const { ask } = follows;
16077
+ if (typeof ask !== "string" || ask === "")
16078
+ return null;
16079
+ if (typeof issue2 === "string" && issue2 !== "")
16080
+ return { ask, owner: issueTopic(issue2) };
16081
+ if (typeof document !== "string")
16082
+ return null;
16083
+ const [project, slug] = document.split("/", 2);
16084
+ if (!project || !slug)
16085
+ return null;
16086
+ return { ask, owner: { label: document, topic: documentTopicOf(project, slug).topic } };
16087
+ }
16088
+ function followNoticeText({ ask, owner }, topics) {
16089
+ const subscribe = ownerSubscribeLine(owner, topics);
16090
+ return `Following ask ${ask} on ${owner.label}: its answer and replies reach you directly (${commandLine("dispatch_follow", { ask, action: "unfollow" })} to stop).${subscribe === null ? "" : ` ${subscribe}.`}`;
16091
+ }
16092
+ function ownerSubscribeLine(owner, topics) {
16093
+ if (topics == null || topics.some((topic) => subscriptionCovers(topic, owner)))
16094
+ return null;
16095
+ return `For every event on ${owner.label}: envoy_subscribe ${owner.topic}`;
16096
+ }
16097
+ function subscriptionCovers(topic, owner) {
16098
+ const base = owner.topic.slice(0, -".>".length).split(".");
16099
+ for (const [index, part] of topic.split(".").entries()) {
16100
+ if (part === ">")
16101
+ return true;
16102
+ if (index >= base.length || part !== "*" && part !== base[index])
16103
+ return false;
16104
+ }
16105
+ return false;
16106
+ }
16107
+
16069
16108
  // ../envoy-client/src/search-answer.ts
16070
16109
  function pageSummaryText(offset, count, total) {
16071
16110
  return `showing ${offset + 1}-${offset + count} of ${total}`;
@@ -16152,11 +16191,14 @@ function resolvedTopic(resolved) {
16152
16191
  throw new Error("issue document is missing its issue");
16153
16192
  return issueTopic(resolved.issue.key);
16154
16193
  }
16155
- function notSubscribed(owner) {
16156
- return `(not subscribed to ${owner.label}; envoy_subscribe ${owner.topic} for every event on it)`;
16194
+ async function notSubscribed(owner, subscriptions) {
16195
+ if (ownerSubscribeLine(owner, await subscriptions?.()) === null)
16196
+ return "";
16197
+ return ` (not subscribed to ${owner.label}; envoy_subscribe ${owner.topic} for every event on it)`;
16157
16198
  }
16158
- function followsAsk(owner) {
16159
- return `You follow this ask: its answer and replies reach you directly. For every event on ${owner.label}: envoy_subscribe ${owner.topic}`;
16199
+ async function followsAsk(owner, subscriptions) {
16200
+ const subscribe = ownerSubscribeLine(owner, await subscriptions?.());
16201
+ return `You follow this ask: its answer and replies reach you directly.${subscribe === null ? "" : ` ${subscribe}`}`;
16160
16202
  }
16161
16203
  var triageAdviceShown = new Set;
16162
16204
  function adviceMemory() {
@@ -16433,6 +16475,37 @@ async function resolveIdPrefix(tool, kind, id, ownerName, list) {
16433
16475
  ]);
16434
16476
  }
16435
16477
  var askIdShapeProblem = "ask ids are uuids (a prefix of at least 8 hex characters works)";
16478
+ async function readAskRef(tool, id, ownerName, client, list) {
16479
+ const fullID = normalizeUUID(id);
16480
+ if (fullID !== undefined) {
16481
+ try {
16482
+ return await client.getAsk(fullID);
16483
+ } catch (error48) {
16484
+ if (!(error48 instanceof DispatchServiceError) || error48.status !== 404)
16485
+ throw error48;
16486
+ const blocks = (await list()).filter((ask) => ask.block_id?.toLowerCase() === fullID);
16487
+ if (blocks.length !== 1 || blocks[0] === undefined)
16488
+ throw error48;
16489
+ return client.getAsk(blocks[0].id);
16490
+ }
16491
+ }
16492
+ const asks = await list();
16493
+ let matches = asks.filter((ask) => ask.block_id === id);
16494
+ if (matches.length === 0) {
16495
+ if (!idPrefixPattern.test(id)) {
16496
+ throw new ToolInputError(tool, [
16497
+ `ask id ${id} must be a full uuid, a prefix of at least 8 hex characters, or the id of an :::ask block on ${ownerName}`
16498
+ ]);
16499
+ }
16500
+ const prefix = id.toLowerCase();
16501
+ matches = asks.filter((ask) => ask.id.toLowerCase().startsWith(prefix) || (ask.block_id?.toLowerCase().startsWith(prefix) ?? false));
16502
+ }
16503
+ if (matches.length === 1 && matches[0] !== undefined)
16504
+ return client.getAsk(matches[0].id);
16505
+ throw new ToolInputError(tool, [
16506
+ matches.length === 0 ? `ask id ${id} matches none of the asks on ${ownerName}; use the full id` : `ask id ${id} matches ${matches.length} asks on ${ownerName}; use the full id`
16507
+ ]);
16508
+ }
16436
16509
  async function sessionOpenAsks(client, sessionId) {
16437
16510
  const session = sessionId?.trim();
16438
16511
  if (!session)
@@ -17055,6 +17128,7 @@ function askSummary({ ask, replies, answers }, graph) {
17055
17128
  "Options:",
17056
17129
  ...ask.options.length === 0 ? ["- none"] : ask.options.map((option) => `- ${option.label}${option.description ? ` \u2014 ${option.description}` : ""}`),
17057
17130
  `State: ${ask.state}`,
17131
+ `Ask id: ${ask.id}`,
17058
17132
  "Answer:",
17059
17133
  ...answer === null ? ["- none"] : [
17060
17134
  `- By: ${answer.user}`,
@@ -17520,7 +17594,7 @@ async function executeDispatchTool(input) {
17520
17594
  const assigned = created.assignee === undefined ? "" : created.assignee === null ? "; unassigned" : `; assigned to ${created.assignee}`;
17521
17595
  return {
17522
17596
  text: [
17523
- `Created ${created.key}: ${created.title} \u2192 ${href}${assigned} ${notSubscribed(issueTopic(created.key))}`,
17597
+ `Created ${created.key}: ${created.title} \u2192 ${href}${assigned}${await notSubscribed(issueTopic(created.key), input.subscriptions)}`,
17524
17598
  ...adviceLines
17525
17599
  ].join(`
17526
17600
  `),
@@ -17637,7 +17711,7 @@ async function executeDispatchTool(input) {
17637
17711
  });
17638
17712
  return {
17639
17713
  text: [
17640
- `${after.key}: ${changes.join("; ")} ${notSubscribed(issueTopic(after.key))}`,
17714
+ `${after.key}: ${changes.join("; ")}${await notSubscribed(issueTopic(after.key), input.subscriptions)}`,
17641
17715
  ...adviceLines
17642
17716
  ].join(`
17643
17717
  `),
@@ -17670,8 +17744,7 @@ async function executeDispatchTool(input) {
17670
17744
  text = `${issueKey}: claimed by you since ${held.at}. Its status is ${after.status}; a claim moves nothing, so move it to in_progress with dispatch issue-update when you start, and release the claim when you stop.`;
17671
17745
  }
17672
17746
  return {
17673
- text: [text, notSubscribed(issueTopic(issueKey))].join(`
17674
- `),
17747
+ text: `${text}${await notSubscribed(issueTopic(issueKey), input.subscriptions)}`,
17675
17748
  details: { issue: issueKey, status: after.status, claim: held }
17676
17749
  };
17677
17750
  }
@@ -17847,7 +17920,7 @@ async function executeDispatchTool(input) {
17847
17920
  return {
17848
17921
  text: [
17849
17922
  `Asked ${ask.id} on ${askOwner.label} (urgency ${ask.urgency}): ${ask.question}
17850
- ${followsAsk(askOwner)}`,
17923
+ ${await followsAsk(askOwner, input.subscriptions)}`,
17851
17924
  ...adviceLines
17852
17925
  ].join(`
17853
17926
  `),
@@ -17913,7 +17986,7 @@ ${followsAsk(askOwner)}`,
17913
17986
  const askState = comment.ask_waiting_on === undefined ? "" : `; ask now waiting on ${comment.ask_waiting_on}`;
17914
17987
  return {
17915
17988
  text: [
17916
- `Replied on ask ${replyToAsk} (comment ${comment.id}${askState}). ${followsAsk(commentOwner2)}`,
17989
+ `Replied on ask ${replyToAsk} (comment ${comment.id}${askState}). ${await followsAsk(commentOwner2, input.subscriptions)}`,
17917
17990
  ...adviceLines
17918
17991
  ].join(`
17919
17992
  `),
@@ -17927,7 +18000,10 @@ ${followsAsk(askOwner)}`,
17927
18000
  };
17928
18001
  }
17929
18002
  return {
17930
- text: [`Posted comment ${comment.id} ${notSubscribed(commentOwner2)}`, ...adviceLines].join(`
18003
+ text: [
18004
+ `Posted comment ${comment.id}${await notSubscribed(commentOwner2, input.subscriptions)}`,
18005
+ ...adviceLines
18006
+ ].join(`
17931
18007
  `),
17932
18008
  details: {
17933
18009
  ...commentDetails,
@@ -17949,7 +18025,7 @@ ${followsAsk(askOwner)}`,
17949
18025
  };
17950
18026
  const comment = resolved.owner.kind === "project" ? await client.artifactSuggest(resolved.artifact.id, suggestionInput) : await client.suggest(issue2(), suggestionInput);
17951
18027
  return {
17952
- text: `Posted suggestion ${comment.id} ${notSubscribed(resolvedTopic(resolved))}`,
18028
+ text: `Posted suggestion ${comment.id}${await notSubscribed(resolvedTopic(resolved), input.subscriptions)}`,
17953
18029
  details: writeResultDetails(resolved, { comment: comment.id })
17954
18030
  };
17955
18031
  }
@@ -18007,7 +18083,7 @@ ${followsAsk(askOwner)}`,
18007
18083
  const adviceLines = renderAdvice(input.tool, issueKey, message.advice, {});
18008
18084
  return {
18009
18085
  text: [
18010
- `Posted message ${message.id} (${messageRef}) ${notSubscribed(issueTopic(issueKey))}`,
18086
+ `Posted message ${message.id} (${messageRef})${await notSubscribed(issueTopic(issueKey), input.subscriptions)}`,
18011
18087
  ...adviceLines
18012
18088
  ].join(`
18013
18089
  `),
@@ -18045,7 +18121,7 @@ ${followsAsk(askOwner)}`,
18045
18121
  const tokenTrailer = edited.token === undefined ? [] : [`Document token: ${edited.token}`];
18046
18122
  return {
18047
18123
  text: [
18048
- `${applied} ${notSubscribed(resolvedTopic(resolved))}`,
18124
+ `${applied}${await notSubscribed(resolvedTopic(resolved), input.subscriptions)}`,
18049
18125
  ...tokenTrailer,
18050
18126
  ...adviceLines
18051
18127
  ].join(`
@@ -18168,7 +18244,7 @@ ${trailer.join(`
18168
18244
  });
18169
18245
  return {
18170
18246
  text: [
18171
- `Uploaded ${result.artifact.name} as version ${result.version.number} (artifact slug ${result.artifact.slug}; ${artifactRef}) ${notSubscribed(uploadOwner)}`,
18247
+ `Uploaded ${result.artifact.name} as version ${result.version.number} (artifact slug ${result.artifact.slug}; ${artifactRef})${await notSubscribed(uploadOwner, input.subscriptions)}`,
18172
18248
  ...adviceLines
18173
18249
  ].join(`
18174
18250
  `),
@@ -18230,9 +18306,8 @@ ${trailer.join(`
18230
18306
  }
18231
18307
  if (ownerArguments.ref?.kind === "ask") {
18232
18308
  const ref = ownerArguments.ref;
18233
- const id = await resolveIdPrefix(input.tool, "ask", ref.id, refOwnerName(ref), async () => ref.owner.kind === "issue" ? client.listIssueAsks(ref.owner.issue) : client.getArtifactAsks((await resolveDocument(ref.owner, ref.artifact)).artifact.id, "all"));
18234
- const askRead = await client.getAsk(id);
18235
- const askRef = refTarget(ref, "ask", id);
18309
+ const askRead = await readAskRef(input.tool, ref.id, refOwnerName(ref), client, async () => ref.owner.kind === "issue" ? client.listIssueAsks(ref.owner.issue) : client.getArtifactAsks((await resolveDocument(ref.owner, ref.artifact)).artifact.id, "all"));
18310
+ const askRef = refTarget(ref, "ask", askRead.ask.id);
18236
18311
  const [pictures3, graph2] = await Promise.all([
18237
18312
  picturesOf(askTexts(askRead)),
18238
18313
  graphSections(client, askRef)
@@ -18505,33 +18580,6 @@ function pruneSessions(root, now) {
18505
18580
  writeFileSync(stamp, "", { mode: PRIVATE_FILE });
18506
18581
  }
18507
18582
 
18508
- // ../envoy-client/src/dispatch-subscribe.ts
18509
- function dispatchFollowNotice(details) {
18510
- if (typeof details !== "object" || details === null)
18511
- return null;
18512
- const { follows, issue: issue2, document } = details;
18513
- if (typeof follows !== "object" || follows === null)
18514
- return null;
18515
- const { ask } = follows;
18516
- if (typeof ask !== "string" || ask === "")
18517
- return null;
18518
- let owner;
18519
- if (typeof issue2 === "string" && issue2 !== "") {
18520
- owner = issueTopic(issue2);
18521
- } else if (typeof document === "string") {
18522
- const [project, slug] = document.split("/", 2);
18523
- if (!project || !slug)
18524
- return null;
18525
- owner = { label: document, topic: documentTopicOf(project, slug).topic };
18526
- } else {
18527
- return null;
18528
- }
18529
- return {
18530
- ask,
18531
- text: `Following ask ${ask} on ${owner.label}: its answer and replies reach you directly (${commandLine("dispatch_follow", { ask, action: "unfollow" })} to stop). For every event on ${owner.label}: envoy_subscribe ${owner.topic}.`
18532
- };
18533
- }
18534
-
18535
18583
  // ../envoy-client/src/envoy-config.ts
18536
18584
  import {
18537
18585
  chmodSync,
@@ -19079,6 +19127,109 @@ function dispatchSettings(env, config2) {
19079
19127
  }
19080
19128
  }
19081
19129
 
19130
+ // ../envoy-client/src/envoy-settings.ts
19131
+ var DEFAULT_ENVOY_URL = "http://127.0.0.1:9020";
19132
+ var DEFAULT_HEARTBEAT_MS = 120000;
19133
+ var MIN_HEARTBEAT_MS = 25;
19134
+ function natsSeedFromEnvironment(env) {
19135
+ const { NATS_NKEY_SEED_FILE: file2, NATS_NKEY_SEED: plain } = env;
19136
+ if (file2 !== undefined) {
19137
+ if (file2 === "")
19138
+ throw new Error("NATS_NKEY_SEED_FILE is set but empty");
19139
+ return {
19140
+ value: readSecretFile("NATS_NKEY_SEED_FILE", file2),
19141
+ source: `NATS_NKEY_SEED_FILE (${file2})`
19142
+ };
19143
+ }
19144
+ if (plain === undefined)
19145
+ return;
19146
+ const value = plain.trim();
19147
+ if (value.length === 0)
19148
+ throw new Error("NATS_NKEY_SEED is set but empty");
19149
+ return { value, source: "NATS_NKEY_SEED" };
19150
+ }
19151
+ var NO_NATS_SERVER = "Envoy has no NATS server (envoy.natsUrls, ENVOY_NATS_URL)";
19152
+ var NO_NATS_CONNECTION = "this session opens no NATS connection";
19153
+ function natsServerKey(url2) {
19154
+ const match = /^([a-z][a-z0-9+.-]*):\/\/([^@/]*@)?(\[[^\]]*\]|[^:/]*)(?::(\d+))?(\/.*)?$/i.exec(url2);
19155
+ if (match === null)
19156
+ return url2;
19157
+ const [, scheme = "", userinfo = "", host = "", port = "4222", rest = ""] = match;
19158
+ return `${scheme.toLowerCase()}://${userinfo}${host.toLowerCase()}:${port}${rest.replace(/\/+$/, "")}`;
19159
+ }
19160
+ function natsCredential(env, section, namedNatsUrls, filePath) {
19161
+ const fromEnvironment = natsSeedFromEnvironment(env);
19162
+ if (fromEnvironment !== undefined || section.natsSeed === undefined)
19163
+ return fromEnvironment;
19164
+ if (namedNatsUrls.length > 0) {
19165
+ const servers = new Set((section.natsUrls ?? []).map(natsServerKey));
19166
+ const named = new Set(namedNatsUrls.map(natsServerKey));
19167
+ if (named.size !== servers.size || ![...named].every((server) => servers.has(server))) {
19168
+ throw new Error(`ENVOY_NATS_URL names other servers than ${filePath}'s envoy.natsUrls; set NATS_NKEY_SEED_FILE for them`);
19169
+ }
19170
+ }
19171
+ return {
19172
+ value: fileValue(section.natsSeed, "envoy.natsSeed", filePath, env),
19173
+ source: `envoy.natsSeed in ${filePath}`
19174
+ };
19175
+ }
19176
+ function envoySettings(env, config2, options = {}) {
19177
+ if (config2.kind === "failed")
19178
+ return offWithError(config2.error);
19179
+ const section = config2.envoy;
19180
+ if (section.enabled === false) {
19181
+ return offWithNotice(`${config2.path} sets envoy.enabled to false: Envoy is off`);
19182
+ }
19183
+ const namedUrl = nonEmpty(env.ENVOY_URL);
19184
+ const namedNatsUrls = (env.ENVOY_NATS_URL ?? "").split(",").map((url3) => url3.trim()).filter((url3) => url3.length > 0);
19185
+ const addressed = namedUrl !== undefined || namedNatsUrls.length > 0 || section.url !== undefined || (section.natsUrls ?? []).length > 0 || section.enabled === true;
19186
+ if (!addressed) {
19187
+ return offWithNotice(config2.missing === null ? `${config2.path} names no Envoy listener or NATS server, and neither ENVOY_URL nor ENVOY_NATS_URL is set: Envoy is off` : missingConfigNotice(config2.path, config2.missing, "Envoy is off"));
19188
+ }
19189
+ if (namedUrl !== undefined && httpUrl2(namedUrl) === undefined) {
19190
+ return offWithError("ENVOY_URL must be an http or https URL");
19191
+ }
19192
+ const listener = section.url ?? DEFAULT_ENVOY_URL;
19193
+ const url2 = namedUrl ?? listener;
19194
+ let token;
19195
+ try {
19196
+ const source = bearerSource(env, "ENVOY", () => section.token === undefined ? undefined : {
19197
+ token: section.token,
19198
+ field: "envoy.token",
19199
+ path: config2.path,
19200
+ server: listener,
19201
+ checkout: false
19202
+ });
19203
+ token = source.kind === "none" ? undefined : source.kind === "variable" ? source.token : fileBearer(source, url2, "ENVOY", env);
19204
+ } catch (error48) {
19205
+ return offWithError(messageFor(error48));
19206
+ }
19207
+ const natsUrls = namedNatsUrls.length > 0 ? namedNatsUrls : section.natsUrls ?? [];
19208
+ let inbound;
19209
+ if (options.nats === false)
19210
+ inbound = { off: NO_NATS_CONNECTION };
19211
+ else if (natsUrls.length === 0)
19212
+ inbound = { off: NO_NATS_SERVER };
19213
+ else {
19214
+ try {
19215
+ inbound = { natsUrls, natsSeed: natsCredential(env, section, namedNatsUrls, config2.path) };
19216
+ } catch (error48) {
19217
+ inbound = { off: messageFor(error48) };
19218
+ }
19219
+ }
19220
+ const rawHeartbeatMs = Number(env.ENVOY_HEARTBEAT_MS);
19221
+ return {
19222
+ enabled: true,
19223
+ url: normalizeEnvoyUrl(url2),
19224
+ token,
19225
+ inbound,
19226
+ heartbeatMs: Number.isFinite(rawHeartbeatMs) && rawHeartbeatMs > 0 ? Math.max(rawHeartbeatMs, MIN_HEARTBEAT_MS) : DEFAULT_HEARTBEAT_MS
19227
+ };
19228
+ }
19229
+ function normalizeEnvoyUrl(value) {
19230
+ return value.replace(/\/+$/, "");
19231
+ }
19232
+
19082
19233
  // ../envoy-client/src/session-settings.ts
19083
19234
  function sessionConfigFile(harness, env) {
19084
19235
  try {
@@ -19105,6 +19256,241 @@ function sessionDispatchSettings(file2, env, options) {
19105
19256
  missing: config2.kind === "read" ? config2.missing : null
19106
19257
  };
19107
19258
  }
19259
+ function sessionEnvoySettings(file2, env, options) {
19260
+ if (file2.path === null)
19261
+ return offWithError(file2.error);
19262
+ return envoySettings(env, readEnvoyConfig(file2.path, env, options), { nats: options.nats });
19263
+ }
19264
+
19265
+ // ../envoy-client/src/transport.ts
19266
+ var DEFAULT_TIMEOUT_MS = 5000;
19267
+ var RETRY_DELAY_MS = 250;
19268
+ var InterestWireSchema = exports_external.object({
19269
+ session_id: exports_external.string(),
19270
+ machine_id: exports_external.string(),
19271
+ dir: exports_external.string(),
19272
+ topics: exports_external.array(exports_external.string()),
19273
+ updated_at: exports_external.number().int().optional(),
19274
+ warnings: exports_external.array(exports_external.string()).optional()
19275
+ });
19276
+ var SessionWireSchema = exports_external.object({
19277
+ session_id: exports_external.string(),
19278
+ machine_id: exports_external.string(),
19279
+ host: exports_external.string().optional(),
19280
+ dir: exports_external.string(),
19281
+ port: exports_external.number().int(),
19282
+ title: exports_external.string(),
19283
+ topics: exports_external.array(exports_external.string()),
19284
+ roles: exports_external.array(exports_external.string()).optional(),
19285
+ capabilities: exports_external.array(exports_external.string()).optional(),
19286
+ self_subscribed: exports_external.boolean(),
19287
+ last_seen: exports_external.number().int().optional()
19288
+ });
19289
+ var RoleWireSchema = exports_external.object({
19290
+ role: exports_external.string(),
19291
+ holder: exports_external.string(),
19292
+ host: exports_external.string().optional(),
19293
+ last_seen: exports_external.number().int()
19294
+ });
19295
+ var RoleHeldWireSchema = exports_external.object({
19296
+ error: exports_external.string(),
19297
+ role: exports_external.string(),
19298
+ holder: exports_external.string()
19299
+ });
19300
+ var ErrorWireSchema = exports_external.object({
19301
+ error: exports_external.string(),
19302
+ expected: exports_external.array(exports_external.string()).optional()
19303
+ });
19304
+ var EnvelopeResponseSchema = EnvelopeSchema.extend({
19305
+ recipient: exports_external.string().optional(),
19306
+ holder: exports_external.string().optional()
19307
+ });
19308
+ function expandSubscriptionTopics(topics) {
19309
+ const expanded = new Set;
19310
+ for (const topic of topics) {
19311
+ const base = topic.endsWith(".>") ? topic.slice(0, -2) : undefined;
19312
+ if (base === undefined || base.includes("*") || base.includes(">")) {
19313
+ expanded.add(topic);
19314
+ continue;
19315
+ }
19316
+ const segments = base.split(".");
19317
+ if (segments.length < 2 || segments.some((segment) => segment.length === 0)) {
19318
+ throw new TypeError(`cannot expand Envoy wildcard topic "${topic}": its concrete base must have at least two non-empty segments`);
19319
+ }
19320
+ expanded.add(base);
19321
+ expanded.add(topic);
19322
+ }
19323
+ return [...expanded];
19324
+ }
19325
+
19326
+ class EnvoyApiError extends Error {
19327
+ details;
19328
+ name = "EnvoyApiError";
19329
+ expected;
19330
+ constructor(details) {
19331
+ let parsed;
19332
+ try {
19333
+ const result = ErrorWireSchema.safeParse(JSON.parse(details.responseBody));
19334
+ if (result.success)
19335
+ parsed = result.data;
19336
+ } catch {}
19337
+ super(parsed === undefined ? `${details.method} ${details.url} failed with ${details.status}: ${details.responseBody}` : `${parsed.error}${parsed.expected === undefined ? "" : ` (expected: ${parsed.expected.join(", ")})`}`);
19338
+ this.details = details;
19339
+ this.expected = parsed?.expected;
19340
+ }
19341
+ }
19342
+ function createEnvoyClient(config2) {
19343
+ const baseUrl = normalizeEnvoyUrl(config2.baseUrl);
19344
+ const timeoutMs = config2.timeoutMs ?? DEFAULT_TIMEOUT_MS;
19345
+ const apiToken = config2.token;
19346
+ const request = async (path2, init) => {
19347
+ const url2 = `${baseUrl}${path2}`;
19348
+ const attempts = config2.retry === false ? 1 : 2;
19349
+ for (let attempt = 0;attempt < attempts; attempt += 1) {
19350
+ let response;
19351
+ try {
19352
+ const headers = new Headers(init.headers);
19353
+ if (apiToken)
19354
+ headers.set("Authorization", `Bearer ${apiToken}`);
19355
+ response = await config2.fetch(url2, {
19356
+ ...init,
19357
+ headers,
19358
+ signal: AbortSignal.timeout(timeoutMs)
19359
+ });
19360
+ } catch (error49) {
19361
+ if (attempt + 1 < attempts) {
19362
+ await waitForRetry();
19363
+ continue;
19364
+ }
19365
+ throw error49;
19366
+ }
19367
+ const responseBody = await response.text();
19368
+ if (response.ok)
19369
+ return responseBody;
19370
+ const error48 = new EnvoyApiError({
19371
+ method: init.method ?? "GET",
19372
+ url: url2,
19373
+ status: response.status,
19374
+ responseBody
19375
+ });
19376
+ if (response.status >= 500 && attempt + 1 < attempts) {
19377
+ await waitForRetry();
19378
+ continue;
19379
+ }
19380
+ throw error48;
19381
+ }
19382
+ throw new Error("Envoy request exhausted retries");
19383
+ };
19384
+ const post = (path2, body) => request(path2, {
19385
+ method: "POST",
19386
+ headers: { "Content-Type": "application/json" },
19387
+ body: JSON.stringify(body)
19388
+ });
19389
+ return {
19390
+ subscribe: async (input) => InterestWireSchema.parse(JSON.parse(await post("/v1/interests/subscribe", {
19391
+ session_id: input.sessionID,
19392
+ dir: input.directory,
19393
+ topics: expandSubscriptionTopics(input.topics),
19394
+ port: input.port,
19395
+ title: input.title,
19396
+ ...input.capabilities === undefined ? {} : { capabilities: input.capabilities },
19397
+ driving: input.driving,
19398
+ ...input.selfSubscribed === undefined ? {} : { self_subscribed: input.selfSubscribed },
19399
+ host: machineID()
19400
+ }))),
19401
+ unsubscribe: async (input) => {
19402
+ await post("/v1/interests/unsubscribe", {
19403
+ session_id: input.sessionID,
19404
+ topics: expandSubscriptionTopics(input.topics)
19405
+ });
19406
+ },
19407
+ getInterest: async (sessionID) => InterestWireSchema.parse(JSON.parse(await request(`/v1/interests/${sessionID}`, {}))),
19408
+ send: async (input) => {
19409
+ const idempotencyKey = input.idempotencyKey ?? crypto.randomUUID();
19410
+ const response = EnvelopeResponseSchema.parse(JSON.parse(await post("/v1/messages/send", {
19411
+ source: input.source ?? "agent",
19412
+ ...sourceSession(input.sourceSessionID),
19413
+ target_session: input.targetSessionID,
19414
+ message: input.message,
19415
+ idempotency_key: idempotencyKey,
19416
+ ...messageMetadata(input)
19417
+ })));
19418
+ return {
19419
+ envelope: response,
19420
+ recipient: response.recipient ?? input.targetSessionID,
19421
+ confirmed: response.recipient !== undefined
19422
+ };
19423
+ },
19424
+ publish: async (input) => {
19425
+ const idempotencyKey = input.idempotencyKey ?? crypto.randomUUID();
19426
+ const response = EnvelopeResponseSchema.parse(JSON.parse(await post("/v1/messages/publish", {
19427
+ source: input.source ?? "agent",
19428
+ ...sourceSession(input.sourceSessionID),
19429
+ topic: input.topic,
19430
+ message: input.message,
19431
+ ...input.payload === undefined ? {} : { payload: input.payload },
19432
+ idempotency_key: idempotencyKey,
19433
+ ...messageMetadata(input)
19434
+ })));
19435
+ return {
19436
+ envelope: response,
19437
+ ...response.holder === undefined ? {} : { holder: response.holder }
19438
+ };
19439
+ },
19440
+ unregisterSession: async (sessionID) => {
19441
+ await request(`/v1/sessions/${encodeURIComponent(sessionID)}`, { method: "DELETE" });
19442
+ },
19443
+ setRole: async (input) => {
19444
+ const soft = input.soft === true;
19445
+ const previous = soft ? input.previousSessionID ?? "" : "";
19446
+ const body = {
19447
+ session_id: input.sessionID,
19448
+ role: input.role,
19449
+ ...soft ? { soft: true } : {},
19450
+ ...previous === "" ? {} : { previous_session_id: previous }
19451
+ };
19452
+ let raw;
19453
+ try {
19454
+ raw = await post("/v1/roles/set", body);
19455
+ } catch (error48) {
19456
+ if (error48 instanceof EnvoyApiError && error48.details.status === 409) {
19457
+ const held = RoleHeldWireSchema.safeParse(JSON.parse(error48.details.responseBody));
19458
+ if (held.success)
19459
+ return { claimed: false, holder: held.data.holder };
19460
+ }
19461
+ throw error48;
19462
+ }
19463
+ return { claimed: true, interest: InterestWireSchema.parse(JSON.parse(raw)) };
19464
+ },
19465
+ getRole: async (role) => RoleWireSchema.parse(JSON.parse(await request(`/v1/roles/${encodeURIComponent(role)}`, { method: "GET" }))),
19466
+ listSessions: async (input = {}) => {
19467
+ const search = new URLSearchParams;
19468
+ if (input.directory !== undefined)
19469
+ search.set("dir", input.directory);
19470
+ if (input.title !== undefined)
19471
+ search.set("title", input.title);
19472
+ const query = search.size === 0 ? "" : `?${search.toString()}`;
19473
+ return SessionWireSchema.array().parse(JSON.parse(await request(`/v1/sessions${query}`, {})));
19474
+ }
19475
+ };
19476
+ }
19477
+ function waitForRetry() {
19478
+ const { promise: promise2, resolve: resolve2 } = Promise.withResolvers();
19479
+ setTimeout(resolve2, RETRY_DELAY_MS);
19480
+ return promise2;
19481
+ }
19482
+ function messageMetadata(input) {
19483
+ return {
19484
+ ...input.inReplyTo === undefined ? {} : { in_reply_to: input.inReplyTo },
19485
+ ...input.supersedes === undefined ? {} : { supersedes: input.supersedes },
19486
+ ...input.urgency === undefined ? {} : { urgency: input.urgency },
19487
+ ...input.expectsReply === undefined ? {} : { expects_reply: input.expectsReply },
19488
+ ...input.expiresAt === undefined ? {} : { expires_at: input.expiresAt }
19489
+ };
19490
+ }
19491
+ function sourceSession(sessionID) {
19492
+ return sessionID === undefined || sessionID === "" ? {} : { source_session: sessionID };
19493
+ }
19108
19494
 
19109
19495
  // ../envoy-client/src/dispatch-cli.ts
19110
19496
  var OK = 0;
@@ -19116,6 +19502,7 @@ var STATE_WRITE_FAILED = "dispatch: Dispatch took the call, but this session's s
19116
19502
  function stateWriteFailed(problem) {
19117
19503
  return `${STATE_WRITE_FAILED} could not be written: ${problem}`;
19118
19504
  }
19505
+ var SUBSCRIPTIONS_TIMEOUT_MS = 1500;
19119
19506
  function fitOutput(text, pictures, kept, limit, writeFull) {
19120
19507
  const whole = [text, ...pictures, ...kept].join(`
19121
19508
  `);
@@ -19214,6 +19601,28 @@ async function runDispatchCli(argv, rawEnv, io) {
19214
19601
  return USAGE;
19215
19602
  }
19216
19603
  const config2 = resolution;
19604
+ let topics;
19605
+ const readTopics = async () => {
19606
+ try {
19607
+ const envoy = sessionEnvoySettings(file2, env, { cwd: io.cwd, nats: false });
19608
+ if (!envoy.enabled)
19609
+ return null;
19610
+ const listener = createEnvoyClient({
19611
+ baseUrl: envoy.url,
19612
+ fetch: io.fetchImpl ?? fetch,
19613
+ token: envoy.token,
19614
+ timeoutMs: SUBSCRIPTIONS_TIMEOUT_MS,
19615
+ retry: false
19616
+ });
19617
+ return (await listener.getInterest(sessionId)).topics;
19618
+ } catch (error48) {
19619
+ return error48 instanceof EnvoyApiError && error48.details.status === 404 ? [] : null;
19620
+ }
19621
+ };
19622
+ const subscriptions = () => {
19623
+ topics ??= readTopics();
19624
+ return topics;
19625
+ };
19217
19626
  let follows;
19218
19627
  try {
19219
19628
  pruneSessions(sessionStateRoot(env), Date.now());
@@ -19239,7 +19648,8 @@ async function runDispatchCli(argv, rawEnv, io) {
19239
19648
  sessionTitle: env.DISPATCH_SESSION_TITLE ?? readSessionTitle(dir),
19240
19649
  config: config2,
19241
19650
  env,
19242
- ...io.fetchImpl === undefined ? {} : { fetchImpl: io.fetchImpl }
19651
+ ...io.fetchImpl === undefined ? {} : { fetchImpl: io.fetchImpl },
19652
+ subscriptions
19243
19653
  });
19244
19654
  text = result.text;
19245
19655
  for (const warning of result.warnings ?? []) {
@@ -19254,10 +19664,10 @@ async function runDispatchCli(argv, rawEnv, io) {
19254
19664
  } catch (error48) {
19255
19665
  problems.push(messageFor(error48));
19256
19666
  }
19257
- const notice = dispatchFollowNotice(result.details);
19258
- if (notice !== null && !follows.has(notice.ask)) {
19259
- follows.add(notice.ask);
19260
- notes.push(notice.text);
19667
+ const followed = followedAsk(result.details);
19668
+ if (followed !== null && !follows.has(followed.ask)) {
19669
+ follows.add(followed.ask);
19670
+ notes.push(followNoticeText(followed, await subscriptions()));
19261
19671
  }
19262
19672
  entry = { tool, details: result.details };
19263
19673
  code = OK;
package/dist/envoy.js CHANGED
@@ -33423,6 +33423,34 @@ function machineID() {
33423
33423
  // ../envoy-client/src/dispatch-cwd.ts
33424
33424
  var execFileAsync = promisify(execFile);
33425
33425
 
33426
+ // ../envoy-client/src/dispatch-subscribe.ts
33427
+ function subscriptionRemovedTopics(raw, sessionID) {
33428
+ let envelope2;
33429
+ try {
33430
+ envelope2 = JSON.parse(raw);
33431
+ } catch {
33432
+ return;
33433
+ }
33434
+ if (typeof envelope2 !== "object" || envelope2 === null)
33435
+ return;
33436
+ const { source, payload } = envelope2;
33437
+ if (source !== "dispatch" || typeof payload !== "string")
33438
+ return;
33439
+ let event;
33440
+ try {
33441
+ event = JSON.parse(payload);
33442
+ } catch {
33443
+ return;
33444
+ }
33445
+ const parsedEvent = DispatchEventSchema.safeParse(event);
33446
+ if (!parsedEvent.success || parsedEvent.data.type !== "subscription.removed")
33447
+ return;
33448
+ const parsedPayload = SubscriptionRemovedEventPayloadSchema.safeParse(parsedEvent.data.payload);
33449
+ if (!parsedPayload.success || parsedPayload.data.session_id !== sessionID)
33450
+ return;
33451
+ return parsedPayload.data.topics ?? [];
33452
+ }
33453
+
33426
33454
  // ../envoy-client/src/dispatch-execute.ts
33427
33455
  var triageAdviceShown = new Set;
33428
33456
  var refGrammarProblem = "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>, " + "dispatch://PROJECT/artifact/<document-ref> (an artifact id, slug, or filename), or " + "dispatch://agent/<session id>/artifact/<slug> (a picture in a conversation on the Agents page, " + "for dispatch doc-read); a dashboard URL on this Dispatch server is accepted too";
@@ -33516,34 +33544,6 @@ function writeSessionTitle(dir, title) {
33516
33544
  writeAtomically(join3(dir, "title"), title);
33517
33545
  }
33518
33546
 
33519
- // ../envoy-client/src/dispatch-subscribe.ts
33520
- function subscriptionRemovedTopics(raw, sessionID) {
33521
- let envelope2;
33522
- try {
33523
- envelope2 = JSON.parse(raw);
33524
- } catch {
33525
- return;
33526
- }
33527
- if (typeof envelope2 !== "object" || envelope2 === null)
33528
- return;
33529
- const { source, payload } = envelope2;
33530
- if (source !== "dispatch" || typeof payload !== "string")
33531
- return;
33532
- let event;
33533
- try {
33534
- event = JSON.parse(payload);
33535
- } catch {
33536
- return;
33537
- }
33538
- const parsedEvent = DispatchEventSchema.safeParse(event);
33539
- if (!parsedEvent.success || parsedEvent.data.type !== "subscription.removed")
33540
- return;
33541
- const parsedPayload = SubscriptionRemovedEventPayloadSchema.safeParse(parsedEvent.data.payload);
33542
- if (!parsedPayload.success || parsedPayload.data.session_id !== sessionID)
33543
- return;
33544
- return parsedPayload.data.topics ?? [];
33545
- }
33546
-
33547
33547
  // ../envoy-client/src/nats-auth.ts
33548
33548
  var import_nats = __toESM(require_mod4(), 1);
33549
33549
  var seedKeys = import_nats.nkeys;
@@ -34249,7 +34249,8 @@ function createEnvoyClient(config2) {
34249
34249
  const apiToken = config2.token;
34250
34250
  const request = async (path2, init) => {
34251
34251
  const url2 = `${baseUrl}${path2}`;
34252
- for (let attempt = 0;attempt < 2; attempt += 1) {
34252
+ const attempts = config2.retry === false ? 1 : 2;
34253
+ for (let attempt = 0;attempt < attempts; attempt += 1) {
34253
34254
  let response;
34254
34255
  try {
34255
34256
  const headers = new Headers(init.headers);
@@ -34261,7 +34262,7 @@ function createEnvoyClient(config2) {
34261
34262
  signal: AbortSignal.timeout(timeoutMs)
34262
34263
  });
34263
34264
  } catch (error49) {
34264
- if (attempt === 0) {
34265
+ if (attempt + 1 < attempts) {
34265
34266
  await waitForRetry();
34266
34267
  continue;
34267
34268
  }
@@ -34276,7 +34277,7 @@ function createEnvoyClient(config2) {
34276
34277
  status: response.status,
34277
34278
  responseBody
34278
34279
  });
34279
- if (response.status >= 500 && attempt === 0) {
34280
+ if (response.status >= 500 && attempt + 1 < attempts) {
34280
34281
  await waitForRetry();
34281
34282
  continue;
34282
34283
  }
@@ -57,6 +57,7 @@ A spec is the design conversation with the human, written down. It starts as the
57
57
  - **Rewrite a changed point. Do not append.** The decision block stays as the record. Versions keep the history.
58
58
  - **No placeholders**: no TBD, TODO or hedging. An open item is a decision block or a two-lane contract you settle over Envoy. Or it is a technical decision your lane makes where the work happens, outside the spec.
59
59
  - **No progress**: no status, timestamps, "Update HH:MMZ" sections, PR lists or handoff notes.
60
+ - **Repeated measurements** (hourly counts, a fleet table) go in one artifact that you edit in place, linked once from the spec. Never post them as ask replies or as new spec rows.
60
61
  - **No commentary**: never call it a draft, version or turn, preview a later version, describe a correction, or narrate the exchange.
61
62
 
62
63
  Before a version goes out, read it as the human will. No two passages conflict, each point has one reading, and each decision block passes the phone test.
@@ -20,9 +20,10 @@ dispatch ask --issue DSP-42 \
20
20
  --option 'Leave it expired: Changes nothing now but leaves the fix unable to run.'
21
21
  ```
22
22
 
23
- - Correct an open ask in place with `dispatch edit-ask`, not a second ask, while the same decision stays open. The human then reconfirms. You cannot edit an answered or resolved ask. If the decision changed, retract it and open a new one.
23
+ - Correct an open ask in place with `dispatch edit-ask`, not a second ask, while the same decision stays open. The human then reconfirms. You cannot edit an answered or resolved ask. If the decision of an open ask changed, retract it and open a new one. If the decision of an answered ask changed, open a new one.
24
24
  - `edit-ask` on a block ask writes the block too, so it mints a version and closes a spec's design gate until approved. Change anything else about the block with `dispatch doc-edit`. A retry with unchanged fields is safe.
25
25
  - A human can change an answer. A new `ask.answered` carries `previous_answer`. Act on the newest.
26
+ - When the selected option and the typed text disagree, the text is the answer. Act on it. To clarify, open a new ask.
26
27
  - Retract a moot or superseded ask, or resolve one whose answer you found:
27
28
 
28
29
  ```bash
@@ -34,7 +34,7 @@ EOF
34
34
  - `delete` of a block's whole text deletes the block. A partial match keeps it. A bullet with a nested list hoists the list. A bullet with other content needs `delete {block:"<item id>"}`.
35
35
  - Dispatch refuses a `delete` or `retype` that takes out an open ask's block, delete-then-insert included. Reword with `replace`, relocate with `move`, or use `dispatch edit-ask`. Moving an open block out of a container and deleting the container takes two calls.
36
36
  - `retype` turns a paragraph or typed block into a typed block in place, keeping its id. Use it when a paragraph is the question that should become a decision.
37
- - An ask block has two ids. The block id (`:::ask{#<id> …}`) goes bare in `block` for `move` / `delete` (`block:<id>` only in `before` / `after`). The ask id goes to `dispatch open-asks`, `dispatch read` and `--reply-to-ask`. `dispatch://KEY/ask/<block-id>` answers `not found`.
37
+ - An ask block has two ids. The block id (`:::ask{#<id> …}`) goes bare in `block` for `move` / `delete` (`block:<id>` only in `before` / `after`). `dispatch read --ref dispatch://KEY/ask/<id>` takes either id, whole or as a unique prefix of 8 or more hex characters, and prints the ask id as `Ask id:`. `dispatch open-asks` lists ask ids. `edit-ask`, `resolve-ask`, `follow` and `--reply-to-ask` take only the ask id.
38
38
 
39
39
  ## Tables
40
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-envoy",
3
- "version": "10.15.0",
3
+ "version": "10.16.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "omp": {