@sjawhar/pi-legion-envoy 1.50.0 → 1.51.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/envoy.js CHANGED
@@ -30394,12 +30394,19 @@ var stateIssue = strictObject({
30394
30394
  parent: nonEmptyString.optional(),
30395
30395
  lastAppliedSeq: number2().int().nonnegative().optional()
30396
30396
  });
30397
+ var stateWorkspaceLost = strictObject({
30398
+ at: nonEmptyString,
30399
+ generation: number2().int().nonnegative(),
30400
+ fromRef: nonEmptyString,
30401
+ previousSessionId: nonEmptyString.optional()
30402
+ });
30397
30403
  var stateTree = strictObject({
30398
30404
  status: _enum2(TREE_STATUSES),
30399
30405
  generation: number2().int().nonnegative(),
30400
30406
  launchFailures: number2().int().nonnegative(),
30401
30407
  readyConfirmedAt: number2().optional(),
30402
- locator: stateTreeLocator.optional()
30408
+ locator: stateTreeLocator.optional(),
30409
+ workspaceLost: stateWorkspaceLost.optional()
30403
30410
  });
30404
30411
  var stateGate = strictObject({
30405
30412
  artifactId: nonEmptyString,
@@ -30413,7 +30420,8 @@ var stateRole = strictObject({
30413
30420
  sessionId: nonEmptyString.optional(),
30414
30421
  readyConfirmedAt: number2().optional(),
30415
30422
  launchFailures: number2().int().nonnegative().optional(),
30416
- locator: stateLocator.optional()
30423
+ locator: stateLocator.optional(),
30424
+ workspaceLost: stateWorkspaceLost.optional()
30417
30425
  });
30418
30426
  var stateQueuedWorkerIdentity = {
30419
30427
  roleToken: nonEmptyString,
@@ -30452,7 +30460,8 @@ var LegionDaemonApi = {
30452
30460
  request: strictObject({
30453
30461
  secret: nonEmptyString,
30454
30462
  sessionId: nonEmptyString,
30455
- ompSessionFile: nonEmptyString.optional()
30463
+ ompSessionFile: nonEmptyString.optional(),
30464
+ pluginVersion: nonEmptyString
30456
30465
  }),
30457
30466
  response: object({})
30458
30467
  },
@@ -30467,7 +30476,8 @@ var LegionDaemonApi = {
30467
30476
  rootSessionId: nonEmptyString,
30468
30477
  agentId: nonEmptyString,
30469
30478
  bootToken: nonEmptyString,
30470
- ompSessionFile: nonEmptyString
30479
+ ompSessionFile: nonEmptyString,
30480
+ pluginVersion: nonEmptyString
30471
30481
  }),
30472
30482
  response: object({
30473
30483
  roleTokens: record(string2(), string2()),
@@ -30509,7 +30519,8 @@ var LegionDaemonApi = {
30509
30519
  bootToken: nonEmptyString,
30510
30520
  sessionId: nonEmptyString,
30511
30521
  agentId: nonEmptyString,
30512
- ompSessionFile: nonEmptyString
30522
+ ompSessionFile: nonEmptyString,
30523
+ pluginVersion: nonEmptyString
30513
30524
  }),
30514
30525
  response: object({
30515
30526
  roleToken: nonEmptyString,
package/dist/legion.js CHANGED
@@ -16142,6 +16142,51 @@ function messageFor(error) {
16142
16142
  // extensions/legion.ts
16143
16143
  var import_nats = __toESM(require_mod4(), 1);
16144
16144
  import { logger } from "@oh-my-pi/pi-utils";
16145
+ // package.json
16146
+ var package_default = {
16147
+ name: "@sjawhar/pi-legion-envoy",
16148
+ version: "1.51.0",
16149
+ type: "module",
16150
+ omp: {
16151
+ extensions: [
16152
+ "dist/envoy.js",
16153
+ "dist/legion.js"
16154
+ ],
16155
+ skills: [
16156
+ "dist/skills"
16157
+ ]
16158
+ },
16159
+ legion: {
16160
+ daemonApiVersion: 8
16161
+ },
16162
+ repository: {
16163
+ type: "git",
16164
+ url: "https://github.com/sjawhar/legion"
16165
+ },
16166
+ files: [
16167
+ "dist"
16168
+ ],
16169
+ scripts: {
16170
+ build: "bun build extensions/envoy.ts extensions/legion.ts --outdir dist --target bun --format esm --external @oh-my-pi/pi-coding-agent --external @oh-my-pi/pi-tui --external @oh-my-pi/pi-utils",
16171
+ prepack: "./scripts/prepack.sh",
16172
+ postpack: "rm -rf dist/skills",
16173
+ typecheck: "bunx tsc --noEmit",
16174
+ test: "bun test",
16175
+ lint: "bunx biome lint extensions/ src/"
16176
+ },
16177
+ devDependencies: {
16178
+ "@biomejs/biome": "^2.3.14",
16179
+ "@legion/contracts": "workspace:*",
16180
+ "@legion/envoy-client": "workspace:*",
16181
+ "@oh-my-pi/pi-tui": "^18.1.15",
16182
+ "@oh-my-pi/pi-utils": "^18.1.15",
16183
+ "@toon-format/toon": "^4.1.1",
16184
+ "@types/bun": "latest",
16185
+ nats: "^2.29.2",
16186
+ typescript: "^5.3.0",
16187
+ zod: "^4.1.8"
16188
+ }
16189
+ };
16145
16190
 
16146
16191
  // ../../node_modules/.bun/zod@4.3.6/node_modules/zod/v4/core/index.js
16147
16192
  var exports_core2 = {};
@@ -29668,12 +29713,19 @@ var stateIssue = strictObject({
29668
29713
  parent: nonEmptyString.optional(),
29669
29714
  lastAppliedSeq: number2().int().nonnegative().optional()
29670
29715
  });
29716
+ var stateWorkspaceLost = strictObject({
29717
+ at: nonEmptyString,
29718
+ generation: number2().int().nonnegative(),
29719
+ fromRef: nonEmptyString,
29720
+ previousSessionId: nonEmptyString.optional()
29721
+ });
29671
29722
  var stateTree = strictObject({
29672
29723
  status: _enum2(TREE_STATUSES),
29673
29724
  generation: number2().int().nonnegative(),
29674
29725
  launchFailures: number2().int().nonnegative(),
29675
29726
  readyConfirmedAt: number2().optional(),
29676
- locator: stateTreeLocator.optional()
29727
+ locator: stateTreeLocator.optional(),
29728
+ workspaceLost: stateWorkspaceLost.optional()
29677
29729
  });
29678
29730
  var stateGate = strictObject({
29679
29731
  artifactId: nonEmptyString,
@@ -29687,7 +29739,8 @@ var stateRole = strictObject({
29687
29739
  sessionId: nonEmptyString.optional(),
29688
29740
  readyConfirmedAt: number2().optional(),
29689
29741
  launchFailures: number2().int().nonnegative().optional(),
29690
- locator: stateLocator.optional()
29742
+ locator: stateLocator.optional(),
29743
+ workspaceLost: stateWorkspaceLost.optional()
29691
29744
  });
29692
29745
  var stateQueuedWorkerIdentity = {
29693
29746
  roleToken: nonEmptyString,
@@ -29726,7 +29779,8 @@ var LegionDaemonApi = {
29726
29779
  request: strictObject({
29727
29780
  secret: nonEmptyString,
29728
29781
  sessionId: nonEmptyString,
29729
- ompSessionFile: nonEmptyString.optional()
29782
+ ompSessionFile: nonEmptyString.optional(),
29783
+ pluginVersion: nonEmptyString
29730
29784
  }),
29731
29785
  response: object({})
29732
29786
  },
@@ -29741,7 +29795,8 @@ var LegionDaemonApi = {
29741
29795
  rootSessionId: nonEmptyString,
29742
29796
  agentId: nonEmptyString,
29743
29797
  bootToken: nonEmptyString,
29744
- ompSessionFile: nonEmptyString
29798
+ ompSessionFile: nonEmptyString,
29799
+ pluginVersion: nonEmptyString
29745
29800
  }),
29746
29801
  response: object({
29747
29802
  roleTokens: record(string2(), string2()),
@@ -29783,7 +29838,8 @@ var LegionDaemonApi = {
29783
29838
  bootToken: nonEmptyString,
29784
29839
  sessionId: nonEmptyString,
29785
29840
  agentId: nonEmptyString,
29786
- ompSessionFile: nonEmptyString
29841
+ ompSessionFile: nonEmptyString,
29842
+ pluginVersion: nonEmptyString
29787
29843
  }),
29788
29844
  response: object({
29789
29845
  roleToken: nonEmptyString,
@@ -29992,6 +30048,40 @@ function generation(env) {
29992
30048
  return value;
29993
30049
  }
29994
30050
 
30051
+ // src/legion/control.ts
30052
+ async function handleLegionControlDirective(directive, actions) {
30053
+ try {
30054
+ switch (directive.type) {
30055
+ case "reclaim-architect":
30056
+ await actions.reclaimArchitect();
30057
+ break;
30058
+ }
30059
+ actions.acknowledge();
30060
+ } catch (error) {
30061
+ actions.reject(messageFor(error));
30062
+ }
30063
+ }
30064
+ function parseControlDirective(raw) {
30065
+ const payload = JSON.parse(raw);
30066
+ if (typeof payload !== "object" || payload === null || !("type" in payload)) {
30067
+ throw new Error("Legion control directive must be an object with a type");
30068
+ }
30069
+ if (payload.type === "reclaim-architect") {
30070
+ if (!("redeliver" in payload) || typeof payload.redeliver !== "object" || payload.redeliver === null || !("topic" in payload.redeliver) || typeof payload.redeliver.topic !== "string" || !("payload" in payload.redeliver) || typeof payload.redeliver.payload !== "string" || !("eventId" in payload.redeliver) || typeof payload.redeliver.eventId !== "string") {
30071
+ throw new Error("Legion control directive is missing redelivery metadata");
30072
+ }
30073
+ return {
30074
+ type: "reclaim-architect",
30075
+ redeliver: {
30076
+ topic: payload.redeliver.topic,
30077
+ payload: payload.redeliver.payload,
30078
+ eventId: payload.redeliver.eventId
30079
+ }
30080
+ };
30081
+ }
30082
+ throw new Error("Invalid Legion control directive");
30083
+ }
30084
+
29995
30085
  // src/legion/daemon-client.ts
29996
30086
  import { setTimeout as delay } from "timers/promises";
29997
30087
  var SPAWN_WORKER_RETRY_DELAYS_MS = [2000, 5000];
@@ -30180,7 +30270,7 @@ function onEnvoyRoleRegained(listener) {
30180
30270
  }
30181
30271
 
30182
30272
  // src/legion/controller-session.ts
30183
- function createControllerSession(persistedTranscript, checkSubagentSession, rerunReadyAfterRegain) {
30273
+ function createControllerSession(persistedTranscript, checkSubagentSession, rerunReadyAfterRegain, pluginVersion) {
30184
30274
  let controllerSessionID;
30185
30275
  let controllerCapability;
30186
30276
  let controllerDaemon;
@@ -30201,7 +30291,8 @@ function createControllerSession(persistedTranscript, checkSubagentSession, reru
30201
30291
  await daemon.controllerReady({
30202
30292
  secret,
30203
30293
  sessionId: sessionID,
30204
- ...ompSessionFile === undefined ? {} : { ompSessionFile }
30294
+ ...ompSessionFile === undefined ? {} : { ompSessionFile },
30295
+ pluginVersion
30205
30296
  });
30206
30297
  controllerSessionID = sessionID;
30207
30298
  controllerTranscript = ompSessionFile;
@@ -30211,7 +30302,8 @@ function createControllerSession(persistedTranscript, checkSubagentSession, reru
30211
30302
  await rerunReadyAfterRegain("controller/ready", role, reason, () => daemon.controllerReady({
30212
30303
  secret,
30213
30304
  sessionId: sessionID,
30214
- ...controllerTranscript === undefined ? {} : { ompSessionFile: controllerTranscript }
30305
+ ...controllerTranscript === undefined ? {} : { ompSessionFile: controllerTranscript },
30306
+ pluginVersion
30215
30307
  }));
30216
30308
  });
30217
30309
  };
@@ -30248,40 +30340,6 @@ function createControllerSession(persistedTranscript, checkSubagentSession, reru
30248
30340
  return { claim, handleSessionStart, reclaimAfterSessionChange, isClaimedSession, mintGrant };
30249
30341
  }
30250
30342
 
30251
- // src/legion/control.ts
30252
- async function handleLegionControlDirective(directive, actions) {
30253
- try {
30254
- switch (directive.type) {
30255
- case "reclaim-architect":
30256
- await actions.reclaimArchitect();
30257
- break;
30258
- }
30259
- actions.acknowledge();
30260
- } catch (error) {
30261
- actions.reject(messageFor(error));
30262
- }
30263
- }
30264
- function parseControlDirective(raw) {
30265
- const payload = JSON.parse(raw);
30266
- if (typeof payload !== "object" || payload === null || !("type" in payload)) {
30267
- throw new Error("Legion control directive must be an object with a type");
30268
- }
30269
- if (payload.type === "reclaim-architect") {
30270
- if (!("redeliver" in payload) || typeof payload.redeliver !== "object" || payload.redeliver === null || !("topic" in payload.redeliver) || typeof payload.redeliver.topic !== "string" || !("payload" in payload.redeliver) || typeof payload.redeliver.payload !== "string" || !("eventId" in payload.redeliver) || typeof payload.redeliver.eventId !== "string") {
30271
- throw new Error("Legion control directive is missing redelivery metadata");
30272
- }
30273
- return {
30274
- type: "reclaim-architect",
30275
- redeliver: {
30276
- topic: payload.redeliver.topic,
30277
- payload: payload.redeliver.payload,
30278
- eventId: payload.redeliver.eventId
30279
- }
30280
- };
30281
- }
30282
- throw new Error("Invalid Legion control directive");
30283
- }
30284
-
30285
30343
  // src/legion/grant-file.ts
30286
30344
  import { randomUUID } from "crypto";
30287
30345
  import { chmod, rename, rm, writeFile } from "fs/promises";
@@ -30509,9 +30567,10 @@ function setLegionBootstrapExitForTests(hook) {
30509
30567
  exitProcess = hook;
30510
30568
  }
30511
30569
  function exitOnRegistrationRefusal(route, error) {
30512
- if (error instanceof LegionDaemonApiError && (error.status === 403 || error.status === 409)) {
30513
- console.error(`[legion] ${route} refused this session (${error.status}); exiting \u2014 the daemon owns what happens to this role next: ${messageFor(error)}`);
30514
- exitProcess(1);
30570
+ if (error instanceof LegionDaemonApiError) {
30571
+ console.error(`[legion] ${route} registration failed (${error.status}): ${error.responseBody}`);
30572
+ if (error.status >= 400 && error.status < 500)
30573
+ exitProcess(1);
30515
30574
  }
30516
30575
  throw error;
30517
30576
  }
@@ -30776,7 +30835,7 @@ function legionExtension(pi) {
30776
30835
  const persisted = await persistedTranscript(context);
30777
30836
  recordBootstrappedSession(persisted.sessionFile);
30778
30837
  return persisted;
30779
- }, checkSubagentSession, rerunReadyAfterRegain);
30838
+ }, checkSubagentSession, rerunReadyAfterRegain, package_default.version);
30780
30839
  const reclaimArchitect = async () => {
30781
30840
  if (capability === undefined || capability.kind !== "root-architect") {
30782
30841
  throw new Error("Legion root architect is not available for reclamation");
@@ -30847,7 +30906,8 @@ function legionExtension(pi) {
30847
30906
  bootToken,
30848
30907
  rootSessionId: sessionID,
30849
30908
  agentId,
30850
- ompSessionFile: sessionFile
30909
+ ompSessionFile: sessionFile,
30910
+ pluginVersion: package_default.version
30851
30911
  });
30852
30912
  } catch (error) {
30853
30913
  exitOnRegistrationRefusal("process/started", error);
@@ -30923,7 +30983,8 @@ function legionExtension(pi) {
30923
30983
  bootToken,
30924
30984
  sessionId: sessionID,
30925
30985
  agentId,
30926
- ompSessionFile: sessionFile
30986
+ ompSessionFile: sessionFile,
30987
+ pluginVersion: package_default.version
30927
30988
  });
30928
30989
  } catch (error) {
30929
30990
  exitOnRegistrationRefusal("worker/started", error);
@@ -308,6 +308,7 @@ active phase worker.
308
308
  | `phase-complete` | Payload `{type:"phase-complete", issue, role, summary}`. May arrive live or via `catchup-overseer`'s `phaseCompletions`. Read the committed handoff for that phase, then spawn the next phase's owner, or `spawn_worker` on the same role again to resume it with corrections if the handoff shows unresolved gaps. A `reviewer` completion whose GitHub review is `CHANGES_REQUESTED` (the daemon returns the issue's Dispatch status to `in_progress` for this, on the reviewer's completion and again when you spawn the corrective implementer unless the daemon already knows the issue is `in_progress`) means `spawn_worker` the **implementer** again with the review findings — thread URLs and blocking items — as its task, then route back through tester and reviewer in order; never `spawn_worker` the reviewer directly off this wake and never proceed to retro on this verdict. A reviewer completion with an `APPROVED` review proceeds to retro (step 5). A `reviewer` completion after a conflict-forced rebase whose review body names an unchanged fingerprint is a confirmation, not a round: if retro already completed, `spawn_worker` the merger; otherwise resume the step you were on. An `implementer` completion that follows the merge is its production report: read the record on the pull request and the issue, then run step 7 — the issue is already at `retro`, the daemon writes no status for this completion, and you set `done` yourself. A `tester` completion whose handoff carries `implementerProof.verdict: "rejected"`, or a failure naming the production-like proof, goes back to the **implementer** with that finding — never forward to the reviewer, and never by supplying the proof from another role. A worker that reports no surface reaches the changed path gets a child issue in this tree (infrastructure, tooling, or a skill) and a resume once it lands; that report is never a reason to advance the phase. |
309
309
  | `worker-queued` | Payload `{type:"worker-queued", issue, role}`. This role's task is queued for promotion — either the deployment's worker cap is full, or the live worker acknowledged the task without starting a turn and the daemon is retrying it (counted; the worker is replaced after three such failures, still with the same task). Do not respawn or retry — wait for `worker-started`. `legion state` shows the queue (`workerAdmission.queue`: role token, issue, role, kind, and the time the task was first queued — never the task text); read it before re-sending. A `spawn_worker` identical to the queued task changes nothing and is not announced again. Different text replaces the queued task silently in the same FIFO slot and retains its original queue time. A `spawn_worker` that fails with "got no response in 3 attempts" was already retried by the plugin under one request id and may still have reached the daemon: read the queue and the role's claim in `legion state` before sending it again. |
310
310
  | `worker-started` | Payload `{type:"worker-started", issue, role}`. A previously queued role has been promoted and is now running. Treat it exactly as a normal spawn: resume tracking that role's live session. |
311
+ | `worker-recovered` | Payload `{type:"worker-recovered", issue, role, fromRef, delivery?}`. The worker's tree volume was lost and the daemon replaced it from the committed handoff on `fromRef`. `delivery: "spawned"` means the current assignment was preserved on the new worker; do not resend it. `delivery: "queued"` means that preserved assignment awaits capacity; wait for `worker-started`. Without `delivery`, inspect `.legion/` and the active phase before deciding whether work needs a new assignment. |
311
312
  | `pr-ready` | Verify the live PR head, green status, and review state. Continue the review/retro/merger order only for that current head. |
312
313
  | `pr-review` | Payload `{type:"pr-review", state, author, body}`. Delivered to whichever role is currently active for the issue, falling back to you when no worker phase is active. Follows the same verdict rule as a reviewer's `phase-complete`: `state: "changes_requested"` sends the implementer back in with the review findings, then tester, then reviewer — never the reviewer again and never retro; that `spawn_worker` returns the issue to `in_progress` on its own (the daemon writes it for a corrective implementer whenever the PR's latest recorded review is changes requested, a human's after approval included), so you set nothing by hand; `state: "approved"` proceeds toward retro (step 5) once the step 6 integration/merge-gate conditions are met. `state: "approved"` on a rebased head whose body names an unchanged fingerprint is that confirmation: proceed to retro if it has not run, otherwise to the merger — never to a second retro or test round. |
313
314
  | `pr-blocked` | Payload `{type:"pr-blocked", pr, attempts}`. `attempts` counts heads pushed onto a red verdict that changed something outside `.legion/` — handoff-only pushes (`.legion/` paths only) never count; a push the daemon cannot classify (a listener without `changed_paths`, a list capped at 100, a push listing no commits) does. Published once per exhausted count, not on every later red verdict for that count. Read the failed CI evidence and recovery attempts. Assign a focused implementer or corrective child, then return it through testing and review; do not treat the blocked PR as final. |
@@ -112,6 +112,7 @@ quoted here.
112
112
  | `child-status` | child key + status transition | Not controller-actionable by default; if the daemon could not route it to the parent's architect role, verify the transition and forward it with `envoy_publish` |
113
113
  | Mention | Slack/GitHub PR @mention text | Answer, or route to the owning issue's architect role |
114
114
  | READY packet seen on a Dispatch issue (via issue subscription) | READY line + gate facts | No action: a human merges; the merger has already notified the queue role if the project has one |
115
+ | `worker-recovered` (role `architect`) from the daemon | issue, fromRef | A root architect's tree volume was lost; it restarted as a new session. Verify the tree is active in `legion state` and that the architect posts its next step on the issue within one resync interval; otherwise treat it as an anomaly. |
115
116
  | Closed-tree activity (comment, review, CI on a closed tree) | issue, root, event summary | Read the artifact; if work should resume, `legion status <root> todo`; otherwise no action — the event is not held or redelivered |
116
117
  | Direct user message | — | Always first |
117
118
 
@@ -111,6 +111,10 @@ predecessor phases already wrote. The durable copy lives in
111
111
  `$LEGION_WORKSPACE/.legion/<phase>.json`. If a committed handoff conflicts with memory or a prior
112
112
  transcript, the committed file wins: it is the copy that survived.
113
113
 
114
+ If your system prompt begins with `Your workspace was recreated…`, read
115
+ `.legion/workspace-recovered.json`, then your phase's committed handoff, and reconcile before any
116
+ new work.
117
+
114
118
  ## jj Safety Rules
115
119
 
116
120
  - **Always `jj -R "$LEGION_WORKSPACE" new` to create isolated commits.** Never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion-envoy",
3
- "version": "1.50.0",
3
+ "version": "1.51.0",
4
4
  "type": "module",
5
5
  "omp": {
6
6
  "extensions": [
@@ -12,7 +12,7 @@
12
12
  ]
13
13
  },
14
14
  "legion": {
15
- "daemonApiVersion": 7
15
+ "daemonApiVersion": 8
16
16
  },
17
17
  "repository": {
18
18
  "type": "git",