@sjawhar/pi-legion 11.6.0 → 12.1.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
@@ -55,7 +55,7 @@ entry, writes the notices and stages this package's partition of `skills/`. It r
55
55
  the committed manifest, whose `omp.extensions` names `extensions/legion.ts`; the release workflow's
56
56
  `pi_legion_build` job (`.github/workflows/release.yaml`), the worker image and the e2e pack step
57
57
  rewrite it to `["dist/legion.js"]` before `bun pm pack`. Only the e2e pack step restores it
58
- afterwards: the build job's tree is discarded, and `pi_legion_publish` commits the version bump to
58
+ afterwards: the build job's tree is discarded, and the `publish` job commits the version bump to
59
59
  the committed manifest, touching `.version` alone. The manifest's `legion` key carries the daemon
60
60
  API contract the plugin was built against; `src/daemon-api-version.test.ts` pins it to the daemon's
61
61
  golden fixture.
package/dist/legion.js CHANGED
@@ -5149,7 +5149,10 @@ var IssueEventPayloadSchema = object({
5149
5149
  status: string2().optional(),
5150
5150
  priority: number2().int().min(0).max(3).nullable().optional(),
5151
5151
  rank: string2().optional(),
5152
- route: string2().nullish()
5152
+ route: string2().nullish(),
5153
+ changed: array(string2()).optional(),
5154
+ ancestors: array(string2()).optional(),
5155
+ thread_logins: array(string2()).optional()
5153
5156
  });
5154
5157
  var ArtifactCreatedEventPayloadSchema = object({
5155
5158
  artifact: object({ id: string2().optional(), slug: string2().optional(), name: string2().optional() }).optional()
@@ -5203,11 +5206,12 @@ var askEventPayloadFields = {
5203
5206
  primary: boolean2().optional()
5204
5207
  }).optional(),
5205
5208
  resolution: object({
5206
- kind: _enum(["retracted", "resolved"]).optional(),
5209
+ kind: _enum(["retracted", "resolved", "withdrawn"]).optional(),
5207
5210
  reason: string2().optional(),
5208
5211
  actor: object({ kind: string2().optional(), id: string2().optional() }).passthrough().optional(),
5209
5212
  at: string2().optional()
5210
- }).nullish()
5213
+ }).nullish(),
5214
+ thread_logins: array(string2()).optional()
5211
5215
  };
5212
5216
  var AskEventPayloadSchema = intersection(object(askEventPayloadFields), object({
5213
5217
  previous: never().optional(),
@@ -5269,7 +5273,8 @@ var CommentEventPayloadSchema = object({
5269
5273
  author: object({ kind: string2(), id: string2() }).optional(),
5270
5274
  created_at: string2().optional(),
5271
5275
  mentions: array(CommentMentionSchema).optional(),
5272
- deliveries: array(CommentDeliverySchema).optional()
5276
+ deliveries: array(CommentDeliverySchema).optional(),
5277
+ thread_logins: array(string2()).optional()
5273
5278
  }).passthrough();
5274
5279
  var MessageEventPayloadSchema = object({
5275
5280
  id: string2().optional(),
@@ -5278,7 +5283,8 @@ var MessageEventPayloadSchema = object({
5278
5283
  in_reply_to: string2().nullish(),
5279
5284
  reply_body: string2().optional(),
5280
5285
  thread_target: string2().optional(),
5281
- author: object({ kind: string2(), id: string2() }).optional()
5286
+ author: object({ kind: string2(), id: string2() }).optional(),
5287
+ thread_logins: array(string2()).optional()
5282
5288
  });
5283
5289
  var DispatchTargetedResourceIDSchema = uuid2();
5284
5290
  var DispatchTargetedMessagePayloadSchema = MessageEventPayloadSchema.extend({
@@ -5508,7 +5514,7 @@ var dispatchToolSpecs = [
5508
5514
  priority: 1,
5509
5515
  before: "DSP-2"
5510
5516
  },
5511
- description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, replace the issues it is blocked by, attach it to " + "architecture components, set the repository it targets, or move it on its project's " + "board. Status is one of " + `${ISSUE_STATUSES.join(", ")}; outside Legion, move it yourself as the work advances; inside ` + "Legion the daemon moves it. A non-empty external_links is " + "merged into the issue's existing links by URL, so linking the pull request you just opened " + "keeps every earlier link; an empty external_links removes every link instead. components " + "replaces the issue's own attachment. Closing an issue " + "(status done) requires reason, the note that says why: it is posted on the issue as a " + "message, then the issue closes, because a closed issue refuses messages, comments, and " + "artifacts; reason goes only with status done. A closed issue takes only a board move " + "(before, after), components, " + "a reopening status (any status but done), and an external_links write that adds no new " + "link (so a link can move to the issue that replaced it); everything else, priority " + "included, waits for the reopen. " + "priority is yours to set and a human overrides it. before and after move the issue on " + "its project's board: before puts it above that issue, after below, and both together " + "land it between them. A project's board order is one agent's to keep; in a Legion " + "deployment that is the controller. Never reassign an issue a person holds to get a " + "faster answer: who holds it is their call. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
5517
+ description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, replace the issues it is blocked by, attach it to " + "architecture components, set the repository it targets, or move it on its project's " + "board. Status is one of " + `${ISSUE_STATUSES.join(", ")}; outside Legion, move it yourself as the work advances; inside ` + "Legion the daemon moves it. A non-empty external_links is " + "merged into the issue's existing links by URL, so linking the pull request you just opened " + "keeps every earlier link; an empty external_links removes every link instead. components " + "replaces the issue's own attachment. Closing an issue " + "(status done) requires reason, the note that says why: it is posted on the issue as a " + "message, then the issue closes, because a closed issue refuses messages, comments, and " + "artifacts; reason goes only with status done. Closing an issue withdraws every open ask " + "it holds (resolution kind withdrawn, naming the closer), and reopening it brings exactly " + "those asks back. A closed issue takes only a board move " + "(before, after), components, " + "a reopening status (any status but done), and an external_links write that adds no new " + "link (so a link can move to the issue that replaced it); everything else, priority " + "included, waits for the reopen. " + "priority is yours to set and a human overrides it. before and after move the issue on " + "its project's board: before puts it above that issue, after below, and both together " + "land it between them. A project's board order is one agent's to keep; in a Legion " + "deployment that is the controller. Never reassign an issue a person holds to get a " + "faster answer: who holds it is their call. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
5512
5518
  arguments: (z) => ({
5513
5519
  issue: z.string().describe(ISSUE_REFERENCE),
5514
5520
  status: z.enum(ISSUE_STATUSES).describe("New lifecycle status.").optional(),
@@ -5535,7 +5541,7 @@ var dispatchToolSpecs = [
5535
5541
  {
5536
5542
  name: "dispatch_claim",
5537
5543
  example: { issue: "DSP-1" },
5538
- description: "Claim a Dispatch issue before you start implementing it, so no other session takes the same work, " + "and release it when you stop. Pass the issue alone to claim it, or --release to give it up. " + "Your claim records your own session and shows on every read of the issue: the dashboard header, the " + "issue list and board, dispatch read, and dispatch issues. Claiming is refused with 409 ISSUE_CLAIMED " + "when another session holds the issue and is still running; the refusal names that session, so talk to " + "it instead of working the same issue in parallel. When a human holds the claim the refusal names the " + "person, not a session: there is nothing running to message, so ask them on the issue rather than " + "taking it. 409 CLAIM_CONTENDED means the issue changed " + "hands twice while your call ran, so nothing was applied and nobody's liveness was checked: read " + "the issue and decide again. A claim whose session is no longer running may be " + "taken: the takeover is recorded on the issue and the session that lost it is told. A claim is not the " + "issue's status \u2014 claiming moves nothing, and the result names who moves the status: you, with " + "dispatch issue-update, or Legion's daemon on an issue it runs, where a status written by anyone " + "else is undone. A claim is released by its holder or any human, and by " + "any agent once the holder's session is no longer running. " + ISSUE_REFERENCE,
5544
+ description: "Claim a Dispatch issue before you start implementing it, so no other session takes the same work, " + "and release it when you stop. Pass the issue alone to claim it, or --release to give it up. " + "Your claim records your own session and shows on every read of the issue: the dashboard header, the " + "issue list and board, dispatch read, and dispatch issues. While you hold it, a person's answers, " + "comments and messages on the issue reach your session directly (a new comment that mentions you " + "arrives once, as the mention, when the mention reached you; when the mention's send fails, the " + "comment still reaches you directly; a mention whose receipt timed out can arrive twice, and the " + "second copy is the same comment); an agent's events and every other change on the issue still " + "need a subscription to it, except in an ask you follow: anyone else's answer, hand-back, " + "resolution or edit of it (other than a quiet edit), and every reply there (new, edited, resolved " + "or reopened), reach you " + "as its follower. Claiming is refused with 409 ISSUE_CLAIMED " + "when another session holds the issue and is still running; the refusal names that session, so talk to " + "it instead of working the same issue in parallel. When a human holds the claim the refusal names the " + "person, not a session: there is nothing running to message, so ask them on the issue rather than " + "taking it. 409 CLAIM_CONTENDED means the issue changed " + "hands twice while your call ran, so nothing was applied and nobody's liveness was checked: read " + "the issue and decide again. A claim whose session is no longer running may be " + "taken: the takeover is recorded on the issue and the session that lost it is told. A claim is not the " + "issue's status \u2014 claiming moves nothing, and the result names who moves the status: you, with " + "dispatch issue-update, or Legion's daemon on an issue it runs, where a status written by anyone " + "else is undone. A claim is released by its holder or any human, and by " + "any agent once the holder's session is no longer running. " + ISSUE_REFERENCE,
5539
5545
  arguments: (z) => ({
5540
5546
  issue: z.string().describe(ISSUE_REFERENCE),
5541
5547
  release: z.boolean().describe("Give up your claim instead of taking it; the issue's status does not change.").optional()
@@ -5622,7 +5628,7 @@ var dispatchToolSpecs = [
5622
5628
  kind: "retracted",
5623
5629
  reason: "A newer question supersedes this one."
5624
5630
  },
5625
- description: "Retract an open question that is moot or resolve one after finding the answer. This closes " + "the question without answering it. An ask that lives as an `ask` block in a document is " + 'closed in the document too. A reason beginning "removed from the document in version" is ' + "refused: that marks a retraction the document's own settlement wrote. Use retracted for an ask " + "a newer one replaces or the design made moot, resolved for one whose answer you found; an " + "answered ask cannot be resolved, and resolving records no decision by a person.",
5631
+ description: "Retract an open question that is moot or resolve one after finding the answer. This closes " + "the question without answering it. An ask that lives as an `ask` block in a document is " + 'closed in the document too. A reason beginning "removed from the document in version" is ' + "refused: that marks a retraction the document's own settlement wrote. Use retracted for an ask " + "a newer one replaces or the design made moot, resolved for one whose answer you found; an " + "answered ask cannot be resolved, and resolving records no decision by a person. An issue's " + "close withdrew its open asks (kind withdrawn, which only Dispatch writes, never this tool); " + "an ask on a closed issue is refused (409 ISSUE_CLOSED): reopen the issue first.",
5626
5632
  arguments: (z) => ({
5627
5633
  ask: z.string().describe("Ask id (uuid) to close; an 8+ hex prefix unique among this session's own open asks " + "works too."),
5628
5634
  kind: z.enum(["retracted", "resolved"]).describe("Whether the ask is retracted or self-resolved."),
@@ -5741,7 +5747,7 @@ var dispatchToolSpecs = [
5741
5747
  {
5742
5748
  name: "dispatch_doc_read",
5743
5749
  example: { issue: "DSP-1" },
5744
- description: "Read a live document or a named document version, or the text of an uploaded file at its latest or named version. " + "Do not use it for issue status, asks, or events; " + "use dispatch read instead. Supply ref, issue, or project plus artifact; issue plus an omitted artifact reads the primary document. " + "A live read returns its document token for an optional dispatch doc-edit precondition. With blocks it lists the live document's blocks instead of its markdown, in document order: each block's id, type, token, anchored comments and asks, and opening words, and under a table each row by the index delete_row takes and each cell by its row and the column delete_column takes; block ids, block tokens and those indexes are what dispatch doc-edit takes. " + "A picture (an uploaded PNG, JPEG, GIF or WebP of at most 3,750,000 bytes, 5 MB once base64-encoded) comes back as an image you see, with its name, type, size and version, every time you ask, including one a read already showed this session; " + "any other file that is not UTF-8 text is described, with the route that serves its bytes. " + OWNER_REFERENCE,
5750
+ description: "Read a live document or a named document version, or the text of an uploaded file at its latest or named version. " + "Do not use it for issue status, asks, or events; " + "use dispatch read instead. Supply ref, issue, or project plus artifact; issue plus an omitted artifact reads the primary document. " + "A live read returns its document token for an optional dispatch doc-edit precondition. With blocks it lists the live document's blocks instead of its markdown, in document order: each block's id, type, token, anchored comments and asks, and opening words, and under a table each row by the index delete_row takes and each cell by its row and the column delete_column takes; block ids, block tokens and those indexes are what dispatch doc-edit takes. " + "After the markdown it prints every open thread anchored on the document in full: an open ask with its id and question and each reply's author, time and text, and a comment thread with its root's id, author, quote and text and each reply's author, time and text, in order; a resolved thread, and an ask that is answered or resolved, is not printed. " + "A picture (an uploaded PNG, JPEG, GIF or WebP of at most 3,750,000 bytes, 5 MB once base64-encoded) comes back as an image you see, with its name, type, size and version, every time you ask, including one a read already showed this session; " + "any other file that is not UTF-8 text is described, with the route that serves its bytes. " + OWNER_REFERENCE,
5745
5751
  arguments: (z) => ({
5746
5752
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
5747
5753
  project: z.string().describe("Project key owning the document.").optional(),
@@ -6480,8 +6486,8 @@ class DispatchClient {
6480
6486
  async resolveComment(id, actor) {
6481
6487
  return this.#json("POST", ["api", "v1", "comments", id, "resolve"], { actor });
6482
6488
  }
6483
- async getComments(issue, artifact) {
6484
- return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue), "comments"], undefined, artifact ? { artifact } : undefined);
6489
+ async getComments(issue) {
6490
+ return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue), "comments"]);
6485
6491
  }
6486
6492
  async getIssueReferences(issue) {
6487
6493
  return this.#json("GET", [
@@ -7489,7 +7495,7 @@ async function findHandoffCommit(input) {
7489
7495
 
7490
7496
  // ../pi-shared/src/interface.ts
7491
7497
  import { logger } from "@oh-my-pi/pi-utils";
7492
- var ENVOY_PLUGIN_INTERFACE_VERSION = 3;
7498
+ var ENVOY_PLUGIN_INTERFACE_VERSION = 4;
7493
7499
  var ENVOY_PLUGIN_INTERFACE_KEY = Symbol.for("legion.pi-shared.envoy-plugin-interface");
7494
7500
  var LEGION_PLUGIN_LOADED_KEY = Symbol.for("legion.pi-legion.loaded");
7495
7501
  var LEGACY_LEGION_LOADED_KEY = Symbol.for("legion.pi-envoy.legion-loaded");
@@ -7509,7 +7515,8 @@ function envoyPluginInterface() {
7509
7515
  slackRoleOwned: false
7510
7516
  },
7511
7517
  injectedUserTurns: new Map,
7512
- bootstrappedSession: { file: undefined }
7518
+ bootstrappedSession: { file: undefined },
7519
+ startupTurn: undefined
7513
7520
  };
7514
7521
  store[ENVOY_PLUGIN_INTERFACE_KEY] = created;
7515
7522
  return created;
@@ -7604,6 +7611,19 @@ function onEnvoyRoleRegained(listener) {
7604
7611
  legionRoleClaimBridge().regained = listener;
7605
7612
  }
7606
7613
 
7614
+ // ../pi-shared/src/startup-turn.ts
7615
+ function markStartupTurnSent(sessionID) {
7616
+ envoyPluginInterface().startupTurn = { session: sessionID, sentAt: Date.now() };
7617
+ }
7618
+ function clearStartupTurn(sessionID) {
7619
+ const reading = readEnvoyPluginInterface();
7620
+ if (reading.kind !== "present")
7621
+ return;
7622
+ if (reading.iface.startupTurn?.session !== sessionID)
7623
+ return;
7624
+ reading.iface.startupTurn = undefined;
7625
+ }
7626
+
7607
7627
  // ../pi-shared/src/subagent-session.ts
7608
7628
  import fs from "fs";
7609
7629
  import path2 from "path";
@@ -7660,7 +7680,7 @@ function subagentSessionCheck() {
7660
7680
  }
7661
7681
 
7662
7682
  // extensions/legion.ts
7663
- import { logger as logger3 } from "@oh-my-pi/pi-utils";
7683
+ import { logger as logger4 } from "@oh-my-pi/pi-utils";
7664
7684
 
7665
7685
  // src/capability-report.ts
7666
7686
  import { readdir, stat } from "fs/promises";
@@ -8158,7 +8178,7 @@ async function bootLegionClaim(host) {
8158
8178
  // package.json
8159
8179
  var package_default = {
8160
8180
  name: "@sjawhar/pi-legion",
8161
- version: "11.6.0",
8181
+ version: "12.1.0",
8162
8182
  license: "Apache-2.0",
8163
8183
  type: "module",
8164
8184
  omp: {
@@ -8170,7 +8190,7 @@ var package_default = {
8170
8190
  ]
8171
8191
  },
8172
8192
  legion: {
8173
- daemonApiVersion: 40
8193
+ daemonApiVersion: 41
8174
8194
  },
8175
8195
  repository: {
8176
8196
  type: "git",
@@ -8185,7 +8205,7 @@ var package_default = {
8185
8205
  prepack: "../../scripts/pi-plugin-prepack.sh",
8186
8206
  postpack: "rm -rf dist/skills",
8187
8207
  typecheck: "bunx tsc --noEmit",
8188
- test: "bun test",
8208
+ test: "bun test --path-ignore-patterns=extensions/controller-start-omp.test.ts && bun test ./extensions/controller-start-omp.test.ts",
8189
8209
  lint: "bunx biome lint extensions/ src/"
8190
8210
  },
8191
8211
  devDependencies: {
@@ -8373,6 +8393,9 @@ function createClaimSession(deps) {
8373
8393
  };
8374
8394
  }
8375
8395
 
8396
+ // src/controller-session.ts
8397
+ import { logger as logger3 } from "@oh-my-pi/pi-utils";
8398
+
8376
8399
  // src/slack-channels.ts
8377
8400
  function daemonLaunchedController(env) {
8378
8401
  return env.LEGION_CONTROLLER === "1" && env.LEGION_TREE === undefined && (env.LEGION_BOOT_TOKEN_FILE !== undefined || env.LEGION_BOOT_TOKEN !== undefined);
@@ -8394,30 +8417,60 @@ function daemonControllerSlackChannels(env) {
8394
8417
  }
8395
8418
 
8396
8419
  // src/controller-session.ts
8420
+ var CLAIM_WIDGET = "legion-controller-claim";
8421
+ function operatorLaunchedController(env) {
8422
+ return classifySession(env).kind === "controller" && !daemonLaunchedController(env);
8423
+ }
8397
8424
  function createControllerSession(deps) {
8398
8425
  const { daemon, persistedTranscript, pi } = deps;
8399
8426
  let controllerSessionID;
8400
8427
  let controllerCapability;
8401
8428
  let mintControllerGrant;
8402
8429
  let startMessageSent = false;
8430
+ let claimComplete = false;
8403
8431
  let controllerTranscript;
8404
- const claim = async (context) => {
8432
+ let claimChain = Promise.resolve();
8433
+ let claimsQueued = 0;
8434
+ let startHandedOver = false;
8435
+ let claimWidget;
8436
+ let startTurnSession;
8437
+ const serialised = (task) => {
8438
+ claimsQueued += 1;
8439
+ const run = claimChain.then(task).finally(() => {
8440
+ claimsQueued -= 1;
8441
+ });
8442
+ claimChain = run.catch(() => {
8443
+ return;
8444
+ });
8445
+ return run;
8446
+ };
8447
+ const claim = (context) => queueClaim(context, { registered: false });
8448
+ const noteWait = (context) => {
8449
+ if (claimsQueued > 0) {
8450
+ context.ui.notify("legion: waiting for the controller claim already running to finish.", "info");
8451
+ }
8452
+ };
8453
+ const queueClaim = (context, progress) => {
8454
+ noteWait(context);
8455
+ return serialised(() => claimOnce(context, progress));
8456
+ };
8457
+ const claimOnce = async (context, progress) => {
8405
8458
  const supervised = daemonLaunchedController(process.env);
8406
8459
  if (!supervised)
8407
- return claimWith(context, false);
8460
+ return claimWith(context, false, progress);
8408
8461
  try {
8409
- await claimWith(context, true);
8462
+ await claimWith(context, true, progress);
8410
8463
  } catch (error) {
8411
- console.error(`[legion] the daemon-launched controller's claim failed; exiting so the daemon launches it again: ${messageFor(error)}`);
8464
+ logger3.error(`[legion] the daemon-launched controller's claim failed; exiting so the daemon launches it again: ${messageFor(error)}`);
8412
8465
  deps.exitProcess(1);
8413
8466
  }
8414
8467
  };
8415
- const claimWith = async (context, supervised) => {
8468
+ const claimWith = async (context, supervised, progress) => {
8416
8469
  const sessionID = context.sessionManager.getSessionId();
8417
8470
  const capability = controllerCapability ?? (supervised ? requiredSecret(process.env, "LEGION_BOOT_TOKEN") : requiredControllerCapability(process.env));
8418
8471
  controllerCapability = capability;
8419
8472
  const launched = classifySession(process.env).kind === "controller";
8420
- const startMessage = launched && !supervised ? requiredEnvironment(process.env, "LEGION_CONTROLLER_START_MESSAGE") : undefined;
8473
+ const startMessage = operatorLaunchedController(process.env) ? requiredEnvironment(process.env, "LEGION_CONTROLLER_START_MESSAGE") : undefined;
8421
8474
  const { sessionFile, agentId } = await persistedTranscript(context);
8422
8475
  if (launched)
8423
8476
  recordBootstrappedSession(sessionFile);
@@ -8437,24 +8490,41 @@ function createControllerSession(deps) {
8437
8490
  pluginContract: package_default.legion.daemonApiVersion
8438
8491
  }).catch((error) => {
8439
8492
  if (error instanceof LegionDaemonApiError) {
8440
- console.error(`[legion] claims/register for the controller failed (${error.status}): ${error.detail}`);
8493
+ logger3.error(`[legion] claims/register for the controller failed (${error.status}): ${error.detail}`);
8441
8494
  }
8442
8495
  throw error;
8443
8496
  });
8444
8497
  if (controllerToken(project) !== registration.claimToken) {
8445
8498
  throw new Error(`LEGION_PROJECT ${project} names controller role ${controllerToken(project)}, but the daemon registered this controller as ${registration.claimToken}`);
8446
8499
  }
8500
+ claimComplete = false;
8501
+ mintControllerGrant = () => client.controllerGrant({ sessionId: sessionID, secret: registration.secret });
8502
+ controllerSessionID = sessionID;
8503
+ controllerTranscript = launched ? sessionFile : undefined;
8504
+ progress.registered = true;
8447
8505
  const envoyContext = "setInterval" in context ? context : undefined;
8448
- await claimEnvoyRole(sessionID, registration.claimToken, envoyContext);
8449
8506
  if (startMessage !== undefined && !startMessageSent) {
8450
- pi.sendUserMessage(startMessage);
8507
+ if (context.sessionManager.getSessionId() !== sessionID) {
8508
+ startHandedOver = true;
8509
+ return;
8510
+ }
8511
+ if (context.isIdle?.() === false) {
8512
+ pi.sendUserMessage(startMessage, { deliverAs: "followUp" });
8513
+ } else {
8514
+ markStartupTurnSent(sessionID);
8515
+ startTurnSession = sessionID;
8516
+ pi.sendUserMessage(startMessage);
8517
+ }
8451
8518
  startMessageSent = true;
8452
8519
  }
8520
+ await claimEnvoyRole(sessionID, registration.claimToken, envoyContext);
8453
8521
  await subscribeLegionNotice(sessionID, legionControllerNoticeSubject(project), envoyContext, registration.claimToken);
8454
8522
  for (const channel of slackChannels) {
8455
8523
  await subscribeLegionNotice(sessionID, slackMentionTopic(channel), envoyContext, registration.claimToken);
8456
8524
  }
8457
8525
  if (supervised) {
8526
+ markStartupTurnSent(sessionID);
8527
+ startTurnSession = sessionID;
8458
8528
  await client.ready({
8459
8529
  claimToken: registration.claimToken,
8460
8530
  sessionId: sessionID,
@@ -8462,30 +8532,100 @@ function createControllerSession(deps) {
8462
8532
  generation: registration.generation
8463
8533
  });
8464
8534
  }
8465
- mintControllerGrant = () => client.controllerGrant({ sessionId: sessionID, secret: registration.secret });
8466
- controllerSessionID = sessionID;
8467
- controllerTranscript = launched ? sessionFile : undefined;
8535
+ claimComplete = true;
8536
+ claimWidget?.clear();
8537
+ claimWidget = undefined;
8468
8538
  };
8469
8539
  const handleSessionStart = async (context) => {
8470
- if (classifySession(process.env).kind !== "controller")
8540
+ if (!daemonLaunchedController(process.env))
8471
8541
  return;
8472
8542
  const sessionID = context.sessionManager.getSessionId();
8473
8543
  if (controllerSessionID === undefined || controllerSessionID === sessionID) {
8474
8544
  await claim(context);
8475
8545
  }
8476
8546
  };
8547
+ const startCommand = (context) => {
8548
+ noteWait(context);
8549
+ return serialised(async () => {
8550
+ if (!operatorLaunchedController(process.env) || claimComplete) {
8551
+ context.ui.notify("legion: nothing to do: the controller claim already succeeded in this process, or this session claims no controller from this command (a hand-started takeover, or the controller the daemon launched).", "info");
8552
+ return;
8553
+ }
8554
+ if (startMessageSent) {
8555
+ context.ui.notify("legion: this process already sent the start message, but its controller claim did not finish; run /legion-claim-controller to claim the controller role and its wakes again.", "warning");
8556
+ return;
8557
+ }
8558
+ try {
8559
+ await claimOnce(context, { registered: false });
8560
+ } catch (error) {
8561
+ const message = messageFor(error);
8562
+ logger3.error(`[legion] the controller claim failed: ${message}`);
8563
+ context.ui.notify(startMessageSent ? `legion: the controller claim failed after the start message was sent: ${message}. The start turn has the controller's registration; run /legion-claim-controller to claim the controller role and its wakes again.` : `legion: the controller claim failed: ${message}. Run /legion-claim-controller to claim again; it sends the start message once the claim succeeds.`, "warning");
8564
+ }
8565
+ });
8566
+ };
8477
8567
  const reclaimAfterSessionChange = async (context) => {
8478
8568
  if (classifySession(process.env).kind !== "controller")
8479
8569
  return;
8480
- if (context.sessionManager.getSessionId() === controllerSessionID && context.sessionManager.getSessionFile() === controllerTranscript) {
8570
+ if (claimComplete && context.sessionManager.getSessionId() === controllerSessionID && context.sessionManager.getSessionFile() === controllerTranscript) {
8571
+ if (claimWidget !== undefined && claimWidget.session !== controllerSessionID) {
8572
+ claimWidget.clear();
8573
+ claimWidget = undefined;
8574
+ }
8481
8575
  return;
8482
8576
  }
8577
+ const notices = [];
8578
+ let shown = false;
8579
+ const notify = (message, level) => {
8580
+ if (shown)
8581
+ context.ui.notify(message, level);
8582
+ else
8583
+ notices.push({ message, level });
8584
+ };
8585
+ const deferred = Object.create(context, { ui: { value: { notify } } });
8586
+ const progress = { registered: false };
8483
8587
  try {
8484
- await claim(context);
8588
+ noteWait(context);
8589
+ await serialised(async () => {
8590
+ if (startHandedOver) {
8591
+ startHandedOver = false;
8592
+ notify("legion: the session changed while the controller claim ran; the claim for the new session follows and sends the start message there.", "info");
8593
+ }
8594
+ await claimOnce(deferred, progress);
8595
+ });
8485
8596
  } catch (error) {
8486
- context.ui.notify(`legion: re-claiming the controller for this session failed (${messageFor(error)}). Until a re-claim succeeds, controller wakes and merges will not reach this session, and the \`legion\` tool's set_status and its other operations that call the daemon have no controller grant to mint and fail.`, "warning");
8597
+ notify(progress.registered ? `legion: re-claiming the controller for this session failed (${messageFor(error)}). The session keeps its controller registration, so the \`legion\` tool's set_status still works, but until a re-claim succeeds controller wakes and merges will not reach this session.` : `legion: re-claiming the controller for this session failed (${messageFor(error)}). Until a re-claim succeeds, controller wakes and merges will not reach this session, and the \`legion\` tool's set_status and its other operations that call the daemon have no controller grant to mint and fail.`, "warning");
8598
+ } finally {
8599
+ const { ui } = context;
8600
+ if (ui.setWidget === undefined) {
8601
+ context.setTimeout(() => {
8602
+ shown = true;
8603
+ for (const { message, level } of notices.splice(0))
8604
+ ui.notify(message, level);
8605
+ }, 0);
8606
+ } else {
8607
+ shown = true;
8608
+ if (notices.length > 0) {
8609
+ ui.setWidget(CLAIM_WIDGET, notices.splice(0).map(({ message }) => message));
8610
+ claimWidget = {
8611
+ session: context.sessionManager.getSessionId(),
8612
+ clear: () => ui.setWidget?.(CLAIM_WIDGET, undefined)
8613
+ };
8614
+ }
8615
+ }
8487
8616
  }
8488
8617
  };
8618
+ const handleAgentStart = (context) => {
8619
+ const sessionID = context.sessionManager.getSessionId();
8620
+ if (startTurnSession === sessionID) {
8621
+ startTurnSession = undefined;
8622
+ return;
8623
+ }
8624
+ if (claimWidget?.session !== sessionID)
8625
+ return;
8626
+ claimWidget.clear();
8627
+ claimWidget = undefined;
8628
+ };
8489
8629
  const isClaimedSession = (sessionID) => controllerSessionID === sessionID && mintControllerGrant !== undefined;
8490
8630
  const mintGrant = async (sessionID) => {
8491
8631
  if (controllerSessionID !== sessionID || mintControllerGrant === undefined) {
@@ -8493,7 +8633,15 @@ function createControllerSession(deps) {
8493
8633
  }
8494
8634
  return mintControllerGrant();
8495
8635
  };
8496
- return { claim, handleSessionStart, reclaimAfterSessionChange, isClaimedSession, mintGrant };
8636
+ return {
8637
+ claim,
8638
+ handleSessionStart,
8639
+ handleAgentStart,
8640
+ reclaimAfterSessionChange,
8641
+ startCommand,
8642
+ isClaimedSession,
8643
+ mintGrant
8644
+ };
8497
8645
  }
8498
8646
 
8499
8647
  // src/phase-stall.ts
@@ -9004,7 +9152,7 @@ function envoyPluginRefusal() {
9004
9152
  }
9005
9153
  function legionExtension(pi) {
9006
9154
  const instance = randomUUID().slice(0, 8);
9007
- logger3.debug("extension instance loaded", { extension: import.meta.url, instance });
9155
+ logger4.debug("extension instance loaded", { extension: import.meta.url, instance });
9008
9156
  globalThis[LEGION_PLUGIN_LOADED_KEY] = {
9009
9157
  from: import.meta.url,
9010
9158
  envoyInterface: ENVOY_PLUGIN_INTERFACE_VERSION
@@ -9071,7 +9219,7 @@ function legionExtension(pi) {
9071
9219
  return;
9072
9220
  const refusal = envoyPluginRefusal();
9073
9221
  if (refusal !== undefined) {
9074
- logger3.error(refusal);
9222
+ logger4.error(refusal);
9075
9223
  exitProcess(1);
9076
9224
  }
9077
9225
  if (session.kind === "controller") {
@@ -9099,8 +9247,13 @@ function legionExtension(pi) {
9099
9247
  resentNoticeId = recreatedNotice.id;
9100
9248
  return { message: workspaceRecreatedMessage(recreatedNotice) };
9101
9249
  });
9102
- pi.on("agent_start", async () => {
9250
+ pi.on("agent_start", async (_event, context) => {
9103
9251
  recreatedNotice = undefined;
9252
+ clearStartupTurn(context.sessionManager.getSessionId());
9253
+ controllerSession.handleAgentStart(context);
9254
+ });
9255
+ pi.on("session_shutdown", async (_event, context) => {
9256
+ clearStartupTurn(context.sessionManager.getSessionId());
9104
9257
  });
9105
9258
  pi.on("context", async (event) => {
9106
9259
  if (resentNoticeId === undefined)
@@ -9193,6 +9346,12 @@ function legionExtension(pi) {
9193
9346
  await activateLegionTool();
9194
9347
  }
9195
9348
  });
9349
+ pi.registerCommand("legion-controller-start", {
9350
+ description: "Claim the Legion controller and send its start message as the session's first turn; `legion controller start` passes this command on Oh My Pi's command line",
9351
+ handler: async (_args, context) => {
9352
+ await controllerSession.startCommand(context);
9353
+ }
9354
+ });
9196
9355
  }
9197
9356
  export {
9198
9357
  legionExtension as default,
@@ -12,7 +12,7 @@ except a clear defect a role reports ([Issues handed to Legion](#issues-handed-t
12
12
 
13
13
  ## Rules
14
14
 
15
- - Handle no wake until the extension's startup claim of `legion-<project>-controller` succeeded, because without it the daemon does not know you as its controller.
15
+ - Handle no wake until the extension has registered this session with the daemon as the project's controller, at session startup or, in a controller an operator started, from its first command once Oh My Pi has started, because without that registration the daemon does not know you as its controller.
16
16
  - Answer a person's direct message first, in the conversation. A plain user turn that is neither the start message nor a task sent with `legion claims deliver` is a person writing from Dispatch's Agents page.
17
17
  - A direct Dispatch message that decides scope, priority or an owner is a ruling: after you answer it, run the weekly-plan reconciliation (`skill://legion-controller/references/backlog-stewardship.md`), because the plan must record the rule.
18
18
  - Handle one wake per turn, then end the turn. Never poll or wait for an event, because the daemon wakes you again (`tick` at least every `controller_wake_interval_seconds`).
@@ -30,9 +30,9 @@ unassigned.
30
30
 
31
31
  Each time you read it:
32
32
 
33
- - `Status: done` means someone closed it, and Dispatch then refuses an ask or a message there
34
- (`409 ISSUE_CLOSED`) and hides its open asks. Reopen it with `set_status` to `icebox` and say so
35
- in the day's report.
33
+ - `Status: done` means someone closed it: the close withdrew its open asks (each recorded
34
+ `withdrawn`; a reopen brings them back), and Dispatch refuses an ask or a message there
35
+ (`409 ISSUE_CLOSED`). Reopen it with `set_status` to `icebox` and say so in the day's report.
36
36
  - `Assignee: unassigned` with a person known: assign it, naming your session (`dispatch whoami`):
37
37
  `curl -sS -X PATCH -H "Authorization: Bearer $(cat "$DISPATCH_TOKEN_FILE")" -H 'Content-Type: application/json' -d '{"assignee": "<email>", "actor": {"kind": "session", "id": "<session id>"}}' "$DISPATCH_URL/api/v1/issues/<REPORT KEY>"`.
38
38
  An answer that does not print the issue with its new `assignee` left the assignee as it was
@@ -83,8 +83,8 @@ environment, then run:
83
83
  It checks `LEGION_PROJECT` against the daemon, registers the session, claims the Envoy role, and
84
84
  moves the daemon's recorded controller session id to this session; the `legion` tool's controller
85
85
  operations then work there. Never pass the secret as a command argument or copy it into a
86
- transcript, because others read transcripts. Do not set `LEGION_CONTROLLER=1`, because a session
87
- carrying that marker claims at startup as the launched controller. Run `/legion-claim-controller`
86
+ transcript, because others read transcripts. Do not set `LEGION_CONTROLLER=1`: that marker makes
87
+ the session the launched controller, which needs a start message. Run `/legion-claim-controller`
88
88
  once per session; do not run it again after an Envoy listener restart, because the heartbeat
89
89
  re-claims the role.
90
90
 
@@ -118,9 +118,9 @@ An ask goes on the controller's report issue whenever it is open: the issue the
118
118
  instructions name for Legion's reports, else the project's `Legion daily report`, which
119
119
  `dispatch search --query '"Legion daily report"' --project <PROJECT>` finds. It stays open in
120
120
  `icebox`, so the ask stays answerable whatever happens to the issue it came from; once an issue
121
- closes, Dispatch takes its open asks out of the Inbox and refuses their answers
122
- (`409 ISSUE_CLOSED`). The question names the issue the finding came from by its `dispatch://`
123
- reference.
121
+ closes, its close withdraws its open asks (a reopen brings them back), and Dispatch refuses their
122
+ answers (`409 ISSUE_CLOSED`). The question names the issue the finding came from by its
123
+ `dispatch://` reference.
124
124
 
125
125
  When the project has no report issue yet, or a person closed it (`dispatch read` shows `Status:
126
126
  done`, and Dispatch refuses the ask with `409 ISSUE_CLOSED`), do not create or reopen one yourself
@@ -289,10 +289,11 @@ reviewing until you finish.
289
289
  repository's deploy workflow (`projects.<KEY>.deploys`), at each completed run of that workflow
290
290
  on the default branch whose named step (such as `Apply`) succeeded after the merge, once per such
291
291
  job; or, for `sjawhar/legion`, on a Legion daemon's boot on a build containing the merge, on
292
- Dispatch's `server.started` naming such a build, or on the build the daemon read from Dispatch at
293
- its boot. The woken look's task names the event (`Dispatch's issue.deployed: run <id> completed <t>`,
292
+ Dispatch's `server.started` announcing that every server process serves such a build, or on the
293
+ build the daemon read from Dispatch at its boot. The woken look's task names the event
294
+ (`Dispatch's issue.deployed: run <id> completed <t>`,
294
295
  `the deploy workflow's run: <repo> <workflow> run <id> attempt <n>, job <job name>: <step> succeeded at <t>`,
295
- `the Legion daemon's boot on build <sha>`, `Dispatch's start on build <sha>`,
296
+ `the Legion daemon's boot on build <sha>`, `Dispatch serving build <sha> on every server process`,
296
297
  `Dispatch's build <sha>`), and its `Production:` line names it too, so the pull request carries
297
298
  the cause. Never poll for the deploy, never wait on a reply, and never ask anyone for approval
298
299
  to deploy. Open a `dispatch ask` to a person only when the instructions name nobody for that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion",
3
- "version": "11.6.0",
3
+ "version": "12.1.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "omp": {
@@ -12,7 +12,7 @@
12
12
  ]
13
13
  },
14
14
  "legion": {
15
- "daemonApiVersion": 40
15
+ "daemonApiVersion": 41
16
16
  },
17
17
  "repository": {
18
18
  "type": "git",
@@ -27,7 +27,7 @@
27
27
  "prepack": "../../scripts/pi-plugin-prepack.sh",
28
28
  "postpack": "rm -rf dist/skills",
29
29
  "typecheck": "bunx tsc --noEmit",
30
- "test": "bun test",
30
+ "test": "bun test --path-ignore-patterns=extensions/controller-start-omp.test.ts && bun test ./extensions/controller-start-omp.test.ts",
31
31
  "lint": "bunx biome lint extensions/ src/"
32
32
  },
33
33
  "devDependencies": {