@sjawhar/opencode-legion-envoy 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/server.js +200 -54
- package/package.json +1 -1
- package/skills/envoy/SKILL.md +123 -334
package/dist/src/server.js
CHANGED
|
@@ -4730,11 +4730,25 @@ function superRefine(fn) {
|
|
|
4730
4730
|
}
|
|
4731
4731
|
// ../../node_modules/.bun/zod@4.3.6/node_modules/zod/v4/classic/external.js
|
|
4732
4732
|
config(en_default());
|
|
4733
|
+
// ../contracts/src/dispatch-question.ts
|
|
4734
|
+
var DispatchQuestionOptionSchema = strictObject({
|
|
4735
|
+
label: string2().min(1),
|
|
4736
|
+
description: string2().optional()
|
|
4737
|
+
});
|
|
4738
|
+
var DispatchQuestionSchema = strictObject({
|
|
4739
|
+
askId: string2().optional(),
|
|
4740
|
+
question: string2().min(1),
|
|
4741
|
+
header: string2().optional(),
|
|
4742
|
+
options: array(DispatchQuestionOptionSchema).optional(),
|
|
4743
|
+
multiple: boolean2().optional(),
|
|
4744
|
+
custom: boolean2().optional()
|
|
4745
|
+
});
|
|
4746
|
+
var DispatchQuestionInputSchema = DispatchQuestionSchema.omit({ askId: true });
|
|
4733
4747
|
// ../contracts/src/envelope.ts
|
|
4734
4748
|
var isSubject = (value) => typeof value === "string" && value.length > 0;
|
|
4735
4749
|
var EnvelopeSchema = object({
|
|
4736
4750
|
event_id: string2().min(1),
|
|
4737
|
-
source: _enum(["agent", "envoy", "github", "slack", "whatsapp", "ghostwispr"]),
|
|
4751
|
+
source: _enum(["agent", "human", "envoy", "github", "slack", "whatsapp", "ghostwispr"]),
|
|
4738
4752
|
source_event_id: string2().min(1),
|
|
4739
4753
|
source_session: string2().optional(),
|
|
4740
4754
|
topic: custom(isSubject, { message: "topic must be a non-empty subject" }),
|
|
@@ -4744,7 +4758,18 @@ var EnvelopeSchema = object({
|
|
|
4744
4758
|
payload_summary: string2().min(1),
|
|
4745
4759
|
payload: string2().optional(),
|
|
4746
4760
|
payload_ref: string2().optional(),
|
|
4747
|
-
trace_id: string2().min(1)
|
|
4761
|
+
trace_id: string2().min(1),
|
|
4762
|
+
sender: object({
|
|
4763
|
+
session_id: string2().min(1),
|
|
4764
|
+
machine: string2().optional(),
|
|
4765
|
+
cwd: string2().optional(),
|
|
4766
|
+
title: string2().optional(),
|
|
4767
|
+
roles: array(string2()).optional()
|
|
4768
|
+
}).optional(),
|
|
4769
|
+
in_reply_to: string2().min(1).optional(),
|
|
4770
|
+
supersedes: string2().min(1).optional(),
|
|
4771
|
+
urgency: _enum(["low", "med", "high", "blocking"]).optional(),
|
|
4772
|
+
expects_reply: _enum(["none", "optional", "required"]).optional()
|
|
4748
4773
|
});
|
|
4749
4774
|
// ../contracts/src/handoff-schema.ts
|
|
4750
4775
|
var HANDOFF_SCHEMA_VERSION = 1;
|
|
@@ -5043,25 +5068,13 @@ var DISPATCH_ARGUMENTS = {
|
|
|
5043
5068
|
repo: "owner/name. Opening a thread only; defaults to the working directory's GitHub repo.",
|
|
5044
5069
|
parent: "<n> | owner/name#<n>[#<commentId>]. Opening a thread only: link the thread as a sub-issue of an existing issue and append a breadcrumb to the comment."
|
|
5045
5070
|
};
|
|
5046
|
-
|
|
5047
5071
|
class DispatchArgumentError extends Error {
|
|
5048
5072
|
name = "DispatchArgumentError";
|
|
5049
5073
|
}
|
|
5050
|
-
var QuestionOptionSchema = strictObject({
|
|
5051
|
-
label: string2().min(1),
|
|
5052
|
-
description: string2().optional()
|
|
5053
|
-
});
|
|
5054
|
-
var DispatchQuestionSchema = strictObject({
|
|
5055
|
-
question: string2().min(1),
|
|
5056
|
-
header: string2().optional(),
|
|
5057
|
-
options: array(QuestionOptionSchema).optional(),
|
|
5058
|
-
multiple: boolean2().optional(),
|
|
5059
|
-
custom: boolean2().optional()
|
|
5060
|
-
});
|
|
5061
5074
|
var prose = {
|
|
5062
5075
|
context: string2(),
|
|
5063
5076
|
question: string2(),
|
|
5064
|
-
ask: array(
|
|
5077
|
+
ask: array(DispatchQuestionInputSchema).optional()
|
|
5065
5078
|
};
|
|
5066
5079
|
var OpenThreadCallSchema = strictObject({
|
|
5067
5080
|
subject: string2(),
|
|
@@ -5076,7 +5089,7 @@ var dispatchToolShape = {
|
|
|
5076
5089
|
thread: string2().describe(DISPATCH_ARGUMENTS.thread).optional(),
|
|
5077
5090
|
context: string2().describe(DISPATCH_ARGUMENTS.context),
|
|
5078
5091
|
question: string2().describe(DISPATCH_ARGUMENTS.question),
|
|
5079
|
-
ask: array(
|
|
5092
|
+
ask: array(DispatchQuestionInputSchema).describe(DISPATCH_ARGUMENTS.ask).optional(),
|
|
5080
5093
|
urgency: _enum(DISPATCH_URGENCIES).describe(DISPATCH_ARGUMENTS.urgency).optional(),
|
|
5081
5094
|
repo: string2().describe(DISPATCH_ARGUMENTS.repo).optional(),
|
|
5082
5095
|
parent: string2().describe(DISPATCH_ARGUMENTS.parent).optional()
|
|
@@ -5184,7 +5197,7 @@ async function resolveOrigin(env, exec, cwd) {
|
|
|
5184
5197
|
cwd,
|
|
5185
5198
|
machine: machineID()
|
|
5186
5199
|
};
|
|
5187
|
-
const pane = env
|
|
5200
|
+
const pane = env.TMUX_PANE;
|
|
5188
5201
|
if (pane) {
|
|
5189
5202
|
const output = await tryExec(exec, "tmux", ["display-message", "-p", "-t", pane, "#S:#I.#P #{pane_id}"], cwd);
|
|
5190
5203
|
const [target, paneId] = output?.trim().split(" ") ?? [];
|
|
@@ -5378,10 +5391,10 @@ function readEnvoyFile(filePath) {
|
|
|
5378
5391
|
return { kind: "valid", settings: parsed.data.dispatch ?? null };
|
|
5379
5392
|
}
|
|
5380
5393
|
function resolveDispatchConfig(env, options = {}) {
|
|
5381
|
-
const explicit = env
|
|
5394
|
+
const explicit = env.DISPATCH_MCP_URL;
|
|
5382
5395
|
if (explicit)
|
|
5383
5396
|
return { url: explicit, error: null };
|
|
5384
|
-
const home = options.home ?? env
|
|
5397
|
+
const home = options.home ?? env.HOME ?? homedir();
|
|
5385
5398
|
const cwd = options.cwd ?? process.cwd();
|
|
5386
5399
|
const userFile = readEnvoyFile(path.join(home, ".config", "opencode", "envoy.json"));
|
|
5387
5400
|
const repoFile = readEnvoyFile(path.join(cwd, ".opencode", "envoy.json"));
|
|
@@ -5422,20 +5435,35 @@ function dispatchSubscriptionTopic(tool, output) {
|
|
|
5422
5435
|
}
|
|
5423
5436
|
|
|
5424
5437
|
// ../envoy-client/src/tool-contract.ts
|
|
5438
|
+
var DELIVERY_CONTRACT = "Delivery is at-least-once, possibly out of order across topics; use id for dedupe and at for freshness.";
|
|
5439
|
+
var TOPIC_GUIDE = "Topic guide (all are under notifications.): agent.<session_id> (subscribe: own inbox); role.<role> " + "(publish-to; holders claim via envoy_role_set). > matches one or more trailing tokens and does not " + "match the base subject. Envoy registers the concrete base when you subscribe to <subject>.>, so " + "the recommended default remains github.<owner>.<repo>.pr.<n>.>. Default PR subscription: " + "github.<owner>.<repo>.pr.<n>.> (it receives the quiet PR family): pr.<n> (lifecycle: " + "opened/synchronize/closed; closed carries merged, merge_commit_sha, merged_by, head_sha), " + "pr.<n>.comment, pr.<n>.review, pr.<n>.mention, pr.<n>.checks (one head-checks settlement event: " + 'passed/failed/cancelled/skipped with failing names and URLs; re-fires with superseded_settlement: "true" ' + "when new runs appear for the same head). Other GitHub: issue.<n>, issue.<n>.comment, " + "issue.<n>.mention, mention, push.branch.<name>, push.tag.<name>, workflow.<file>.<action> (only " + "runs without an associated PR); slack.<team>.<channel>.message|mention and " + "slack.<team>.<channel>.thread.<ts>.message|mention; ghostwispr.<session>.<kind>; " + "whatsapp.<phone>.<jid>.<kind>; envoy.exceptions.<original-topic>.";
|
|
5440
|
+
var MessageMetadataSchema = object({
|
|
5441
|
+
in_reply_to: string2().optional(),
|
|
5442
|
+
supersedes: string2().optional(),
|
|
5443
|
+
urgency: EnvelopeSchema.shape.urgency,
|
|
5444
|
+
expects_reply: EnvelopeSchema.shape.expects_reply,
|
|
5445
|
+
expires_at: EnvelopeSchema.shape.expires_at
|
|
5446
|
+
});
|
|
5447
|
+
var messageArguments = {
|
|
5448
|
+
message: string2(),
|
|
5449
|
+
...MessageMetadataSchema.shape
|
|
5450
|
+
};
|
|
5425
5451
|
var EnvoyToolOperation = {
|
|
5426
5452
|
subscribe: "subscribe",
|
|
5427
5453
|
unsubscribe: "unsubscribe",
|
|
5428
5454
|
listInterests: "listInterests",
|
|
5455
|
+
inbox: "inbox",
|
|
5429
5456
|
send: "send",
|
|
5430
5457
|
publish: "publish",
|
|
5431
5458
|
setRole: "setRole",
|
|
5459
|
+
getRole: "getRole",
|
|
5432
5460
|
whoami: "whoami",
|
|
5433
5461
|
listSessions: "listSessions"
|
|
5434
5462
|
};
|
|
5435
5463
|
var envoyToolSpecs = [
|
|
5436
5464
|
{
|
|
5437
5465
|
name: "envoy_subscribe",
|
|
5438
|
-
description:
|
|
5466
|
+
description: `Subscribe this session to Envoy notification topics. ${TOPIC_GUIDE}`,
|
|
5439
5467
|
arguments: {
|
|
5440
5468
|
topics: array(string2()).describe("NATS-style topic patterns to subscribe to.")
|
|
5441
5469
|
},
|
|
@@ -5456,20 +5484,27 @@ var envoyToolSpecs = [
|
|
|
5456
5484
|
operation: EnvoyToolOperation.listInterests,
|
|
5457
5485
|
requiresSubscriptionCapability: false
|
|
5458
5486
|
},
|
|
5487
|
+
{
|
|
5488
|
+
name: "envoy_inbox",
|
|
5489
|
+
description: "List this Pi session's 50 most recent rendered Envoy deliveries, newest first.",
|
|
5490
|
+
arguments: {},
|
|
5491
|
+
operation: EnvoyToolOperation.inbox,
|
|
5492
|
+
requiresSubscriptionCapability: false
|
|
5493
|
+
},
|
|
5459
5494
|
{
|
|
5460
5495
|
name: "envoy_send",
|
|
5461
|
-
description:
|
|
5496
|
+
description: `Send an Envoy agent-to-agent message directly to another session by session ID. ${DELIVERY_CONTRACT}`,
|
|
5462
5497
|
arguments: {
|
|
5463
|
-
session_id: string2().describe("Target session ID
|
|
5464
|
-
|
|
5498
|
+
session_id: string2().describe("Target session ID; find it with envoy_sessions or envoy_whoami."),
|
|
5499
|
+
...messageArguments
|
|
5465
5500
|
},
|
|
5466
5501
|
operation: EnvoyToolOperation.send,
|
|
5467
5502
|
requiresSubscriptionCapability: false
|
|
5468
5503
|
},
|
|
5469
5504
|
{
|
|
5470
5505
|
name: "envoy_publish",
|
|
5471
|
-
description:
|
|
5472
|
-
arguments: { topic: string2(),
|
|
5506
|
+
description: `Publish an Envoy message to any topic. ${DELIVERY_CONTRACT}`,
|
|
5507
|
+
arguments: { topic: string2(), ...messageArguments },
|
|
5473
5508
|
operation: EnvoyToolOperation.publish,
|
|
5474
5509
|
requiresSubscriptionCapability: false
|
|
5475
5510
|
},
|
|
@@ -5480,6 +5515,13 @@ var envoyToolSpecs = [
|
|
|
5480
5515
|
operation: EnvoyToolOperation.setRole,
|
|
5481
5516
|
requiresSubscriptionCapability: false
|
|
5482
5517
|
},
|
|
5518
|
+
{
|
|
5519
|
+
name: "envoy_role_get",
|
|
5520
|
+
description: "Get the live holder of a named Envoy role.",
|
|
5521
|
+
arguments: { role: string2() },
|
|
5522
|
+
operation: EnvoyToolOperation.getRole,
|
|
5523
|
+
requiresSubscriptionCapability: false
|
|
5524
|
+
},
|
|
5483
5525
|
{
|
|
5484
5526
|
name: "envoy_whoami",
|
|
5485
5527
|
description: "Returns this session's Envoy identity: session ID, machine ID, port, and directory.",
|
|
@@ -5489,8 +5531,12 @@ var envoyToolSpecs = [
|
|
|
5489
5531
|
},
|
|
5490
5532
|
{
|
|
5491
5533
|
name: "envoy_sessions",
|
|
5492
|
-
description: "List all live sessions registered with Envoy.
|
|
5493
|
-
arguments: {
|
|
5534
|
+
description: "List all live sessions registered with Envoy. Filter by optional machine, directory, or title.",
|
|
5535
|
+
arguments: {
|
|
5536
|
+
machine: string2().optional(),
|
|
5537
|
+
dir: string2().optional(),
|
|
5538
|
+
title: string2().optional()
|
|
5539
|
+
},
|
|
5494
5540
|
operation: EnvoyToolOperation.listSessions,
|
|
5495
5541
|
requiresSubscriptionCapability: false
|
|
5496
5542
|
}
|
|
@@ -5498,12 +5544,14 @@ var envoyToolSpecs = [
|
|
|
5498
5544
|
|
|
5499
5545
|
// ../envoy-client/src/transport.ts
|
|
5500
5546
|
var DEFAULT_TIMEOUT_MS = 5000;
|
|
5547
|
+
var RETRY_DELAY_MS = 250;
|
|
5501
5548
|
var InterestWireSchema = object({
|
|
5502
5549
|
session_id: string2(),
|
|
5503
5550
|
machine_id: string2(),
|
|
5504
5551
|
dir: string2(),
|
|
5505
5552
|
topics: array(string2()),
|
|
5506
|
-
updated_at: number2().int().optional()
|
|
5553
|
+
updated_at: number2().int().optional(),
|
|
5554
|
+
warnings: array(string2()).optional()
|
|
5507
5555
|
});
|
|
5508
5556
|
var SessionWireSchema = object({
|
|
5509
5557
|
session_id: string2(),
|
|
@@ -5512,15 +5560,55 @@ var SessionWireSchema = object({
|
|
|
5512
5560
|
port: number2().int(),
|
|
5513
5561
|
title: string2(),
|
|
5514
5562
|
topics: array(string2()),
|
|
5515
|
-
|
|
5516
|
-
|
|
5563
|
+
roles: array(string2()).optional(),
|
|
5564
|
+
self_subscribed: boolean2(),
|
|
5565
|
+
last_seen: number2().int().optional()
|
|
5566
|
+
});
|
|
5567
|
+
var RoleWireSchema = object({
|
|
5568
|
+
role: string2(),
|
|
5569
|
+
holder: string2(),
|
|
5570
|
+
last_seen: number2().int()
|
|
5571
|
+
});
|
|
5572
|
+
var ErrorWireSchema = object({
|
|
5573
|
+
error: string2(),
|
|
5574
|
+
expected: array(string2()).optional()
|
|
5575
|
+
});
|
|
5576
|
+
var EnvelopeResponseSchema = EnvelopeSchema.extend({
|
|
5577
|
+
recipient: string2().optional(),
|
|
5578
|
+
holder: string2().optional()
|
|
5579
|
+
});
|
|
5580
|
+
function expandSubscriptionTopics(topics) {
|
|
5581
|
+
const expanded = new Set;
|
|
5582
|
+
for (const topic of topics) {
|
|
5583
|
+
const base = topic.endsWith(".>") ? topic.slice(0, -2) : undefined;
|
|
5584
|
+
if (base === undefined || base.includes("*") || base.includes(">")) {
|
|
5585
|
+
expanded.add(topic);
|
|
5586
|
+
continue;
|
|
5587
|
+
}
|
|
5588
|
+
const segments = base.split(".");
|
|
5589
|
+
if (segments.length < 2 || segments.some((segment) => segment.length === 0)) {
|
|
5590
|
+
throw new TypeError(`cannot expand Envoy wildcard topic "${topic}": its concrete base must have at least two non-empty segments`);
|
|
5591
|
+
}
|
|
5592
|
+
expanded.add(base);
|
|
5593
|
+
expanded.add(topic);
|
|
5594
|
+
}
|
|
5595
|
+
return [...expanded];
|
|
5596
|
+
}
|
|
5517
5597
|
|
|
5518
5598
|
class EnvoyApiError extends Error {
|
|
5519
5599
|
details;
|
|
5520
5600
|
name = "EnvoyApiError";
|
|
5601
|
+
expected;
|
|
5521
5602
|
constructor(details) {
|
|
5522
|
-
|
|
5603
|
+
let parsed;
|
|
5604
|
+
try {
|
|
5605
|
+
const result = ErrorWireSchema.safeParse(JSON.parse(details.responseBody));
|
|
5606
|
+
if (result.success)
|
|
5607
|
+
parsed = result.data;
|
|
5608
|
+
} catch {}
|
|
5609
|
+
super(parsed === undefined ? `${details.method} ${details.url} failed with ${details.status}: ${details.responseBody}` : `${parsed.error}${parsed.expected === undefined ? "" : ` (expected: ${parsed.expected.join(", ")})`}`);
|
|
5523
5610
|
this.details = details;
|
|
5611
|
+
this.expected = parsed?.expected;
|
|
5524
5612
|
}
|
|
5525
5613
|
}
|
|
5526
5614
|
function createEnvoyClient(config) {
|
|
@@ -5528,17 +5616,33 @@ function createEnvoyClient(config) {
|
|
|
5528
5616
|
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
5529
5617
|
const request = async (path, init) => {
|
|
5530
5618
|
const url = `${baseUrl}${path}`;
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5619
|
+
for (let attempt = 0;attempt < 2; attempt += 1) {
|
|
5620
|
+
let response;
|
|
5621
|
+
try {
|
|
5622
|
+
response = await config.fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
|
5623
|
+
} catch (error) {
|
|
5624
|
+
if (attempt === 0) {
|
|
5625
|
+
await waitForRetry();
|
|
5626
|
+
continue;
|
|
5627
|
+
}
|
|
5628
|
+
throw error;
|
|
5629
|
+
}
|
|
5630
|
+
const responseBody = await response.text();
|
|
5631
|
+
if (response.ok)
|
|
5632
|
+
return responseBody;
|
|
5633
|
+
const error = new EnvoyApiError({
|
|
5535
5634
|
method: init.method ?? "GET",
|
|
5536
5635
|
url,
|
|
5537
5636
|
status: response.status,
|
|
5538
5637
|
responseBody
|
|
5539
5638
|
});
|
|
5639
|
+
if (response.status >= 500 && attempt === 0) {
|
|
5640
|
+
await waitForRetry();
|
|
5641
|
+
continue;
|
|
5642
|
+
}
|
|
5643
|
+
throw error;
|
|
5540
5644
|
}
|
|
5541
|
-
|
|
5645
|
+
throw new Error("Envoy request exhausted retries");
|
|
5542
5646
|
};
|
|
5543
5647
|
const post = (path, body) => request(path, {
|
|
5544
5648
|
method: "POST",
|
|
@@ -5549,7 +5653,7 @@ function createEnvoyClient(config) {
|
|
|
5549
5653
|
subscribe: async (input) => InterestWireSchema.parse(JSON.parse(await post("/v1/interests/subscribe", {
|
|
5550
5654
|
session_id: input.sessionID,
|
|
5551
5655
|
dir: input.directory,
|
|
5552
|
-
topics: input.topics,
|
|
5656
|
+
topics: expandSubscriptionTopics(input.topics),
|
|
5553
5657
|
port: input.port,
|
|
5554
5658
|
title: input.title,
|
|
5555
5659
|
driving: input.driving,
|
|
@@ -5558,29 +5662,71 @@ function createEnvoyClient(config) {
|
|
|
5558
5662
|
unsubscribe: async (input) => {
|
|
5559
5663
|
await post("/v1/interests/unsubscribe", {
|
|
5560
5664
|
session_id: input.sessionID,
|
|
5561
|
-
topics: input.topics
|
|
5665
|
+
topics: expandSubscriptionTopics(input.topics)
|
|
5562
5666
|
});
|
|
5563
5667
|
},
|
|
5564
5668
|
getInterest: async (sessionID) => InterestWireSchema.parse(JSON.parse(await request(`/v1/interests/${sessionID}`, {}))),
|
|
5565
|
-
send: async (input) =>
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5669
|
+
send: async (input) => {
|
|
5670
|
+
const idempotencyKey = input.idempotencyKey ?? crypto.randomUUID();
|
|
5671
|
+
const response = EnvelopeResponseSchema.parse(JSON.parse(await post("/v1/messages/send", {
|
|
5672
|
+
source: input.source ?? "agent",
|
|
5673
|
+
...input.sourceSessionID === undefined ? {} : { source_session: input.sourceSessionID },
|
|
5674
|
+
target_session: input.targetSessionID,
|
|
5675
|
+
message: input.message,
|
|
5676
|
+
idempotency_key: idempotencyKey,
|
|
5677
|
+
...messageMetadata(input)
|
|
5678
|
+
})));
|
|
5679
|
+
return {
|
|
5680
|
+
envelope: response,
|
|
5681
|
+
recipient: response.recipient ?? input.targetSessionID,
|
|
5682
|
+
confirmed: response.recipient !== undefined
|
|
5683
|
+
};
|
|
5684
|
+
},
|
|
5685
|
+
publish: async (input) => {
|
|
5686
|
+
const idempotencyKey = input.idempotencyKey ?? crypto.randomUUID();
|
|
5687
|
+
const response = EnvelopeResponseSchema.parse(JSON.parse(await post("/v1/messages/publish", {
|
|
5688
|
+
source: input.source ?? "agent",
|
|
5689
|
+
...input.sourceSessionID === undefined ? {} : { source_session: input.sourceSessionID },
|
|
5690
|
+
topic: input.topic,
|
|
5691
|
+
message: input.message,
|
|
5692
|
+
...input.payload === undefined ? {} : { payload: input.payload },
|
|
5693
|
+
idempotency_key: idempotencyKey,
|
|
5694
|
+
...messageMetadata(input)
|
|
5695
|
+
})));
|
|
5696
|
+
return {
|
|
5697
|
+
envelope: response,
|
|
5698
|
+
...response.holder === undefined ? {} : { holder: response.holder }
|
|
5699
|
+
};
|
|
5700
|
+
},
|
|
5701
|
+
unregisterSession: async (sessionID) => {
|
|
5702
|
+
await request(`/v1/sessions/${encodeURIComponent(sessionID)}`, { method: "DELETE" });
|
|
5703
|
+
},
|
|
5578
5704
|
setRole: async (input) => InterestWireSchema.parse(JSON.parse(await post("/v1/roles/set", { session_id: input.sessionID, role: input.role }))),
|
|
5579
|
-
|
|
5705
|
+
getRole: async (role) => RoleWireSchema.parse(JSON.parse(await request(`/v1/roles/${encodeURIComponent(role)}`, { method: "GET" }))),
|
|
5706
|
+
listSessions: async (input = {}) => {
|
|
5707
|
+
const search = new URLSearchParams;
|
|
5708
|
+
if (input.directory !== undefined)
|
|
5709
|
+
search.set("dir", input.directory);
|
|
5710
|
+
if (input.title !== undefined)
|
|
5711
|
+
search.set("title", input.title);
|
|
5712
|
+
const query = search.size === 0 ? "" : `?${search.toString()}`;
|
|
5713
|
+
return SessionWireSchema.array().parse(JSON.parse(await request(`/v1/sessions${query}`, {})));
|
|
5714
|
+
}
|
|
5580
5715
|
};
|
|
5581
5716
|
}
|
|
5582
|
-
function
|
|
5583
|
-
|
|
5717
|
+
function waitForRetry() {
|
|
5718
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
5719
|
+
setTimeout(resolve, RETRY_DELAY_MS);
|
|
5720
|
+
return promise;
|
|
5721
|
+
}
|
|
5722
|
+
function messageMetadata(input) {
|
|
5723
|
+
return {
|
|
5724
|
+
...input.inReplyTo === undefined ? {} : { in_reply_to: input.inReplyTo },
|
|
5725
|
+
...input.supersedes === undefined ? {} : { supersedes: input.supersedes },
|
|
5726
|
+
...input.urgency === undefined ? {} : { urgency: input.urgency },
|
|
5727
|
+
...input.expectsReply === undefined ? {} : { expects_reply: input.expectsReply },
|
|
5728
|
+
...input.expiresAt === undefined ? {} : { expires_at: input.expiresAt }
|
|
5729
|
+
};
|
|
5584
5730
|
}
|
|
5585
5731
|
|
|
5586
5732
|
// src/server.ts
|
package/package.json
CHANGED
package/skills/envoy/SKILL.md
CHANGED
|
@@ -5,337 +5,171 @@ description: Use when subscribing sessions to Envoy topics, sending agent-to-age
|
|
|
5
5
|
|
|
6
6
|
# Envoy
|
|
7
7
|
|
|
8
|
-
Envoy
|
|
8
|
+
Envoy delivers external signals and session messages. Deliveries are at-least-once and can arrive
|
|
9
|
+
out of order across topics: use `id` to deduplicate and `at` to judge freshness.
|
|
9
10
|
|
|
10
|
-
##
|
|
11
|
+
## The one subscription you need for a PR
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
- `envoy_unsubscribe(topics?)` — stop receiving some or all topics
|
|
14
|
-
- `envoy_list()` — show the union of live local and persisted registry subscriptions, with each topic marked `live`, `registry`, or `both`
|
|
15
|
-
- `envoy_send(session_id, message)` — SEND a message directly to another session
|
|
16
|
-
|
|
17
|
-
## Topic formats
|
|
18
|
-
|
|
19
|
-
### Agent-to-agent
|
|
20
|
-
|
|
21
|
-
- Direct session route:
|
|
22
|
-
- `notifications.agent.<session_id>`
|
|
23
|
-
|
|
24
|
-
Example:
|
|
25
|
-
|
|
26
|
-
- `notifications.agent.ses_2e6ca3034ffejVikSZ8mDwk0mR`
|
|
27
|
-
|
|
28
|
-
### Legion role claims
|
|
29
|
-
|
|
30
|
-
- Role topic:
|
|
31
|
-
- `notifications.role.<role>`
|
|
32
|
-
|
|
33
|
-
Legion agents receive through a daemon-minted role token. Claim the role for the current
|
|
34
|
-
session with `envoy_role_set(role="<role>")`; claiming transfers its holder with
|
|
35
|
-
last-claim-wins semantics. A claimant does not manually subscribe to its role topic.
|
|
36
|
-
|
|
37
|
-
Legion role tokens must satisfy `^[a-z0-9][a-z0-9_-]*$` and are unique across repositories:
|
|
13
|
+
Subscribe to the whole PR family, not individual event types:
|
|
38
14
|
|
|
39
15
|
```text
|
|
40
|
-
|
|
41
|
-
|
|
16
|
+
envoy_subscribe([
|
|
17
|
+
"notifications.github.example-org.example-repo.pr.42.>"
|
|
18
|
+
])
|
|
42
19
|
```
|
|
43
20
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
Claims last through the issue's post-close linger. They survive parking and worker
|
|
50
|
-
re-creation; a re-claim re-points the role to the backing session. The daemon publishes to an
|
|
51
|
-
issue role only while the issue is active. Inactive issue events become daemon state and later
|
|
52
|
-
surface as derived catch-up, never as raw event replay.
|
|
53
|
-
|
|
54
|
-
### Legion exception lane
|
|
55
|
-
|
|
56
|
-
`no_holder` and `delivery_failed` on a Legion role are daemon liveness signals, not a prompt
|
|
57
|
-
for a second subscriber or a manual retry. The daemon probes the process that owns the tree:
|
|
58
|
-
|
|
59
|
-
1. If it is alive, the daemon sends a control-topic directive. The extension revives or
|
|
60
|
-
re-creates the backing worker in code, then the daemon re-delivers the message.
|
|
61
|
-
2. If it is dead, the daemon resurrects the root process behind a generation lock and supplies
|
|
62
|
-
derived catch-up plus the shared workspace handoffs.
|
|
63
|
-
|
|
64
|
-
This keeps raw delivery failures out of architect context. Controller exception wakes are
|
|
65
|
-
handled by the controller's wake routing table; every other role follows the liveness path.
|
|
66
|
-
|
|
67
|
-
### GitHub
|
|
68
|
-
|
|
69
|
-
GitHub topics are **resource-scoped** — every event includes the resource type and number (or, for push/workflow events, the ref or workflow filename).
|
|
70
|
-
|
|
71
|
-
**Topic structure:** `notifications.github.<owner>.<repo>.<resource_type>.<number>.<event_kind>`
|
|
72
|
-
|
|
73
|
-
- PR opened/closed/merged/ready:
|
|
74
|
-
- `notifications.github.<owner>.<repo>.pr.<number>`
|
|
75
|
-
- Issue opened/closed/labeled:
|
|
76
|
-
- `notifications.github.<owner>.<repo>.issue.<number>`
|
|
77
|
-
- Comment on a PR:
|
|
78
|
-
- `notifications.github.<owner>.<repo>.pr.<number>.comment`
|
|
79
|
-
- Comment on an issue:
|
|
80
|
-
- `notifications.github.<owner>.<repo>.issue.<number>.comment`
|
|
81
|
-
- PR review submitted:
|
|
82
|
-
- `notifications.github.<owner>.<repo>.pr.<number>.review`
|
|
83
|
-
- Raw CI check observation (per-PR, immediate):
|
|
84
|
-
- `notifications.github.<owner>.<repo>.pr.<number>.check`
|
|
85
|
-
- Every PR-associated `check_run` publishes one raw observation per associated PR. `Payload` is JSON with `sha`, `name`, `status`, and `conclusion`; `PayloadSummary` names the check and its state.
|
|
86
|
-
- CI/check summary (per-PR, per-commit, debounced):
|
|
87
|
-
- `notifications.github.<owner>.<repo>.pr.<number>.ci`
|
|
88
|
-
- The same `check_run` updates ingest-side per-commit state in JetStream KV bucket `envoy_ci_state`. Once the check set has been quiet for `ENVOY_CI_DEBOUNCE` (default `5s`), Envoy publishes one JSON summary. A new summary is emitted only when the check set changes; a new push starts a fresh tally, and a re-run that returns to running changes the tally.
|
|
89
|
-
- `PayloadSummary` is a compact JSON object (`Payload` unused): `{"kind":"ci_summary","repo":"<o>/<r>","number":"<n>","sha":"<sha>","failed":{"count":N,"checks":[...]},"running":{...},"passed":{...},"queued":{...},"skipped":{...}}`. Each status is `{count, checks}` with the full sorted name list; every status is present (`{"count":0,"checks":[]}` when empty).
|
|
90
|
-
- `check_suite` is ignored (it is a per-app rollup without a per-check name). Checks not tied to a PR are dropped, so there is no repo-wide CI topic. For non-PR visibility, use `workflow.<filename>.<action>`; individual GitHub Actions `workflow_job` events are not routed.
|
|
91
|
-
- Mention events (per-resource):
|
|
92
|
-
- `notifications.github.<owner>.<repo>.pr.<number>.mention`
|
|
93
|
-
- `notifications.github.<owner>.<repo>.issue.<number>.mention`
|
|
94
|
-
- Mention events (repo-wide — catches all mentions):
|
|
95
|
-
- `notifications.github.<owner>.<repo>.mention`
|
|
96
|
-
- Push events (per branch/tag):
|
|
97
|
-
- `notifications.github.<owner>.<repo>.push.branch.<branch>`
|
|
98
|
-
- `notifications.github.<owner>.<repo>.push.tag.<tag>`
|
|
99
|
-
Branch and tag names with dots are sanitized to underscores (`v1.0.0` → `v1_0_0`).
|
|
100
|
-
Push events for refs other than `refs/heads/...` and `refs/tags/...` are not routed.
|
|
101
|
-
- Workflow run events (per workflow file):
|
|
102
|
-
- `notifications.github.<owner>.<repo>.workflow.<filename>.<action>`
|
|
103
|
-
Filename is the basename of `workflow_run.path` with dots sanitized (`ci.yml` → `ci_yml`).
|
|
104
|
-
`action` is one of `requested`, `in_progress`, `completed`.
|
|
105
|
-
|
|
106
|
-
**Using wildcards to subscribe broadly:**
|
|
107
|
-
- All events in a repo: `notifications.github.<owner>.<repo>.>`
|
|
108
|
-
- All PR events in a repo: `notifications.github.<owner>.<repo>.pr.>`
|
|
109
|
-
- All events for a specific PR: `notifications.github.<owner>.<repo>.pr.<number>.>`
|
|
110
|
-
- All issue events in a repo: `notifications.github.<owner>.<repo>.issue.>`
|
|
111
|
-
- All events for a specific issue: `notifications.github.<owner>.<repo>.issue.<number>.>`
|
|
112
|
-
- All push events in a repo: `notifications.github.<owner>.<repo>.push.>`
|
|
113
|
-
- Pushes to main only: `notifications.github.<owner>.<repo>.push.branch.main`
|
|
114
|
-
- All branch pushes: `notifications.github.<owner>.<repo>.push.branch.>`
|
|
115
|
-
- All tag pushes: `notifications.github.<owner>.<repo>.push.tag.>`
|
|
116
|
-
- All workflow events: `notifications.github.<owner>.<repo>.workflow.>`
|
|
117
|
-
- All events for a specific workflow: `notifications.github.<owner>.<repo>.workflow.ci_yml.>`
|
|
118
|
-
- Every workflow completion: `notifications.github.<owner>.<repo>.workflow.*.completed`
|
|
119
|
-
|
|
120
|
-
Examples:
|
|
121
|
-
|
|
122
|
-
- `notifications.github.example-org.example-repo.pr.9880` (PR #9880 state changes)
|
|
123
|
-
- `notifications.github.example-org.example-repo.pr.9880.comment` (comments on PR #9880)
|
|
124
|
-
- `notifications.github.example-org.example-repo.issue.9909.>` (all events on issue #9909)
|
|
125
|
-
- `notifications.github.sjawhar.legion.pr.>` (all PR events across all PRs)
|
|
126
|
-
- `notifications.github.sjawhar.legion.mention` (all @mentions repo-wide)
|
|
127
|
-
- `notifications.github.sjawhar.legion.push.branch.main` (pushes to main)
|
|
128
|
-
- `notifications.github.sjawhar.legion.workflow.ci_yml.in_progress` (CI workflow starts)
|
|
129
|
-
|
|
130
|
-
### Slack
|
|
131
|
-
|
|
132
|
-
- Channel message events:
|
|
133
|
-
- `notifications.slack.<team_id>.<channel_id>.message`
|
|
134
|
-
- App mention events:
|
|
135
|
-
- `notifications.slack.<team_id>.<channel_id>.mention`
|
|
136
|
-
- Thread message events:
|
|
137
|
-
- `notifications.slack.<team_id>.<channel_id>.thread.<normalized_ts>.message`
|
|
138
|
-
- Thread mention events:
|
|
139
|
-
- `notifications.slack.<team_id>.<channel_id>.thread.<normalized_ts>.mention`
|
|
140
|
-
|
|
141
|
-
Thread timestamps are normalized: `1234567890.123456` → `1234567890_123456`
|
|
142
|
-
(dots replaced with underscores to make the thread ID a single NATS segment).
|
|
143
|
-
|
|
144
|
-
Examples:
|
|
145
|
-
|
|
146
|
-
- `notifications.slack.T01234567.C0A0DHVU8HE.message`
|
|
147
|
-
- `notifications.slack.T01234567.C0A0DHVU8HE.mention`
|
|
148
|
-
- `notifications.slack.T01234567.C0A0DHVU8HE.thread.1234567890_123456.message`
|
|
149
|
-
- `notifications.slack.T01234567.C0A0DHVU8HE.thread.1234567890_123456.mention`
|
|
150
|
-
|
|
151
|
-
### Ghost Wispr
|
|
152
|
-
|
|
153
|
-
- Session started events:
|
|
154
|
-
- `notifications.ghostwispr.<session_id>.session.started`
|
|
155
|
-
- Session ended events:
|
|
156
|
-
- `notifications.ghostwispr.<session_id>.session.ended`
|
|
157
|
-
- Summary ready events:
|
|
158
|
-
- `notifications.ghostwispr.<session_id>.summary.ready`
|
|
159
|
-
|
|
160
|
-
**Parameters:**
|
|
161
|
-
- `<session_id>`: Ghost Wispr session timestamp string (e.g., `20260326041405`). Alphanumeric only, safe for NATS topic segments.
|
|
162
|
-
- Supported kinds: `session.started`, `session.ended`, `summary.ready`
|
|
163
|
-
|
|
164
|
-
Examples:
|
|
165
|
-
|
|
166
|
-
- `notifications.ghostwispr.20260326041405.session.ended` (session ended)
|
|
167
|
-
- `notifications.ghostwispr.20260326041629.summary.ready` (summary ready)
|
|
168
|
-
|
|
169
|
-
### WhatsApp
|
|
170
|
-
|
|
171
|
-
- Chat message events:
|
|
172
|
-
- `notifications.whatsapp.<phone>.<jid>.message`
|
|
173
|
-
- Status/receipt events:
|
|
174
|
-
- `notifications.whatsapp.<phone>.<jid>.status`
|
|
175
|
-
|
|
176
|
-
**Parameters:**
|
|
177
|
-
- `<phone>`: Connected WhatsApp account phone number in E.164 digits-only format (no `+` prefix). Example: `15551234567`. This identifies **which WhatsApp account** the events belong to — not the remote contact.
|
|
178
|
-
- `<jid>`: Remote chat's WhatsApp JID. Individual: `PHONE@s.whatsapp.net`. Group: `ID@g.us`.
|
|
179
|
-
- Supported kinds: `message`, `status`
|
|
180
|
-
|
|
181
|
-
> **⚠️ JID dot expansion:** JID dots (`.`) become additional NATS subject tokens. For example, `5551234567@s.whatsapp.net` splits into tokens `5551234567@s`, `whatsapp`, `net`. This means individual chat topics produce 7 tokens and group chat topics produce 6 tokens. **Always use `>` (multi-level wildcard), never `*` (single-token wildcard)**, when subscribing to a chat or phone number.
|
|
182
|
-
|
|
183
|
-
Examples:
|
|
184
|
-
|
|
185
|
-
- `notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.message` (individual chat messages — note this expands to 7 NATS tokens)
|
|
186
|
-
- `notifications.whatsapp.15551234567.120363XXX@g.us.message` (group chat messages — 6 NATS tokens)
|
|
187
|
-
|
|
188
|
-
## When to use what
|
|
189
|
-
|
|
190
|
-
### To receive future Slack/GitHub/WhatsApp events
|
|
191
|
-
|
|
192
|
-
1. Decide the exact topic(s)
|
|
193
|
-
2. Call `envoy_subscribe([...])`
|
|
194
|
-
3. Optionally call `envoy_list()` to confirm
|
|
195
|
-
|
|
196
|
-
### To talk directly to another agent/session
|
|
197
|
-
|
|
198
|
-
1. Get the target session ID
|
|
199
|
-
2. Call `envoy_send(session_id, message)`
|
|
200
|
-
|
|
201
|
-
You do NOT need to subscribe in order to send or publish.
|
|
202
|
-
|
|
203
|
-
**Session ids are not stable for the life of a conversation.** On OMP, `/fork` and `/handoff`
|
|
204
|
-
re-mint the session id while the conversation continues (esc-esc rewinds also did, on omp
|
|
205
|
-
18.1.0–18.1.2 only); the extension rebinds automatically and injects an `envoy` notice naming
|
|
206
|
-
the previous and new ids. When that notice arrives, any id you shared earlier (an
|
|
207
|
-
`envoy_whoami` result quoted in a message, an id a peer saved) is stale — re-run
|
|
208
|
-
`envoy_whoami` and re-announce yourself. Never treat a whoami result from earlier in the
|
|
209
|
-
transcript as current when identifying yourself to peers.
|
|
210
|
-
|
|
211
|
-
### To wait for CI, PR checks, or other async work
|
|
212
|
-
|
|
213
|
-
**Don't `sleep`-poll. Don't "check back in N minutes."** Subscribe to the event and continue with productive work — the system will wake the session when the event arrives.
|
|
21
|
+
NATS `>` matches **one or more** trailing tokens, so it does not match the lifecycle base
|
|
22
|
+
`pr.42` itself. Envoy registers that concrete base automatically when you subscribe to
|
|
23
|
+
`<subject>.>`, making `pr.<n>.>` the recommended default: one call receives both the lifecycle
|
|
24
|
+
subject and its child events.
|
|
214
25
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
26
|
+
For a typical push, this receives `pr.42` with `synchronize`, then any comments or reviews, then
|
|
27
|
+
one `pr.42.checks` event when that head's checks settle. The family is `pr.42` (lifecycle),
|
|
28
|
+
`pr.42.comment`, `pr.42.review`, `pr.42.mention`, and `pr.42.checks`. A closed lifecycle payload
|
|
29
|
+
carries `merged`, `merge_commit_sha`, `merged_by`, and `head_sha`.
|
|
219
30
|
|
|
220
|
-
|
|
31
|
+
The retired literal `pr.<n>.check` and `pr.<n>.ci` topics do not receive events. Existing
|
|
32
|
+
registrations remain dead; subscribe to `pr.<n>.checks` (or the recommended `pr.<n>.>`) instead.
|
|
33
|
+
Lifecycle stays on the base PR topic and CI arrives as one settled `checks` event.
|
|
221
34
|
|
|
222
|
-
|
|
35
|
+
## Inbound deliveries
|
|
223
36
|
|
|
224
|
-
|
|
225
|
-
- `envoy_unsubscribe(topics?)` — stop receiving some or all topics
|
|
226
|
-
- `envoy_list()` — show live and persisted subscriptions, marked by source
|
|
227
|
-
- `envoy_send(session_id, message)` — send directly to a specific session (point-to-point)
|
|
228
|
-
- `envoy_publish(topic, message)` — publish a normal topic to matching subscribers or route a role topic to its current holder
|
|
229
|
-
- `envoy_role_set(role)` — claim a named role for the current session (exactly-one-holder)
|
|
230
|
-
|
|
231
|
-
## Patterns
|
|
232
|
-
|
|
233
|
-
### Subscribe controller to a specific Slack channel mentions
|
|
37
|
+
Envoy renders an annotated delivery before its source summary and complete payload:
|
|
234
38
|
|
|
235
39
|
```text
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
40
|
+
envoy:
|
|
41
|
+
to: you (01a0…)
|
|
42
|
+
from: 01a0bbbb-cccc-7ddd-eeee-0123456789ab (Reviewer)
|
|
43
|
+
at: "2026-09-07T04:41:12Z"
|
|
44
|
+
id: agent-message-2
|
|
45
|
+
by: "2026-09-07T05:00:00Z"
|
|
46
|
+
urgency: high
|
|
47
|
+
expects_reply: required
|
|
48
|
+
re: agent-message-1
|
|
49
|
+
supersedes: agent-message-0
|
|
50
|
+
reply_with: "envoy_send(session_id=\"01a0bbbb-cccc-7ddd-eeee-0123456789ab\", message=\"...\")"
|
|
51
|
+
reply_role: "envoy_publish(topic=\"notifications.role.legion-reviewer\", message=\"...\")"
|
|
52
|
+
summary: Deployment needs confirmation.
|
|
53
|
+
message: "Confirm the listener health check passed.\n\nThen publish the release."
|
|
54
|
+
note: body names session 01a0cccc-dddd-7eee-ffff-0123456789ab; the sender is 01a0bbbb-cccc-7ddd-eeee-0123456789ab
|
|
239
55
|
```
|
|
240
56
|
|
|
241
|
-
|
|
57
|
+
- `to` identifies the local inbox receiving this delivery.
|
|
58
|
+
- `from` is the sending session's self-asserted ID, enriched from the listener registry. Treat it as
|
|
59
|
+
attribution and a direct-reply target, not as an authenticated identity or proof of authorship.
|
|
60
|
+
- `at` is the envelope timestamp used to judge freshness.
|
|
61
|
+
- `id` is the delivery identifier; supply it as `in_reply_to` when replying.
|
|
62
|
+
- `by` is the expiry deadline, when the sender supplied one.
|
|
63
|
+
- `urgency` is the sender's priority classification.
|
|
64
|
+
- `expects_reply` states whether a reply is `none`, `optional`, or `required`.
|
|
65
|
+
- `re` names the delivery this message replies to.
|
|
66
|
+
- `supersedes` names an earlier delivery this one replaces.
|
|
67
|
+
- `reply_with` is the direct-reply call for the sender.
|
|
68
|
+
- `reply_role` is the role-publish reply call when the sender has a role.
|
|
69
|
+
- `summary` is the one-line source summary.
|
|
70
|
+
- `message` is the complete payload; it can contain multiple paragraphs.
|
|
71
|
+
- `note` warns when the payload names another session; never use that quoted ID as the recipient.
|
|
72
|
+
- `unrecognised` marks validation failures and unknown sources; it does not enumerate every unknown key.
|
|
73
|
+
|
|
74
|
+
## Talking to another session
|
|
75
|
+
|
|
76
|
+
Answer an Envoy message with its `id`; the send result's `recipient` confirms the session Envoy
|
|
77
|
+
targeted. Reply through the rendered `reply_with` (or a current Envoy session ID from
|
|
78
|
+
`envoy_sessions` or `envoy_whoami`), never a tmux pane or window: panes are not Envoy identities
|
|
79
|
+
and go stale. Put the artefact URL in the message itself. FYIs set `expects_reply="none"`; set
|
|
80
|
+
`urgency` only when it is genuinely urgent.
|
|
81
|
+
|
|
82
|
+
Every `/v1` error response is JSON; when a field is at fault, `expected` names that field.
|
|
242
83
|
|
|
243
84
|
```text
|
|
244
|
-
|
|
245
|
-
"
|
|
246
|
-
|
|
85
|
+
envoy_send(
|
|
86
|
+
session_id="ses_example_reviewer",
|
|
87
|
+
message="Review complete: artifact://review.md",
|
|
88
|
+
in_reply_to="agent-message-2",
|
|
89
|
+
expects_reply="none"
|
|
90
|
+
)
|
|
247
91
|
```
|
|
248
92
|
|
|
249
|
-
|
|
93
|
+
## Waiting for CI or a merge
|
|
250
94
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
95
|
+
Subscribe to `notifications.github.example-org.example-repo.pr.42.>` and end the turn. The single
|
|
96
|
+
`pr.42.checks` event wakes you when the current head settles; a `pr.42` `closed` event with
|
|
97
|
+
`merged: true` tells you the PR merged. Do not create `gh` pollers.
|
|
98
|
+
Settlement waits for the head to be quiet for a few seconds, every reported check run to finish,
|
|
99
|
+
and every recorded GitHub check suite to be `completed`. It covers those reported checks and suites
|
|
100
|
+
for the head, not GitHub's required-checks set; until then, a silent subscription is normal.
|
|
256
101
|
|
|
257
|
-
|
|
102
|
+
Check settlement is at-least-once: a settlement can be followed by a `superseded_settlement: "true"` payload. Every settlement carries its attempt set `check_runs` — the latest GitHub check-run id per check name, sorted by name — plus the listener's `generation` (the record's state version) and `snapshot` (the record's hash). Consumers order same-head settlements by the attempt set, compared per shared name: no id lower and some id higher (or a new name) is newer; every shared id equal and no new name is the same set; no id higher and some lower is older; anything else is a mixed view and is dropped as a conflict (names only in the stored set are ignored — a check can vanish from GitHub's view, and a record recreated after the seven-day KV TTL starts sparse). Within one producer record per-name ids never decrease, and a consumer's fence is the per-name maximum over every view it has accepted — an accepted set merges into the fence, nothing is pruned — so the fence never decreases either: a newer attempt is newer whatever its completion time, no timestamps take part in ordering, and a name an incomplete view omitted cannot later reappear as new. At the same set the listener's `generation` orders its own settlements: lower is stale; equal is a duplicate when the `snapshot` matches and otherwise a conflict (an equal pair with a different snapshot cannot occur within one record's lifetime; a recreated record may reuse one and is dropped). A live settlement is a possibly incomplete view of the head (a missed webhook, a record recreated after the KV TTL): it decides the outcome of every name it reports — at any id the ordering accepted, including the same run observed in place — and says nothing about the rest, whose last known outcome stands; the head is red while any failure remains. A consumer that reconciles a verdict from GitHub's rollup compares the rollup's attempt set the same way, but GitHub's read is complete: its failing check runs and failing commit statuses replace the stored ones wholesale. Statuses have no check run and the listener never sees them, so a consumer keeps them apart from check-run failures: a check run that shares a status's name cannot retire it — only GitHub does (likewise a deleted check's failure). A newer rollup set merges into the fence and takes the identity (no listener generation); the same set applies GitHub's verdict and keeps the listener identity for duplicate detection; an older, mixed, or empty-over-fenced set is ignored. A terminal read (green or red) then holds the tie at that set: a live settlement at the same set is accepted only if its effective outcome — the check-run failures it reports plus the stored ones it omits and the stored commit-status failures — agrees with the reconciled verdict, refreshing the listener identity without releasing GitHub's authority; a disagreeing one is stale whatever its generation until the set advances; a pending or cancelled-only read uncertifies a green head, leaves a red one untouched, and holds nothing — it releases any authority held at that set — so the terminal live settlement that follows applies at once, subject to the ordinary generation and duplicate rules (a replay or a lower generation still does not apply). Pending is therefore not a commutative join: a pending read after a live green uncertifies it until the next terminal view. Two remainders. An in-place conclusion change on an existing run id: GitHub's view stands and the listener's is recovered by the next successful, non-skipped read at that set — the dropped delivery is not replayed. A check whose highest run is deleted on GitHub: the fence keeps that id, so a rollup reporting a lower run under the same name is older until a newer run appears. A head publishes only when at least one check has a positive run id; legacy checks without one remain in the status groups and failing names but not in `check_runs`. A legacy in-progress check whose completion is never observed holds the head unsettled until it reruns; rerun the affected check to release it.
|
|
258
103
|
|
|
259
|
-
|
|
260
|
-
envoy_subscribe([
|
|
261
|
-
"notifications.slack.T01234567.C0A0DHVU8HE.thread.>"
|
|
262
|
-
])
|
|
263
|
-
```
|
|
104
|
+
## When a subscription is silent
|
|
264
105
|
|
|
265
|
-
|
|
106
|
+
Check the `warnings` returned by `envoy_subscribe`, then inspect the active topics with
|
|
107
|
+
`envoy_list()`:
|
|
266
108
|
|
|
267
109
|
```text
|
|
268
110
|
envoy_subscribe([
|
|
269
|
-
"notifications.github.example-org.example-repo.pr.>"
|
|
111
|
+
"notifications.github.example-org.example-repo.pr.42.>"
|
|
270
112
|
])
|
|
113
|
+
// warnings: ["no GitHub event for example-org/example-repo in the stream's retention window; is the App installed there?"]
|
|
271
114
|
```
|
|
272
115
|
|
|
273
|
-
|
|
116
|
+
A warning says no GitHub event for that repository occurred within the stream's 72-hour retention
|
|
117
|
+
window; it does not mean the repository was never seen. Verify the GitHub App is installed before
|
|
118
|
+
relying on a wakeup.
|
|
274
119
|
|
|
275
|
-
|
|
276
|
-
envoy_subscribe([
|
|
277
|
-
"notifications.github.example-org.example-repo.mention"
|
|
278
|
-
])
|
|
279
|
-
```
|
|
120
|
+
## Roles
|
|
280
121
|
|
|
281
|
-
|
|
122
|
+
Publish to a role; do not subscribe as its holder. A successful `envoy_publish` to a role returns
|
|
123
|
+
its live `holder`; an unheld role returns an error. Use `envoy_role_get(role="reviewer")` to find
|
|
124
|
+
the live holder first when you need one.
|
|
282
125
|
|
|
283
|
-
|
|
284
|
-
envoy_send(
|
|
285
|
-
session_id="ses_2e6ca3034ffejVikSZ8mDwk0mR",
|
|
286
|
-
message="Please continue the smoke test"
|
|
287
|
-
)
|
|
288
|
-
```
|
|
126
|
+
## Legion role claims
|
|
289
127
|
|
|
290
|
-
|
|
128
|
+
Legion agents receive through a daemon-minted role token. Claim the assigned role with
|
|
129
|
+
`envoy_role_set(role="<assigned-role>")`; a claimant does not manually subscribe to its role
|
|
130
|
+
topic. Claims use last-claim-wins semantics, survive parking and worker re-creation, and remain
|
|
131
|
+
through the issue's post-close linger. The daemon owns the authoritative token-to-issue map, so do
|
|
132
|
+
not construct a token from a partial issue reference.
|
|
291
133
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
134
|
+
## Legion exception lane
|
|
135
|
+
|
|
136
|
+
`no_holder` and `delivery_failed` for a Legion role are daemon liveness signals, not reasons to
|
|
137
|
+
add a second subscriber or manually retry. For example, treat a `delivery_failed` wake as the
|
|
138
|
+
daemon's responsibility to revive or recreate the backing worker and re-deliver; otherwise it
|
|
139
|
+
resurrects the root process with derived catch-up and the workspace handoffs. Raw delivery failures
|
|
140
|
+
stay out of architect context.
|
|
297
141
|
|
|
298
|
-
|
|
142
|
+
## Slack
|
|
299
143
|
|
|
300
|
-
|
|
144
|
+
Use the real team ID, not a workspace slug. Slack delivers a one-line prose `summary` plus a
|
|
145
|
+
structured `message` payload. The payload records `subtype` for edits, deletes, and bot messages;
|
|
146
|
+
`thread_ts` for replies; and `bot_id` and `bot_name` when a bot supplied the message.
|
|
301
147
|
|
|
302
148
|
```text
|
|
303
149
|
envoy_subscribe([
|
|
304
|
-
"notifications.
|
|
150
|
+
"notifications.slack.T01234567.C01234567.thread.1_000.>"
|
|
305
151
|
])
|
|
306
152
|
```
|
|
307
153
|
|
|
308
|
-
|
|
154
|
+
This follows every message and mention in one thread. Channel-level topics end in `.message` or
|
|
155
|
+
`.mention`; thread timestamps replace dots with underscores.
|
|
156
|
+
|
|
157
|
+
## Ghost Wispr
|
|
158
|
+
|
|
159
|
+
Ghost Wispr topics are `notifications.ghostwispr.<session>.<kind>`, where `kind` is
|
|
160
|
+
`session.started`, `session.ended`, or `summary.ready`. Their summary is concise prose and their
|
|
161
|
+
payload is structured; `summary_ready` includes its status, summary, and summary metadata.
|
|
309
162
|
|
|
310
163
|
```text
|
|
311
164
|
envoy_subscribe([
|
|
312
|
-
"notifications.
|
|
165
|
+
"notifications.ghostwispr.session-example.summary.ready"
|
|
313
166
|
])
|
|
314
167
|
```
|
|
315
168
|
|
|
316
|
-
|
|
169
|
+
## WhatsApp
|
|
317
170
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
- **Group chat** — when monitoring a specific group for commands or events
|
|
321
|
-
- **All chats for a phone** — when building a general WhatsApp event handler or dashboard for an account
|
|
322
|
-
|
|
323
|
-
## Important notes
|
|
324
|
-
|
|
325
|
-
- Sessions choose their own Slack/GitHub subscriptions
|
|
326
|
-
- Different sessions can subscribe to different channels/repos
|
|
327
|
-
- Agent-to-agent delivery uses exact session IDs
|
|
328
|
-
- `envoy_list()` distinguishes `live`, `registry`, and `both`; a `live` topic is receiving now even when the listener registry has not caught up.
|
|
329
|
-
- For Slack, use the real `team_id` in topics (for example `T01234567`), not a workspace slug like `acme`
|
|
330
|
-
- GitHub mention routing is body-based because GitHub has no dedicated app mention webhook event
|
|
331
|
-
|
|
332
|
-
## Synthetic Smoke Test (WhatsApp — NATS Routing Only)
|
|
333
|
-
|
|
334
|
-
> **Important:** This procedure validates Envoy's NATS → listener → session delivery path using `envoy_publish`. It does **not** test real WhatsApp message ingestion. The generic MCP bridge (`packages/envoy/cmd/mcp/`) can bridge real WhatsApp events, but requires production configuration. See "Current Limitations" below.
|
|
335
|
-
>
|
|
336
|
-
> **Two sessions required:** `envoy_publish` sets `source_session` to the publishing session's ID. The listener skips delivering broadcasts back to the sender (`packages/envoy/cmd/listener/main.go`). You must subscribe in one session and publish from a different session.
|
|
337
|
-
|
|
338
|
-
### Step 1: Subscribe to a WhatsApp topic (Session A)
|
|
171
|
+
WhatsApp topics are `notifications.whatsapp.<phone>.<jid>.message` or `.status`. A JID contains
|
|
172
|
+
dots, which become additional NATS segments, so use `>` rather than `*` for a chat.
|
|
339
173
|
|
|
340
174
|
```text
|
|
341
175
|
envoy_subscribe([
|
|
@@ -343,62 +177,17 @@ envoy_subscribe([
|
|
|
343
177
|
])
|
|
344
178
|
```
|
|
345
179
|
|
|
346
|
-
###
|
|
347
|
-
|
|
348
|
-
```text
|
|
349
|
-
envoy_list()
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
Confirm `notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.>` appears as `live` or `both`.
|
|
180
|
+
### WhatsApp routing smoke test
|
|
353
181
|
|
|
354
|
-
|
|
182
|
+
This checks Envoy routing, not real WhatsApp ingestion. In Session A, subscribe as above. From a
|
|
183
|
+
different Session B, publish a synthetic message to the same `.message` topic:
|
|
355
184
|
|
|
356
185
|
```text
|
|
357
186
|
envoy_publish(
|
|
358
187
|
topic="notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.message",
|
|
359
|
-
message="Synthetic WhatsApp
|
|
188
|
+
message="Synthetic WhatsApp routing test"
|
|
360
189
|
)
|
|
361
190
|
```
|
|
362
191
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
Session A should receive a notification containing the text "Synthetic WhatsApp smoke test: hello from envoy_publish". This confirms:
|
|
366
|
-
- The topic pattern matches the subscription
|
|
367
|
-
- NATS routes the message to the listener
|
|
368
|
-
- The listener delivers to the subscribed session (Session A ≠ the publishing session)
|
|
369
|
-
|
|
370
|
-
**If the notification does not arrive:** Check `envoy_list()` in Session A. The topic should be `live` or `both`; `registry` alone does not confirm the local subscription is receiving. Verify the topic in `envoy_publish` matches the subscription pattern. Ensure you are publishing from a **different** session than the one subscribed.
|
|
371
|
-
|
|
372
|
-
### Reference: Real WhatsApp Envelope Shape
|
|
373
|
-
|
|
374
|
-
When the MCP bridge (`packages/envoy/internal/mcpbridge/envelope.go`) publishes a real WhatsApp event, the Envoy envelope has this structure:
|
|
375
|
-
|
|
376
|
-
```json
|
|
377
|
-
{
|
|
378
|
-
"event_id": "<generated unique ID>",
|
|
379
|
-
"source": "whatsapp",
|
|
380
|
-
"source_event_id": "whatsapp://messages/15551234567/5551234567@s.whatsapp.net",
|
|
381
|
-
"topic": "notifications.whatsapp.15551234567.5551234567@s.whatsapp.net.message",
|
|
382
|
-
"dedupe_key": "whatsapp.<event_id value>",
|
|
383
|
-
"issued_at": 1712345678000,
|
|
384
|
-
"payload_summary": "Hello from WhatsApp",
|
|
385
|
-
"payload_ref": "whatsapp://messages/15551234567/5551234567@s.whatsapp.net",
|
|
386
|
-
"trace_id": "<generated unique ID>"
|
|
387
|
-
}
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
**Field notes:**
|
|
391
|
-
- `source` is `"whatsapp"` — in contrast, `envoy_publish` sets `source: "agent"` for synthetic messages
|
|
392
|
-
- `issued_at` is in **milliseconds** (Unix epoch ms), not seconds
|
|
393
|
-
- `dedupe_key` is `source + "." + event_id` (e.g., `"whatsapp.cuid_abc123"`)
|
|
394
|
-
- `payload_summary` is the actual message text from the MCP resource read (truncated to 200 chars), or fallback `"whatsapp event from <uri>"` if no text content
|
|
395
|
-
- `payload_ref` and `source_event_id` are both the MCP resource notification URI
|
|
396
|
-
- `source_session` is **omitted** (empty) — the MCP bridge is not an OpenCode session, so no echo-skip occurs
|
|
397
|
-
- `expires_at` is **omitted** — the bridge does not set message expiry
|
|
398
|
-
|
|
399
|
-
## Current Limitations (WhatsApp)
|
|
400
|
-
|
|
401
|
-
- **No production WhatsApp event ingestion configured.** The repo contains a generic MCP→NATS bridge (`packages/envoy/cmd/mcp/` + `packages/envoy/internal/mcpbridge/`) that already supports WhatsApp topic patterns (tested in `packages/envoy/internal/integration/delivery_test.go`). However, it is not yet configured/deployed to connect to the `@sjawhar/whatsapp-mcp` server in production.
|
|
402
|
-
- **Synthetic testing only.** The smoke test above uses `envoy_publish` to inject test messages into NATS. It validates Envoy delivery mechanics (NATS → listener → session), not true WhatsApp end-to-end delivery.
|
|
403
|
-
- **Production wiring needed.** To receive real WhatsApp events, the MCP bridge needs to be configured with the `@sjawhar/whatsapp-mcp` server connection details (similar to how `packages/envoy/cmd/github/` and `packages/envoy/cmd/slack/` are configured for their respective platforms). The bridge would then subscribe to WhatsApp MCP resource notifications and publish Envoy envelopes to NATS automatically.
|
|
404
|
-
- **Subscription + routing + delivery path is ready.** The contracts layer (`whatsappSubject` helper), NATS topic format, generic listener routing, and MCP bridge infrastructure all work. Only the production configuration connecting the bridge to the WhatsApp MCP server is missing.
|
|
192
|
+
Session A should receive it. Broadcasts do not echo to their publishing session, so one session
|
|
193
|
+
cannot perform both steps. Real WhatsApp delivery additionally requires a configured MCP bridge.
|