@sjawhar/pi-legion-envoy 5.28.0 → 5.30.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
@@ -71,9 +71,10 @@ at `dist/skills`. The manifest declares `omp.skills: ["dist/skills"]` alongside
71
71
  `<plugin root>/skills` or from directories a manifest's `omp.skills` array names, and this
72
72
  package ships skills at `dist/skills` (not the plugin root itself), so the field is required
73
73
  for every session — including a headless Legion controller — to see the Legion skills at all.
74
- The tarball also ships `agents/`, the task agents Legion's prompts dispatch (`oracle`, the
75
- reviewer's pair `thermonuclear-deep-review` and `thermonuclear-code-quality`, and `deep-worker`,
76
- which writes the implementer's code), which Oh My Pi discovers under any extension package root:
74
+ The tarball also ships `agents/`, the task agents Legion's prompts dispatch (`oracle`; the
75
+ reviewer's pair `thermonuclear-deep-review` and `thermonuclear-code-quality`; `deep-worker`,
76
+ which writes the implementer's code; and the planner's checks, `plan-gap-analyst` before it drafts
77
+ and `plan-reviewer` after), which Oh My Pi discovers under any extension package root:
77
78
  an installed plugin in a pane, the explicit `--extension` root in a Sandbox pod. Each declares its
78
79
  model as a role the operator maps (`docs/kubernetes.md`, "Model roles"). The skills those agents and the
79
80
  role prompts load (the pair's rubrics, the implementer's `ce-simplify-code`) ship in
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: plan-gap-analyst
3
+ description: |
4
+ Pre-planning gap analyst. Read-only. Use before drafting a plan: finds the hidden requirements,
5
+ ambiguities, and missing machine-checkable acceptance criteria an issue leaves unsaid, each with
6
+ what the plan must answer.
7
+ # @oracle is the deployment's `oracle` model role; the Go daemon's boot gate refuses to start unless the operator's settings give
8
+ # this agent a model, through modelRoles.oracle or a task.agentModelOverrides entry for it (docs/kubernetes.md, Operator configuration).
9
+ model: ["@oracle"]
10
+ tools: read, glob, grep, todo
11
+ # The planner waits for the analysis before it drafts; without this, Oh My Pi runs a task agent in
12
+ # the background whenever async jobs are enabled.
13
+ blocking: true
14
+ color: yellow
15
+ ---
16
+
17
+ You read an issue before its plan exists and find what the issue leaves unsaid that would derail
18
+ the plan. You advise the planner; the planner owns the plan.
19
+
20
+ ## What you look for
21
+
22
+ 1. **Hidden requirements.** What the change needs but the issue does not state: a caller, contract,
23
+ migration, configuration, document, or consumer the change reaches; a behaviour the existing
24
+ code promises that the change must keep.
25
+ 2. **Ambiguities.** A sentence with two readings that lead to different work. Name both readings
26
+ and what each would cost.
27
+ 3. **Acceptance a machine cannot check.** A criterion with no command, test, or request whose
28
+ expected output decides it, or a requirement with no criterion at all.
29
+
30
+ Read the code and the repository's own documentation before you call something a gap. A gap the
31
+ issue, the code, or the documentation already answers is not a finding.
32
+
33
+ ## What a finding contains
34
+
35
+ - **The gap**, quoting the issue's words where it has them.
36
+ - **The evidence**: the file and line, or the issue text, that shows it.
37
+ - **What the plan must answer**: the question or decision the plan has to settle and, for an
38
+ acceptance gap, a check a machine could run. Say whether the code settles it or only the
39
+ issue's owner can (a product choice).
40
+
41
+ Report the findings that change the plan, the most work-changing first. Style, edge cases the plan
42
+ can settle in passing, and the design you would have chosen are not findings. When nothing changes
43
+ the plan, say so in one line.
44
+
45
+ ## Constraints
46
+
47
+ - **Read-only: you analyse; you do not implement.** Your file tools are read-only; do not ask for
48
+ others or route edits through other means. Your session also carries the Dispatch and Envoy
49
+ tools: read with them if you need to, but write nothing through them — no issue, comment, ask,
50
+ message, suggestion or document edit, and nothing sent or published.
51
+ - Cite what you read. Anything you did not read is an assumption and is written as one.
52
+ - Do not write the plan.
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: plan-reviewer
3
+ description: |
4
+ Plan executability reviewer. Read-only. Use after drafting a plan: checks that it can be carried
5
+ out as written, approves when in doubt, and names at most three blocking issues, each with its
6
+ evidence.
7
+ # @review is the deployment's `review` model role; the Go daemon's boot gate refuses to start unless the operator's settings give
8
+ # this agent a model, through modelRoles.review or a task.agentModelOverrides entry for it (docs/kubernetes.md, Operator configuration).
9
+ model: ["@review"]
10
+ tools: read, glob, grep, todo
11
+ # The planner waits for each verdict before it revises or proceeds; without this, Oh My Pi runs a
12
+ # task agent in the background whenever async jobs are enabled.
13
+ blocking: true
14
+ color: magenta
15
+ ---
16
+
17
+ You answer one question: can a capable engineer who has only this plan and the repository carry
18
+ it out without getting stuck? You find blockers, not improvements. You advise the planner; the
19
+ planner owns the plan.
20
+
21
+ ## What you check
22
+
23
+ 1. **References.** The files, functions, commands, and skills the plan names exist and hold what
24
+ the plan says. Read them; do not assume.
25
+ 2. **A place to start.** Every task says where the work is and what changes, in an order that can
26
+ be followed.
27
+ 3. **Contradictions.** No two steps contradict each other, and no step contradicts the issue's
28
+ acceptance criteria.
29
+ 4. **Runnable checks.** Every acceptance criterion names the surface and the exact command that
30
+ decides it.
31
+
32
+ You do not judge whether the approach is the best one, style, naming, edge cases the engineer can
33
+ settle during the work, or anything you would simply do differently.
34
+
35
+ ## Verdict
36
+
37
+ Approve when in doubt. A plan that is mostly clear is good enough. Reject only for a blocker: a
38
+ reference that does not exist or does not hold what the plan says (read to confirm), a task with
39
+ nowhere to start, a contradiction, or a criterion nothing can check.
40
+
41
+ A rejection names at most three blocking issues, the most severe first. Each gives:
42
+
43
+ - **The issue**: the exact task, step, or criterion.
44
+ - **The evidence**: the file and line you read, or the plan's own words.
45
+ - **What must change** for it to pass.
46
+
47
+ A revised plan is read again in full and judged fresh: an issue the revision resolved is not
48
+ raised again, and a second round is no place for requests that would not have blocked the first.
49
+
50
+ ## Output
51
+
52
+ The first line is the verdict alone: `approved` or `rejected`. Then one or two sentences on why.
53
+ On `rejected`, the numbered blocking issues. Nothing else.
54
+
55
+ ## Constraints
56
+
57
+ - **Read-only: you review; you do not implement.** Your file tools are read-only; do not ask for
58
+ others or route edits through other means. Your session also carries the Dispatch and Envoy
59
+ tools: read with them if you need to, but write nothing through them — no issue, comment, ask,
60
+ message, suggestion or document edit, and nothing sent or published.
61
+ - Do not rewrite the plan.
package/dist/envoy.js CHANGED
@@ -29796,6 +29796,7 @@ var DispatchTargetedMessagePayloadSchema = MessageEventPayloadSchema.extend({
29796
29796
  body: exports_external.string(),
29797
29797
  target: exports_external.string(),
29798
29798
  in_reply_to: exports_external.string().nullable(),
29799
+ broadcast_id: exports_external.string().nullish(),
29799
29800
  deliveries: exports_external.array(exports_external.unknown()),
29800
29801
  created_at: exports_external.string()
29801
29802
  });
@@ -30477,6 +30478,8 @@ var EnvelopeSchema = exports_external.object({
30477
30478
  // ../contracts/src/handoff-schema.ts
30478
30479
  var HANDOFF_SCHEMA_VERSION = 1;
30479
30480
  var HANDOFF_PHASES = ["architect", "plan", "implement", "test", "review"];
30481
+ var PLAN_REVIEW_MAX_ROUNDS = 3;
30482
+ var PLAN_REVIEW_VERDICTS = ["approved", "rejected", "failed"];
30480
30483
  var isoTimestamp = exports_external.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
30481
30484
  var handoffPhase = exports_external.enum(HANDOFF_PHASES);
30482
30485
  var nonEmpty = exports_external.string().trim().min(1);
@@ -30513,6 +30516,16 @@ var requiredSkillsSchema = exports_external.object({
30513
30516
  test: exports_external.array(exports_external.string()).optional(),
30514
30517
  review: exports_external.array(exports_external.string()).optional()
30515
30518
  }).passthrough().optional();
30519
+ var gapAnalysisSchema = exports_external.object({
30520
+ findings: exports_external.array(exports_external.object({ finding: exports_external.string(), answer: exports_external.string() }).passthrough()).optional(),
30521
+ error: exports_external.string().optional()
30522
+ }).passthrough().optional();
30523
+ var planReviewSchema = exports_external.object({
30524
+ verdict: exports_external.enum(PLAN_REVIEW_VERDICTS),
30525
+ rounds: exports_external.number(),
30526
+ remainingIssues: exports_external.array(exports_external.object({ issue: exports_external.string(), evidence: exports_external.string() }).passthrough()).optional(),
30527
+ error: exports_external.string().optional()
30528
+ }).passthrough().optional();
30516
30529
  var planSchema = baseHandoffSchema.extend({
30517
30530
  phase: exports_external.literal("plan"),
30518
30531
  taskCount: exports_external.number().optional(),
@@ -30520,7 +30533,9 @@ var planSchema = baseHandoffSchema.extend({
30520
30533
  routingHints: routingHintsSchema,
30521
30534
  concerns: exports_external.array(exports_external.string()).optional(),
30522
30535
  workflowRecommendation: exports_external.string().optional(),
30523
- requiredSkills: requiredSkillsSchema
30536
+ requiredSkills: requiredSkillsSchema,
30537
+ gapAnalysis: gapAnalysisSchema,
30538
+ planReview: planReviewSchema
30524
30539
  });
30525
30540
  var implementSchema = baseHandoffSchema.extend({
30526
30541
  phase: exports_external.literal("implement"),
@@ -30560,13 +30575,60 @@ var reviewSchema = baseHandoffSchema.extend({
30560
30575
  verdict: exports_external.enum(["approved", "changes_requested"]).optional(),
30561
30576
  keyFindings: exports_external.array(exports_external.object({ severity: exports_external.string(), file: exports_external.string(), description: exports_external.string() }).passthrough()).optional()
30562
30577
  });
30563
- var nonEmptySkillList = exports_external.array(exports_external.string().trim().min(1)).min(1);
30578
+ var nonEmptySkillList = exports_external.array(nonEmpty).min(1);
30579
+ var recorded = (shape, whatToRecord) => exports_external.object(shape, {
30580
+ error: (issue2) => issue2.input === undefined ? `missing \u2014 record ${whatToRecord}` : undefined
30581
+ }).passthrough();
30582
+ var gapAnalysisWriteSchema = recorded({
30583
+ findings: exports_external.array(exports_external.object({ finding: nonEmpty, answer: nonEmpty }).passthrough()).optional(),
30584
+ error: nonEmpty.optional()
30585
+ }, "the gap analyst's `findings`, each with how the plan answers it (`[]` when it found none), or its failed call's `error`").refine((analysis) => analysis.findings === undefined !== (analysis.error === undefined), {
30586
+ message: "record exactly one of `findings` or the failed call's `error`"
30587
+ });
30588
+ var planReviewWriteSchema = recorded({
30589
+ verdict: exports_external.enum(PLAN_REVIEW_VERDICTS),
30590
+ rounds: exports_external.number().int().min(1).max(PLAN_REVIEW_MAX_ROUNDS),
30591
+ remainingIssues: exports_external.array(exports_external.object({ issue: nonEmpty, evidence: nonEmpty }).passthrough()).optional(),
30592
+ error: nonEmpty.optional()
30593
+ }, "the plan review's `verdict` and `rounds`, with `remainingIssues` when it was rejected or `error` when a review's call failed").superRefine((review, ctx) => {
30594
+ const remaining = review.remainingIssues?.length ?? 0;
30595
+ if (review.verdict === "rejected" && remaining === 0) {
30596
+ ctx.addIssue({
30597
+ code: "custom",
30598
+ path: ["remainingIssues"],
30599
+ message: "a rejected review records the blocking issues its last round named"
30600
+ });
30601
+ }
30602
+ if (review.verdict === "rejected" && review.rounds < PLAN_REVIEW_MAX_ROUNDS) {
30603
+ ctx.addIssue({
30604
+ code: "custom",
30605
+ path: ["rounds"],
30606
+ message: `a review still rejecting after ${review.rounds} of ${PLAN_REVIEW_MAX_ROUNDS} rounds is revised and reviewed again, not recorded`
30607
+ });
30608
+ }
30609
+ if (review.verdict === "approved" && remaining > 0) {
30610
+ ctx.addIssue({
30611
+ code: "custom",
30612
+ path: ["remainingIssues"],
30613
+ message: "an approved review leaves no blocking issue standing"
30614
+ });
30615
+ }
30616
+ if (review.verdict === "failed" !== (review.error !== undefined)) {
30617
+ ctx.addIssue({
30618
+ code: "custom",
30619
+ path: ["error"],
30620
+ message: "a failed review records its call's error, and only a failed review does"
30621
+ });
30622
+ }
30623
+ });
30564
30624
  var planWriteSchema = planSchema.extend({
30565
30625
  requiredSkills: exports_external.object({
30566
30626
  implement: nonEmptySkillList,
30567
30627
  test: nonEmptySkillList,
30568
30628
  review: nonEmptySkillList
30569
- }).passthrough()
30629
+ }).passthrough(),
30630
+ gapAnalysis: gapAnalysisWriteSchema,
30631
+ planReview: planReviewWriteSchema
30570
30632
  });
30571
30633
  var phaseHandoffSchema = exports_external.discriminatedUnion("phase", [
30572
30634
  architectSchema,
@@ -31969,7 +32031,8 @@ function renderInbound(raw, sessionID, subject2) {
31969
32031
  replyPath: dispatchReplyPath("message", message2.data.id),
31970
32032
  replyFields: {},
31971
32033
  issueKey: frame.event.issue_key,
31972
- body: message2.data.body
32034
+ body: message2.data.body,
32035
+ ...typeof message2.data.broadcast_id === "string" ? { broadcastId: message2.data.broadcast_id } : {}
31973
32036
  };
31974
32037
  dispatchReply = {
31975
32038
  tool: "dispatch_message",
@@ -32517,6 +32580,9 @@ class DispatchClient {
32517
32580
  async getMessageThread(id, session) {
32518
32581
  return this.#json("GET", ["api", "v1", "messages", id], undefined, { session });
32519
32582
  }
32583
+ async acceptMessageDelivery(id, attempt, input) {
32584
+ return this.#json("POST", ["api", "v1", "messages", id, "deliveries", String(attempt), "accept"], input);
32585
+ }
32520
32586
  async artifact(issue2, input) {
32521
32587
  const artifactPath = ["api", "v1", "issues", await this.#resolveIssue(issue2), "artifacts"];
32522
32588
  if ("content" in input)
@@ -35236,7 +35302,7 @@ function conversationParts(content) {
35236
35302
  return parts;
35237
35303
  }
35238
35304
  function frameFor(entry) {
35239
- const { message } = entry;
35305
+ const { dispatchMessageId, message } = entry;
35240
35306
  if (message.role === "toolResult" && message.toolCallId !== undefined) {
35241
35307
  return {
35242
35308
  kind: "tool-result",
@@ -35258,7 +35324,8 @@ function frameFor(entry) {
35258
35324
  id: entry.key.slice(2),
35259
35325
  parts: conversationParts(message.host.content),
35260
35326
  role: message.role === "assistant" ? "assistant" : "user",
35261
- streaming: entry.streaming
35327
+ streaming: entry.streaming,
35328
+ ...dispatchMessageId === undefined ? {} : { dispatchMessageId }
35262
35329
  },
35263
35330
  seq: entry.seq,
35264
35331
  v: AGENT_STREAM_PROTOCOL
@@ -35287,7 +35354,7 @@ class AgentStreamPublisher {
35287
35354
  this.#publishedAt.clear();
35288
35355
  this.#watchedUntil = 0;
35289
35356
  }
35290
- record(subject2, raw, streaming) {
35357
+ record(subject2, raw, streaming, dispatchMessageId) {
35291
35358
  const message = readMessage(raw);
35292
35359
  if (message === null)
35293
35360
  return false;
@@ -35300,6 +35367,7 @@ class AgentStreamPublisher {
35300
35367
  this.#seq += 1;
35301
35368
  const entry = {
35302
35369
  at: message.timestamp,
35370
+ dispatchMessageId: dispatchMessageId ?? this.#history.get(key)?.dispatchMessageId,
35303
35371
  key,
35304
35372
  message,
35305
35373
  seq: this.#seq,
@@ -35370,6 +35438,101 @@ function withDispatchFirst(messages, context) {
35370
35438
  ];
35371
35439
  }
35372
35440
 
35441
+ // src/dispatch-user-turn.ts
35442
+ function isUserTurnCandidate(rendered) {
35443
+ const { delivery } = rendered;
35444
+ return rendered.envelope?.source === "dispatch" && delivery?.resource === "message" && delivery.issueKey === null && delivery.broadcastId === undefined && rendered.dispatchActor?.kind === "user" && (delivery.mode === "aside" || delivery.mode === "steer");
35445
+ }
35446
+ var AcceptedDeliverySchema = exports_external.object({
35447
+ message_id: exports_external.string(),
35448
+ body: exports_external.string(),
35449
+ delivery: exports_external.enum(["aside", "steer"])
35450
+ });
35451
+ function turnFromAccept(accepted) {
35452
+ const parsed = AcceptedDeliverySchema.safeParse(accepted);
35453
+ if (!parsed.success)
35454
+ return;
35455
+ return { body: parsed.data.body, messageId: parsed.data.message_id, mode: parsed.data.delivery };
35456
+ }
35457
+ var HANDLED_ATTEMPT_ENTRY = "envoy-dispatch-handled-attempt";
35458
+ function handledAttemptKey(messageId, attempt) {
35459
+ return `${messageId}#${attempt}`;
35460
+ }
35461
+ function handledAttempts(entries) {
35462
+ const handled = new Set;
35463
+ for (const entry of entries) {
35464
+ if (typeof entry !== "object" || entry === null)
35465
+ continue;
35466
+ if (!("type" in entry) || entry.type !== "custom")
35467
+ continue;
35468
+ if (!("customType" in entry) || entry.customType !== HANDLED_ATTEMPT_ENTRY)
35469
+ continue;
35470
+ if (!("data" in entry) || typeof entry.data !== "object" || entry.data === null)
35471
+ continue;
35472
+ const { data } = entry;
35473
+ if (!("message_id" in data) || typeof data.message_id !== "string")
35474
+ continue;
35475
+ if (!("attempt" in data) || typeof data.attempt !== "number")
35476
+ continue;
35477
+ handled.add(handledAttemptKey(data.message_id, data.attempt));
35478
+ }
35479
+ return handled;
35480
+ }
35481
+ function userMessageText(content) {
35482
+ if (typeof content === "string")
35483
+ return content;
35484
+ if (!Array.isArray(content))
35485
+ return "";
35486
+ const pieces = [];
35487
+ for (const part of content) {
35488
+ if (typeof part !== "object" || part === null)
35489
+ continue;
35490
+ if ("type" in part && part.type === "text" && "text" in part && typeof part.text === "string") {
35491
+ pieces.push(part.text);
35492
+ }
35493
+ }
35494
+ return pieces.join(`
35495
+ `);
35496
+ }
35497
+ var INJECTED_USER_TURNS = Symbol.for("legion.pi-envoy.injected-user-turns");
35498
+ function injectedUserTurns() {
35499
+ const store = globalThis;
35500
+ const turns = store[INJECTED_USER_TURNS] ?? new Map;
35501
+ store[INJECTED_USER_TURNS] = turns;
35502
+ return turns;
35503
+ }
35504
+ function noteInjectedUserTurn(sessionID, body, messageId) {
35505
+ const turns = injectedUserTurns();
35506
+ const session = turns.get(sessionID) ?? { found: new Map, sent: [] };
35507
+ session.sent.push({ body, messageId });
35508
+ turns.set(sessionID, session);
35509
+ }
35510
+ function matchInjectedUserTurn(sessionID, message) {
35511
+ if (typeof message !== "object" || message === null)
35512
+ return;
35513
+ if (!("role" in message) || message.role !== "user")
35514
+ return;
35515
+ if (!("timestamp" in message) || typeof message.timestamp !== "number")
35516
+ return;
35517
+ const session = injectedUserTurns().get(sessionID);
35518
+ if (session === undefined)
35519
+ return;
35520
+ const known = session.found.get(message.timestamp);
35521
+ if (known !== undefined)
35522
+ return known;
35523
+ const text = userMessageText("content" in message ? message.content : undefined);
35524
+ const index = session.sent.findIndex((sent2) => sent2.body === text);
35525
+ const sent = session.sent[index];
35526
+ if (sent === undefined)
35527
+ return;
35528
+ session.sent.splice(index, 1);
35529
+ session.found.set(message.timestamp, sent.messageId);
35530
+ return sent.messageId;
35531
+ }
35532
+ function endInjectedUserTurns(sessionID) {
35533
+ injectedUserTurns().delete(sessionID);
35534
+ }
35535
+
35373
35536
  // src/envoy-session.ts
35374
35537
  import path2 from "path";
35375
35538
  var ENVOY_SESSIONS = Symbol.for("legion.pi-envoy.envoy-session");
@@ -35400,9 +35563,9 @@ function resolveEnvoySession(sessionFile) {
35400
35563
  const parent = `${directory}.jsonl`;
35401
35564
  if (parent === candidate)
35402
35565
  break;
35403
- const recorded = sessions.get(parent);
35404
- if (recorded !== undefined)
35405
- return recorded;
35566
+ const recorded2 = sessions.get(parent);
35567
+ if (recorded2 !== undefined)
35568
+ return recorded2;
35406
35569
  if (path2.dirname(directory) === directory)
35407
35570
  break;
35408
35571
  candidate = parent;
@@ -35626,6 +35789,7 @@ var NATS_RETRY_INTERVAL_MS = 15000;
35626
35789
  var CAPABILITIES_WITHOUT_BTW = DELIVERY_CAPABILITIES.filter((capability) => capability !== "btw");
35627
35790
  var ROLE_CLAIM_ENTRY = "envoy-role-claim";
35628
35791
  var OPEN_ASKS_TIMEOUT_MS = 3000;
35792
+ var USER_TURN_ACCEPT_TIMEOUT_MS = 1e4;
35629
35793
  var LEGION_MANAGED_ENTRY = "legion-managed-session";
35630
35794
  var ASK_REMINDER_MESSAGE = "dispatch-ask-reminder";
35631
35795
  var ASK_PROMPT_LINE_SEPARATOR = /\r\n|[\r\n\u0085\u2028\u2029]/;
@@ -35755,6 +35919,7 @@ function envoyExtension(pi) {
35755
35919
  let runSeq = 0;
35756
35920
  let legionManagedTranscript = false;
35757
35921
  const legionManaged = (id) => legionManagedTranscript || legionRoleClaimBridge().managedSessions.has(id);
35922
+ let handledDispatchAttempts = new Set;
35758
35923
  const availabilityWarningSessionIDs = new Set;
35759
35924
  const warnAskAvailability = (context, error48) => {
35760
35925
  const sessionID2 = context.sessionManager.getSessionId();
@@ -35812,6 +35977,7 @@ function envoyExtension(pi) {
35812
35977
  awarenessGeneration++;
35813
35978
  }
35814
35979
  legionManagedTranscript = branch.some(isLegionManagedEntry);
35980
+ handledDispatchAttempts = handledAttempts(context.sessionManager.getEntries());
35815
35981
  };
35816
35982
  pi.on("resources_discover", async () => ({ skillPaths: [SKILLS_DIRECTORY] }));
35817
35983
  registerEnvoyMessageRenderer(pi);
@@ -35831,6 +35997,34 @@ function envoyExtension(pi) {
35831
35997
  const postDispatchReply = async (delivery, result) => {
35832
35998
  await postDeliveryReply(currentDispatchConfig(), sessionID, delivery, result);
35833
35999
  };
36000
+ const acceptedUserTurn = async (rendered) => {
36001
+ if (!isUserTurnCandidate(rendered))
36002
+ return;
36003
+ const { delivery } = rendered;
36004
+ const key = handledAttemptKey(delivery.id, delivery.attempt);
36005
+ if (handledDispatchAttempts.has(key)) {
36006
+ logger.warn("envoy: a frame names a Dispatch attempt this session already delivered; it arrives as a card", { attempt: delivery.attempt, messageID: delivery.id });
36007
+ return;
36008
+ }
36009
+ handledDispatchAttempts.add(key);
36010
+ pi.appendEntry(HANDLED_ATTEMPT_ENTRY, { attempt: delivery.attempt, message_id: delivery.id });
36011
+ try {
36012
+ const config2 = activeDispatchConfig(process.env, { cwd: process.cwd() });
36013
+ if (config2 === null)
36014
+ return;
36015
+ const accepted = await new DispatchClient(config2.url, config2.token, fetch, AbortSignal.timeout(USER_TURN_ACCEPT_TIMEOUT_MS)).acceptMessageDelivery(delivery.id, delivery.attempt, {
36016
+ actor: { id: sessionID, kind: "session" }
36017
+ });
36018
+ const turn = turnFromAccept(accepted);
36019
+ if (turn === undefined) {
36020
+ logger.warn("envoy: Dispatch accepted a direct message without the stored body and mode to inject; it arrives as a card", { attempt: delivery.attempt, messageID: delivery.id });
36021
+ }
36022
+ return turn;
36023
+ } catch (error48) {
36024
+ logger.warn("envoy: Dispatch did not accept a direct message as this session's turn; it arrives as a card", { attempt: delivery.attempt, messageID: delivery.id, error: messageFor(error48) });
36025
+ return;
36026
+ }
36027
+ };
35834
36028
  const deliver = async (subject2, raw, reply) => {
35835
36029
  const rendered = renderInbound(raw, sessionID, subject2);
35836
36030
  const directSubject = agentSubject(sessionID);
@@ -35889,10 +36083,16 @@ function envoyExtension(pi) {
35889
36083
  }
35890
36084
  }
35891
36085
  } else {
35892
- pi.sendMessage({ customType: "envoy-message", content: rendered.content, display: true }, {
35893
- deliverAs: rendered.delivery?.mode === "aside" ? "aside" : "steer",
35894
- triggerTurn: true
35895
- });
36086
+ const turn = await acceptedUserTurn(rendered);
36087
+ if (turn === undefined) {
36088
+ pi.sendMessage({ customType: "envoy-message", content: rendered.content, display: true }, {
36089
+ deliverAs: rendered.delivery?.mode === "aside" ? "aside" : "steer",
36090
+ triggerTurn: true
36091
+ });
36092
+ } else {
36093
+ noteInjectedUserTurn(sessionID, turn.body, turn.messageId);
36094
+ pi.sendUserMessage(turn.body, turn.mode === "aside" ? { deliverAs: "aside" } : undefined);
36095
+ }
35896
36096
  }
35897
36097
  } catch (error48) {
35898
36098
  console.warn(`[envoy] failed to deliver envelope ${envelope2?.event_id ?? "unknown"}`, error48);
@@ -36018,7 +36218,7 @@ function envoyExtension(pi) {
36018
36218
  const recordAgentStreamMessage = (message, streaming) => {
36019
36219
  if (sessionID === "")
36020
36220
  return;
36021
- agentStream.record(agentStreamFramesSubject(sessionID), message, streaming);
36221
+ agentStream.record(agentStreamFramesSubject(sessionID), message, streaming, matchInjectedUserTurn(sessionID, message));
36022
36222
  };
36023
36223
  pi.on("message_start", async (event) => {
36024
36224
  recordAgentStreamMessage(event.message, true);
@@ -36495,6 +36695,7 @@ function envoyExtension(pi) {
36495
36695
  }
36496
36696
  pi.on("agent_end", async (event, context) => {
36497
36697
  const id = context.sessionManager.getSessionId();
36698
+ endInjectedUserTurns(sessionID);
36498
36699
  const lastReply = event.messages?.findLast((message) => message.role === "assistant");
36499
36700
  const ask = sideTurn(pi, context);
36500
36701
  if (event.willContinue === true || lastReply?.stopReason !== "stop" || !context.hasUI || id === "" || ask === undefined || !checkOwed(id)) {
package/dist/legion.js CHANGED
@@ -29965,6 +29965,7 @@ var DispatchTargetedMessagePayloadSchema = MessageEventPayloadSchema.extend({
29965
29965
  body: exports_external.string(),
29966
29966
  target: exports_external.string(),
29967
29967
  in_reply_to: exports_external.string().nullable(),
29968
+ broadcast_id: exports_external.string().nullish(),
29968
29969
  deliveries: exports_external.array(exports_external.unknown()),
29969
29970
  created_at: exports_external.string()
29970
29971
  });
@@ -30646,6 +30647,8 @@ var EnvelopeSchema = exports_external.object({
30646
30647
  // ../contracts/src/handoff-schema.ts
30647
30648
  var HANDOFF_SCHEMA_VERSION = 1;
30648
30649
  var HANDOFF_PHASES = ["architect", "plan", "implement", "test", "review"];
30650
+ var PLAN_REVIEW_MAX_ROUNDS = 3;
30651
+ var PLAN_REVIEW_VERDICTS = ["approved", "rejected", "failed"];
30649
30652
  var isoTimestamp = exports_external.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
30650
30653
  var handoffPhase = exports_external.enum(HANDOFF_PHASES);
30651
30654
  var nonEmpty = exports_external.string().trim().min(1);
@@ -30682,6 +30685,16 @@ var requiredSkillsSchema = exports_external.object({
30682
30685
  test: exports_external.array(exports_external.string()).optional(),
30683
30686
  review: exports_external.array(exports_external.string()).optional()
30684
30687
  }).passthrough().optional();
30688
+ var gapAnalysisSchema = exports_external.object({
30689
+ findings: exports_external.array(exports_external.object({ finding: exports_external.string(), answer: exports_external.string() }).passthrough()).optional(),
30690
+ error: exports_external.string().optional()
30691
+ }).passthrough().optional();
30692
+ var planReviewSchema = exports_external.object({
30693
+ verdict: exports_external.enum(PLAN_REVIEW_VERDICTS),
30694
+ rounds: exports_external.number(),
30695
+ remainingIssues: exports_external.array(exports_external.object({ issue: exports_external.string(), evidence: exports_external.string() }).passthrough()).optional(),
30696
+ error: exports_external.string().optional()
30697
+ }).passthrough().optional();
30685
30698
  var planSchema = baseHandoffSchema.extend({
30686
30699
  phase: exports_external.literal("plan"),
30687
30700
  taskCount: exports_external.number().optional(),
@@ -30689,7 +30702,9 @@ var planSchema = baseHandoffSchema.extend({
30689
30702
  routingHints: routingHintsSchema,
30690
30703
  concerns: exports_external.array(exports_external.string()).optional(),
30691
30704
  workflowRecommendation: exports_external.string().optional(),
30692
- requiredSkills: requiredSkillsSchema
30705
+ requiredSkills: requiredSkillsSchema,
30706
+ gapAnalysis: gapAnalysisSchema,
30707
+ planReview: planReviewSchema
30693
30708
  });
30694
30709
  var implementSchema = baseHandoffSchema.extend({
30695
30710
  phase: exports_external.literal("implement"),
@@ -30729,13 +30744,60 @@ var reviewSchema = baseHandoffSchema.extend({
30729
30744
  verdict: exports_external.enum(["approved", "changes_requested"]).optional(),
30730
30745
  keyFindings: exports_external.array(exports_external.object({ severity: exports_external.string(), file: exports_external.string(), description: exports_external.string() }).passthrough()).optional()
30731
30746
  });
30732
- var nonEmptySkillList = exports_external.array(exports_external.string().trim().min(1)).min(1);
30747
+ var nonEmptySkillList = exports_external.array(nonEmpty).min(1);
30748
+ var recorded = (shape, whatToRecord) => exports_external.object(shape, {
30749
+ error: (issue2) => issue2.input === undefined ? `missing \u2014 record ${whatToRecord}` : undefined
30750
+ }).passthrough();
30751
+ var gapAnalysisWriteSchema = recorded({
30752
+ findings: exports_external.array(exports_external.object({ finding: nonEmpty, answer: nonEmpty }).passthrough()).optional(),
30753
+ error: nonEmpty.optional()
30754
+ }, "the gap analyst's `findings`, each with how the plan answers it (`[]` when it found none), or its failed call's `error`").refine((analysis) => analysis.findings === undefined !== (analysis.error === undefined), {
30755
+ message: "record exactly one of `findings` or the failed call's `error`"
30756
+ });
30757
+ var planReviewWriteSchema = recorded({
30758
+ verdict: exports_external.enum(PLAN_REVIEW_VERDICTS),
30759
+ rounds: exports_external.number().int().min(1).max(PLAN_REVIEW_MAX_ROUNDS),
30760
+ remainingIssues: exports_external.array(exports_external.object({ issue: nonEmpty, evidence: nonEmpty }).passthrough()).optional(),
30761
+ error: nonEmpty.optional()
30762
+ }, "the plan review's `verdict` and `rounds`, with `remainingIssues` when it was rejected or `error` when a review's call failed").superRefine((review, ctx) => {
30763
+ const remaining = review.remainingIssues?.length ?? 0;
30764
+ if (review.verdict === "rejected" && remaining === 0) {
30765
+ ctx.addIssue({
30766
+ code: "custom",
30767
+ path: ["remainingIssues"],
30768
+ message: "a rejected review records the blocking issues its last round named"
30769
+ });
30770
+ }
30771
+ if (review.verdict === "rejected" && review.rounds < PLAN_REVIEW_MAX_ROUNDS) {
30772
+ ctx.addIssue({
30773
+ code: "custom",
30774
+ path: ["rounds"],
30775
+ message: `a review still rejecting after ${review.rounds} of ${PLAN_REVIEW_MAX_ROUNDS} rounds is revised and reviewed again, not recorded`
30776
+ });
30777
+ }
30778
+ if (review.verdict === "approved" && remaining > 0) {
30779
+ ctx.addIssue({
30780
+ code: "custom",
30781
+ path: ["remainingIssues"],
30782
+ message: "an approved review leaves no blocking issue standing"
30783
+ });
30784
+ }
30785
+ if (review.verdict === "failed" !== (review.error !== undefined)) {
30786
+ ctx.addIssue({
30787
+ code: "custom",
30788
+ path: ["error"],
30789
+ message: "a failed review records its call's error, and only a failed review does"
30790
+ });
30791
+ }
30792
+ });
30733
30793
  var planWriteSchema = planSchema.extend({
30734
30794
  requiredSkills: exports_external.object({
30735
30795
  implement: nonEmptySkillList,
30736
30796
  test: nonEmptySkillList,
30737
30797
  review: nonEmptySkillList
30738
- }).passthrough()
30798
+ }).passthrough(),
30799
+ gapAnalysis: gapAnalysisWriteSchema,
30800
+ planReview: planReviewWriteSchema
30739
30801
  });
30740
30802
  var phaseHandoffSchema = exports_external.discriminatedUnion("phase", [
30741
30803
  architectSchema,
@@ -31423,6 +31485,9 @@ class DispatchClient {
31423
31485
  async getMessageThread(id, session) {
31424
31486
  return this.#json("GET", ["api", "v1", "messages", id], undefined, { session });
31425
31487
  }
31488
+ async acceptMessageDelivery(id, attempt, input) {
31489
+ return this.#json("POST", ["api", "v1", "messages", id, "deliveries", String(attempt), "accept"], input);
31490
+ }
31426
31491
  async artifact(issue2, input) {
31427
31492
  const artifactPath = ["api", "v1", "issues", await this.#resolveIssue(issue2), "artifacts"];
31428
31493
  if ("content" in input)
@@ -33611,7 +33676,7 @@ import { logger } from "@oh-my-pi/pi-utils";
33611
33676
  // package.json
33612
33677
  var package_default = {
33613
33678
  name: "@sjawhar/pi-legion-envoy",
33614
- version: "5.28.0",
33679
+ version: "5.30.0",
33615
33680
  type: "module",
33616
33681
  omp: {
33617
33682
  extensions: [
@@ -33656,6 +33721,101 @@ var package_default = {
33656
33721
  }
33657
33722
  };
33658
33723
 
33724
+ // src/dispatch-user-turn.ts
33725
+ function isUserTurnCandidate(rendered) {
33726
+ const { delivery } = rendered;
33727
+ return rendered.envelope?.source === "dispatch" && delivery?.resource === "message" && delivery.issueKey === null && delivery.broadcastId === undefined && rendered.dispatchActor?.kind === "user" && (delivery.mode === "aside" || delivery.mode === "steer");
33728
+ }
33729
+ var AcceptedDeliverySchema = exports_external.object({
33730
+ message_id: exports_external.string(),
33731
+ body: exports_external.string(),
33732
+ delivery: exports_external.enum(["aside", "steer"])
33733
+ });
33734
+ function turnFromAccept(accepted) {
33735
+ const parsed = AcceptedDeliverySchema.safeParse(accepted);
33736
+ if (!parsed.success)
33737
+ return;
33738
+ return { body: parsed.data.body, messageId: parsed.data.message_id, mode: parsed.data.delivery };
33739
+ }
33740
+ var HANDLED_ATTEMPT_ENTRY = "envoy-dispatch-handled-attempt";
33741
+ function handledAttemptKey(messageId, attempt) {
33742
+ return `${messageId}#${attempt}`;
33743
+ }
33744
+ function handledAttempts(entries) {
33745
+ const handled = new Set;
33746
+ for (const entry of entries) {
33747
+ if (typeof entry !== "object" || entry === null)
33748
+ continue;
33749
+ if (!("type" in entry) || entry.type !== "custom")
33750
+ continue;
33751
+ if (!("customType" in entry) || entry.customType !== HANDLED_ATTEMPT_ENTRY)
33752
+ continue;
33753
+ if (!("data" in entry) || typeof entry.data !== "object" || entry.data === null)
33754
+ continue;
33755
+ const { data } = entry;
33756
+ if (!("message_id" in data) || typeof data.message_id !== "string")
33757
+ continue;
33758
+ if (!("attempt" in data) || typeof data.attempt !== "number")
33759
+ continue;
33760
+ handled.add(handledAttemptKey(data.message_id, data.attempt));
33761
+ }
33762
+ return handled;
33763
+ }
33764
+ function userMessageText(content) {
33765
+ if (typeof content === "string")
33766
+ return content;
33767
+ if (!Array.isArray(content))
33768
+ return "";
33769
+ const pieces = [];
33770
+ for (const part of content) {
33771
+ if (typeof part !== "object" || part === null)
33772
+ continue;
33773
+ if ("type" in part && part.type === "text" && "text" in part && typeof part.text === "string") {
33774
+ pieces.push(part.text);
33775
+ }
33776
+ }
33777
+ return pieces.join(`
33778
+ `);
33779
+ }
33780
+ var INJECTED_USER_TURNS = Symbol.for("legion.pi-envoy.injected-user-turns");
33781
+ function injectedUserTurns() {
33782
+ const store = globalThis;
33783
+ const turns = store[INJECTED_USER_TURNS] ?? new Map;
33784
+ store[INJECTED_USER_TURNS] = turns;
33785
+ return turns;
33786
+ }
33787
+ function noteInjectedUserTurn(sessionID, body, messageId) {
33788
+ const turns = injectedUserTurns();
33789
+ const session = turns.get(sessionID) ?? { found: new Map, sent: [] };
33790
+ session.sent.push({ body, messageId });
33791
+ turns.set(sessionID, session);
33792
+ }
33793
+ function matchInjectedUserTurn(sessionID, message) {
33794
+ if (typeof message !== "object" || message === null)
33795
+ return;
33796
+ if (!("role" in message) || message.role !== "user")
33797
+ return;
33798
+ if (!("timestamp" in message) || typeof message.timestamp !== "number")
33799
+ return;
33800
+ const session = injectedUserTurns().get(sessionID);
33801
+ if (session === undefined)
33802
+ return;
33803
+ const known = session.found.get(message.timestamp);
33804
+ if (known !== undefined)
33805
+ return known;
33806
+ const text = userMessageText("content" in message ? message.content : undefined);
33807
+ const index = session.sent.findIndex((sent2) => sent2.body === text);
33808
+ const sent = session.sent[index];
33809
+ if (sent === undefined)
33810
+ return;
33811
+ session.sent.splice(index, 1);
33812
+ session.found.set(message.timestamp, sent.messageId);
33813
+ return sent.messageId;
33814
+ }
33815
+ function endInjectedUserTurns(sessionID) {
33816
+ injectedUserTurns().delete(sessionID);
33817
+ }
33818
+
33659
33819
  // src/legion/classify.ts
33660
33820
  function classifySession(env) {
33661
33821
  if (env.LEGION_CONTROLLER !== undefined && env.LEGION_TREE !== undefined) {
@@ -35777,7 +35937,8 @@ function legionExtension(pi) {
35777
35937
  pi.on("message_start", async (event, context) => {
35778
35938
  if (!phaseWorkerSession(context))
35779
35939
  return;
35780
- const kind = inboundKind(event.message);
35940
+ const injected = matchInjectedUserTurn(context.sessionManager.getSessionId(), event.message) !== undefined;
35941
+ const kind = injected ? "inbound-event" : inboundKind(event.message);
35781
35942
  if (kind !== undefined)
35782
35943
  advancePhaseStall({ kind });
35783
35944
  });
@@ -6,7 +6,8 @@ human messages you directly from the Agents page.
6
6
  ## Targeted agent messages
7
7
 
8
8
  A human — or any bearer caller over HTTP, such as a test rig — can target the issue message at a
9
- live Envoy session or role as **BTW**, **Aside**, or **Steer**. The incoming Dispatch frame names
9
+ live Envoy session or role as **BTW**, **Aside**, or **Steer** (the dashboard calls a steer
10
+ **Send**). The incoming Dispatch frame names
10
11
  the issue and includes a `reply_with` hint (`{ tool, args }`, ready to issue on any host); reply on the same open issue with the existing
11
12
  tool, never a new targeted send:
12
13
 
@@ -32,9 +33,17 @@ target only a session that advertises the mode you want. Sending to a session wi
32
33
 
33
34
  ## Answering a direct message
34
35
 
35
- A human can also message you directly from the **Agents** page, with no issue at all. That frame
36
- names no issue and its `reply_with` hint carries none either; answer it with the message's bare
37
- id in `in_reply_to`, alone:
36
+ A human can also message you directly from the **Agents** page, with no issue at all. On Oh My
37
+ Pi, a person's **Send** or **Aside** arrives as their own user message, exactly as if they had
38
+ typed it at your terminal: your Envoy plugin takes it only once Dispatch accepts it as a person's
39
+ own fresh message to you, and injects the text Dispatch stored. Answer it in the conversation as
40
+ you would anything typed, with no `dispatch_message`; the Agents page shows your conversation
41
+ live, so they read your answer there.
42
+
43
+ Everything else still arrives as a Dispatch frame: a **BTW**, a broadcast, a direct message on a
44
+ host that takes no user turn from its plugin (Claude Code), and one Dispatch did not accept.
45
+ That frame names no issue and its `reply_with` hint carries none either; answer it with the
46
+ message's bare id in `in_reply_to`, alone:
38
47
 
39
48
  ```ts
40
49
  dispatch_message({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion-envoy",
3
- "version": "5.28.0",
3
+ "version": "5.30.0",
4
4
  "type": "module",
5
5
  "omp": {
6
6
  "extensions": [