@sjawhar/pi-legion-envoy 1.67.0 → 1.68.1

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
@@ -30502,12 +30502,6 @@ var phaseHandoffSchema = exports_external.discriminatedUnion("phase", [
30502
30502
  testSchema,
30503
30503
  reviewSchema
30504
30504
  ]);
30505
- var handoffMessageSchema = exports_external.object({
30506
- from: handoffPhase,
30507
- to: handoffPhase,
30508
- body: exports_external.string(),
30509
- timestamp: isoTimestamp
30510
- });
30511
30505
  // ../contracts/src/subject.ts
30512
30506
  var AGENT_TOPIC_PREFIX = "notifications.agent.";
30513
30507
  var ROLE_TOPIC_PREFIX = "notifications.role.";
package/dist/legion.js CHANGED
@@ -16142,7 +16142,7 @@ import { logger } from "@oh-my-pi/pi-utils";
16142
16142
  // package.json
16143
16143
  var package_default = {
16144
16144
  name: "@sjawhar/pi-legion-envoy",
16145
- version: "1.67.0",
16145
+ version: "1.68.1",
16146
16146
  type: "module",
16147
16147
  omp: {
16148
16148
  extensions: [
@@ -16155,7 +16155,7 @@ var package_default = {
16155
16155
  },
16156
16156
  legion: {
16157
16157
  daemonApiVersion: 8,
16158
- goDaemonApiVersion: 3
16158
+ goDaemonApiVersion: 4
16159
16159
  },
16160
16160
  repository: {
16161
16161
  type: "git",
@@ -30574,12 +30574,6 @@ var phaseHandoffSchema = exports_external.discriminatedUnion("phase", [
30574
30574
  testSchema,
30575
30575
  reviewSchema
30576
30576
  ]);
30577
- var handoffMessageSchema = exports_external.object({
30578
- from: handoffPhase,
30579
- to: handoffPhase,
30580
- body: exports_external.string(),
30581
- timestamp: isoTimestamp
30582
- });
30583
30577
  // ../contracts/src/subject.ts
30584
30578
  var AGENT_TOPIC_PREFIX = "notifications.agent.";
30585
30579
  var ROLE_TOPIC_PREFIX = "notifications.role.";
@@ -31238,42 +31232,47 @@ function onEnvoyRoleRegained(listener) {
31238
31232
  }
31239
31233
 
31240
31234
  // src/legion/controller-session.ts
31241
- function createControllerSession(persistedTranscript, checkSubagentSession, rerunReadyAfterRegain, pluginVersion) {
31235
+ function typescriptControllerDaemon(rerunReadyAfterRegain, pluginVersion) {
31236
+ let daemon;
31237
+ const client = () => {
31238
+ daemon ??= createLegionDaemonClient(requiredEnvironment(process.env, "LEGION_DAEMON_URL"));
31239
+ return daemon;
31240
+ };
31241
+ return {
31242
+ claim: async ({ sessionID, capability, ompSessionFile, context }) => {
31243
+ const daemon2 = client();
31244
+ const { project } = await daemon2.state();
31245
+ const token = controllerToken(project);
31246
+ await claimEnvoyRole(sessionID, token, "setInterval" in context ? context : undefined);
31247
+ const ready = {
31248
+ secret: capability,
31249
+ sessionId: sessionID,
31250
+ ...ompSessionFile === undefined ? {} : { ompSessionFile },
31251
+ pluginVersion
31252
+ };
31253
+ await daemon2.controllerReady(ready);
31254
+ onEnvoyRoleRegained(async (role, reason) => {
31255
+ if (role !== token)
31256
+ return;
31257
+ await rerunReadyAfterRegain("controller/ready", role, reason, () => daemon2.controllerReady(ready));
31258
+ });
31259
+ return () => daemon2.grant({ sessionId: sessionID, secret: capability });
31260
+ }
31261
+ };
31262
+ }
31263
+ function createControllerSession(persistedTranscript, checkSubagentSession, daemon) {
31242
31264
  let controllerSessionID;
31243
31265
  let controllerCapability;
31244
- let controllerDaemon;
31245
- const controllerDaemonClient = () => {
31246
- controllerDaemon ??= createLegionDaemonClient(requiredEnvironment(process.env, "LEGION_DAEMON_URL"));
31247
- return controllerDaemon;
31248
- };
31266
+ let mintControllerGrant;
31249
31267
  let controllerTranscript;
31250
31268
  const claim = async (context) => {
31251
31269
  const sessionID = context.sessionManager.getSessionId();
31252
- const daemon = controllerDaemonClient();
31253
- const secret = controllerCapability ?? requiredControllerCapability(process.env);
31254
- controllerCapability = secret;
31255
- const { project } = await daemon.state();
31256
- const token = controllerToken(project);
31257
- await claimEnvoyRole(sessionID, token, "setInterval" in context ? context : undefined);
31270
+ const capability = controllerCapability ?? requiredControllerCapability(process.env);
31271
+ controllerCapability = capability;
31258
31272
  const ompSessionFile = classifySession(process.env).kind === "controller" ? (await persistedTranscript(context)).sessionFile : undefined;
31259
- await daemon.controllerReady({
31260
- secret,
31261
- sessionId: sessionID,
31262
- ...ompSessionFile === undefined ? {} : { ompSessionFile },
31263
- pluginVersion
31264
- });
31273
+ mintControllerGrant = await daemon().claim({ sessionID, capability, ompSessionFile, context });
31265
31274
  controllerSessionID = sessionID;
31266
31275
  controllerTranscript = ompSessionFile;
31267
- onEnvoyRoleRegained(async (role, reason) => {
31268
- if (role !== token)
31269
- return;
31270
- await rerunReadyAfterRegain("controller/ready", role, reason, () => daemon.controllerReady({
31271
- secret,
31272
- sessionId: sessionID,
31273
- ...controllerTranscript === undefined ? {} : { ompSessionFile: controllerTranscript },
31274
- pluginVersion
31275
- }));
31276
- });
31277
31276
  };
31278
31277
  const handleSessionStart = async (context) => {
31279
31278
  if (classifySession(process.env).kind !== "controller")
@@ -31297,13 +31296,12 @@ function createControllerSession(persistedTranscript, checkSubagentSession, reru
31297
31296
  context.ui.notify(`legion: re-claiming the controller for this session failed (${messageFor(error48)}). Until a re-claim succeeds, controller wakes and merges will not reach this session and its shell commands run without a Legion grant, so legion gh is unavailable.`, "warning");
31298
31297
  }
31299
31298
  };
31300
- const isClaimedSession = (sessionID) => controllerSessionID === sessionID && controllerCapability !== undefined;
31299
+ const isClaimedSession = (sessionID) => controllerSessionID === sessionID && mintControllerGrant !== undefined;
31301
31300
  const mintGrant = async (sessionID) => {
31302
- if (controllerSessionID !== sessionID || controllerCapability === undefined) {
31301
+ if (controllerSessionID !== sessionID || mintControllerGrant === undefined) {
31303
31302
  throw new Error("Controller session is not registered; cannot mint its grant");
31304
31303
  }
31305
- const secret = controllerCapability;
31306
- return controllerDaemonClient().grant({ sessionId: sessionID, secret });
31304
+ return mintControllerGrant();
31307
31305
  };
31308
31306
  return { claim, handleSessionStart, reclaimAfterSessionChange, isClaimedSession, mintGrant };
31309
31307
  }
@@ -31436,11 +31434,18 @@ var legionGoAdmission = exports_external.strictObject({
31436
31434
  active: exports_external.array(nonEmptyString2),
31437
31435
  waiting: exports_external.array(nonEmptyString2)
31438
31436
  });
31437
+ var legionGoControllerLocator = exports_external.strictObject({
31438
+ runtime: exports_external.enum(["tmux", "kubernetes"]),
31439
+ external: exports_external.literal(true),
31440
+ sessionId: nonEmptyString2,
31441
+ registeredAt: timestamp
31442
+ });
31439
31443
  var LegionGoStateResponse = exports_external.strictObject({
31440
31444
  daemon: goDaemonInfo,
31441
31445
  admission: legionGoAdmission,
31442
31446
  issues: exports_external.record(exports_external.string(), legionGoIssue),
31443
- pendingStatusWrites: exports_external.array(legionGoPendingStatusWrite)
31447
+ pendingStatusWrites: exports_external.array(legionGoPendingStatusWrite),
31448
+ controllerLocator: legionGoControllerLocator.optional()
31444
31449
  });
31445
31450
  var LegionGoRegisterResponse = exports_external.strictObject({
31446
31451
  claimToken: nonEmptyString2,
@@ -31450,6 +31455,15 @@ var LegionGoRegisterResponse = exports_external.strictObject({
31450
31455
  generation: exports_external.number().int().positive(),
31451
31456
  secret: nonEmptyString2
31452
31457
  });
31458
+ var LegionGoControllerRegisterResponse = exports_external.strictObject({
31459
+ claimToken: nonEmptyString2,
31460
+ role: exports_external.literal("controller"),
31461
+ generation: exports_external.number().int().positive(),
31462
+ secret: nonEmptyString2
31463
+ });
31464
+ var LegionGoControllerSecretResponse = exports_external.strictObject({
31465
+ secret: nonEmptyString2
31466
+ });
31453
31467
  var LegionGoErrorResponse = exports_external.union([
31454
31468
  exports_external.strictObject({ error: nonEmptyString2 }),
31455
31469
  exports_external.strictObject({ code: nonEmptyString2, error: nonEmptyString2 })
@@ -31504,6 +31518,10 @@ var LegionGoGrantRequest = exports_external.strictObject({
31504
31518
  tree: nonEmptyString2,
31505
31519
  issue: nonEmptyString2
31506
31520
  });
31521
+ var LegionGoControllerGrantRequest = exports_external.strictObject({
31522
+ sessionId: nonEmptyString2,
31523
+ secret: nonEmptyString2
31524
+ });
31507
31525
  var LegionGoGrantCredentialRequest = exports_external.strictObject({
31508
31526
  grantId: nonEmptyString2
31509
31527
  });
@@ -31624,9 +31642,11 @@ function createLegionGoDaemonClient(baseUrl, fetchFn = fetch) {
31624
31642
  return {
31625
31643
  state: () => read("GET", "/legion/v1/state", LegionGoStateResponse),
31626
31644
  register: (input) => read("POST", "/legion/v1/claims/register", LegionGoRegisterResponse, input),
31645
+ registerController: (input) => read("POST", "/legion/v1/claims/register", LegionGoControllerRegisterResponse, input),
31627
31646
  ready: (input) => acknowledge("/legion/v1/claims/ready", input),
31628
31647
  exit: (input) => acknowledge("/legion/v1/claims/exit", input),
31629
31648
  grant: (input) => post("/legion/v1/grants", LegionGoGrantRequest, LegionGoGrantResponse, input),
31649
+ controllerGrant: (input) => post("/legion/v1/grants", LegionGoControllerGrantRequest, LegionGoGrantResponse, input),
31630
31650
  githubToken: (input) => post("/legion/v1/gh-token", LegionGoGrantCredentialRequest, LegionGoGitHubTokenResponse, input),
31631
31651
  gitCredential: (input) => post("/legion/v1/git-credential", LegionGoGrantCredentialRequest, LegionGoGitCredentialResponse, input),
31632
31652
  provisioningCredential: (input) => post("/legion/v1/provisioning-credential", LegionGoGrantCredentialRequest, LegionGoGitHubTokenResponse, input),
@@ -31726,13 +31746,31 @@ async function callGoReadyWithRetry(label, call) {
31726
31746
  }
31727
31747
  }
31728
31748
  }
31749
+ function goControllerDaemon(daemon, transcript) {
31750
+ return {
31751
+ claim: async ({ sessionID, capability, context }) => {
31752
+ const { sessionFile, agentId } = await transcript(context);
31753
+ const client = daemon();
31754
+ const registration = await client.registerController({
31755
+ bootToken: capability,
31756
+ sessionId: sessionID,
31757
+ ompSessionFile: sessionFile,
31758
+ agentId,
31759
+ pluginContract: package_default.legion.goDaemonApiVersion
31760
+ }).catch((error48) => {
31761
+ if (error48 instanceof LegionGoDaemonApiError) {
31762
+ console.error(`[legion] claims/register for the controller failed (${error48.status}): ${error48.detail}`);
31763
+ }
31764
+ throw error48;
31765
+ });
31766
+ await claimEnvoyRole(sessionID, registration.claimToken, "setInterval" in context ? context : undefined);
31767
+ return () => client.controllerGrant({ sessionId: sessionID, secret: registration.secret });
31768
+ }
31769
+ };
31770
+ }
31729
31771
  async function bootstrapGoClaim(context, state) {
31730
- const classification = classifySession(process.env);
31731
- if (classification.kind === "not-legion")
31772
+ if (classifySession(process.env).kind === "not-legion")
31732
31773
  return;
31733
- if (classification.kind === "controller") {
31734
- throw new Error("LEGION_DAEMON_API=go names the Go daemon, which launches no controller session");
31735
- }
31736
31774
  const sessionID = context.sessionManager.getSessionId();
31737
31775
  const capability = state.capability();
31738
31776
  if (capability !== undefined && capability.sessionID !== sessionID)
@@ -31811,7 +31849,6 @@ import { execFile as execFile2 } from "child_process";
31811
31849
  var HANDOFF_OPERATION_FIELDS = {
31812
31850
  handoff_write: ["phase", "data"],
31813
31851
  handoff_read: ["phase"],
31814
- handoff_message: ["sender", "recipient", "body"],
31815
31852
  handoff_complete: ["summary", "verdict", "ready", "phase"]
31816
31853
  };
31817
31854
  var HANDOFF_OPERATIONS = Object.keys(HANDOFF_OPERATION_FIELDS);
@@ -31822,15 +31859,12 @@ function handoffSchemaFields(z2) {
31822
31859
  return {
31823
31860
  phase: z2.enum(HANDOFF_PHASES).optional(),
31824
31861
  data: z2.unknown().optional(),
31825
- sender: z2.enum(HANDOFF_PHASES).optional(),
31826
- recipient: z2.enum(HANDOFF_PHASES).optional(),
31827
- body: z2.string().optional(),
31828
31862
  summary: z2.string().optional(),
31829
31863
  verdict: z2.enum(["pass", "fail"]).optional(),
31830
31864
  ready: z2.boolean().optional()
31831
31865
  };
31832
31866
  }
31833
- var HANDOFF_DESCRIPTION = "Handoff actions (phase workers and sub-architects): handoff_write writes this phase's handoff " + "(`phase`, and `data`: the phase-specific fields as a JSON object) to `.legion/<phase>.json` in " + "the issue workspace; handoff_read returns the handoffs (every phase, or `phase`); " + "handoff_message leaves a message for another phase (`sender`, `recipient`, `body`); " + "handoff_complete reports this phase complete to the daemon (`summary`: two sentences for the " + "architect; `verdict` pass|fail when your role's instructions require one; `ready: true` for " + "the merger's READY; no `phase` is needed, and one other than your own is refused). Each " + "returns the command's output; a failed action changed nothing.";
31867
+ var HANDOFF_DESCRIPTION = "Handoff actions (phase workers and sub-architects): handoff_write writes this phase's handoff " + "(`phase`, and `data`: the phase-specific fields as a JSON object) to `.legion/<phase>.json` in " + "the issue workspace; handoff_read returns the handoffs (every phase, or `phase`); " + "handoff_complete reports this phase complete to the daemon (`summary`: two sentences for the " + "architect; `verdict` pass|fail when your role's instructions require one; `ready: true` for " + "the merger's READY; no `phase` is needed, and one other than your own is refused). Each " + "returns the command's output; a failed action changed nothing. What a later phase needs goes " + "in your handoff; a question for another live role goes to its role topic with envoy_publish.";
31834
31868
  var ROLE_HANDOFF_PHASE = {
31835
31869
  architect: "architect",
31836
31870
  planner: "plan",
@@ -31864,21 +31898,12 @@ function commandArguments(operation, parameters) {
31864
31898
  throw new Error("handoff_write requires data: the phase's handoff fields as a JSON object");
31865
31899
  }
31866
31900
  const phase = required2(parameters, operation, "phase");
31867
- return ["handoff", "write", "--phase", phase, "--data", JSON.stringify(data)];
31901
+ return { args: ["handoff", "write", "--phase", phase], stdin: JSON.stringify(data) };
31868
31902
  }
31869
31903
  case "handoff_read":
31870
- return parameters.phase === undefined ? ["handoff", "read"] : ["handoff", "read", "--phase", required2(parameters, operation, "phase")];
31871
- case "handoff_message":
31872
- return [
31873
- "handoff",
31874
- "message",
31875
- "--from",
31876
- required2(parameters, operation, "sender"),
31877
- "--to",
31878
- required2(parameters, operation, "recipient"),
31879
- "--body",
31880
- required2(parameters, operation, "body")
31881
- ];
31904
+ return {
31905
+ args: parameters.phase === undefined ? ["handoff", "read"] : ["handoff", "read", "--phase", required2(parameters, operation, "phase")]
31906
+ };
31882
31907
  case "handoff_complete": {
31883
31908
  const command = [
31884
31909
  "handoff",
@@ -31908,13 +31933,13 @@ function commandArguments(operation, parameters) {
31908
31933
  }
31909
31934
  if (ready === true)
31910
31935
  command.push("--ready");
31911
- return command;
31936
+ return { args: command };
31912
31937
  }
31913
31938
  }
31914
31939
  }
31915
- function runLegion(args, cwd, signal) {
31940
+ function runLegion(args, stdin, cwd, signal) {
31916
31941
  const { promise: promise2, resolve, reject } = Promise.withResolvers();
31917
- execFile2("legion", args, { cwd, env: process.env, signal, timeout: COMMAND_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, (error48, stdout, stderr) => {
31942
+ const child = execFile2("legion", args, { cwd, env: process.env, signal, timeout: COMMAND_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, (error48, stdout, stderr) => {
31918
31943
  const output = [stdout, stderr].map((text) => text.trimEnd()).filter((text) => text !== "").join(`
31919
31944
  `);
31920
31945
  if (error48 === null) {
@@ -31925,13 +31950,18 @@ function runLegion(args, cwd, signal) {
31925
31950
  reject(new Error(`legion ${args.slice(0, 2).join(" ")} did not run: ${messageFor(error48)}`));
31926
31951
  }
31927
31952
  });
31953
+ child.stdin?.on("error", (error48) => {
31954
+ if (error48.code !== "EPIPE")
31955
+ reject(error48);
31956
+ });
31957
+ child.stdin?.end(stdin ?? "");
31928
31958
  return promise2;
31929
31959
  }
31930
31960
  async function runHandoffAction(input) {
31931
- const args = commandArguments(input.operation, input.parameters);
31961
+ const { args, stdin } = commandArguments(input.operation, input.parameters);
31932
31962
  if (input.operation === "handoff_complete")
31933
31963
  await writeMintedGrant(input.mintGrant);
31934
- const { exitCode, output } = await runLegion(args, requiredEnvironment(process.env, "LEGION_WORKSPACE"), input.signal);
31964
+ const { exitCode, output } = await runLegion(args, stdin, requiredEnvironment(process.env, "LEGION_WORKSPACE"), input.signal);
31935
31965
  const text = output === "" ? `legion ${args.slice(0, 2).join(" ")} exited ${exitCode}` : output;
31936
31966
  const details = { operation: input.operation, exitCode };
31937
31967
  if (exitCode !== 0)
@@ -32467,7 +32497,7 @@ function jjLogRewriteMention(text) {
32467
32497
  if (!JJ_MENTION.test(text))
32468
32498
  return;
32469
32499
  const match = JJ_LOG_REWRITE_MENTION.exec(text);
32470
- return match === null ? undefined : match[0].replace(/\W+/g, " ");
32500
+ return match === null ? undefined : `jj ${match[0].replace(/\W+/g, " ")}`;
32471
32501
  }
32472
32502
  function splitShellCommands(command) {
32473
32503
  const commands = [];
@@ -32530,29 +32560,60 @@ function splitShellCommands(command) {
32530
32560
  endCommand();
32531
32561
  return commands;
32532
32562
  }
32533
- function jjLogRewriteInvocation(command) {
32534
- const commands = splitShellCommands(command);
32563
+ function jjLogRewriteInvocation(words) {
32564
+ const jj = words.findIndex((word) => word === "jj" || word.endsWith("/jj"));
32565
+ if (jj === -1)
32566
+ return;
32567
+ const args = words.slice(jj + 1);
32568
+ const rewritesLog = args.some((arg) => JJ_LOG_REWRITE_WORDS.includes(arg)) || args.some((arg) => JJ_OP_WORDS.includes(arg)) && args.some((arg) => JJ_OP_LOG_REWRITE_WORDS.includes(arg));
32569
+ return rewritesLog ? words.slice(jj).join(" ") : undefined;
32570
+ }
32571
+ var JJ_LOG_REWRITE = {
32572
+ mention: jjLogRewriteMention,
32573
+ invocation: jjLogRewriteInvocation,
32574
+ refusal: (attempt) => `refused \`${attempt}\`: jj undo, jj abandon, and jj op restore/revert/abandon/undo ` + "rewrite the jj operation log, which every Legion issue workspace shares (each is a jj " + "workspace of one clone), so they rewrite other trees' commits too. Recover forward with " + "a new commit or `jj restore <paths>` of files; anything else, stop and send the owning " + 'architect the `jj -R "$LEGION_WORKSPACE" log` evidence.'
32575
+ };
32576
+ var LEGION_HANDOFF_COMPLETE_MENTION = /\blegion[\s"'`,[\]]+handoff[\s"'`,[\]]+complete\b/;
32577
+ var LEGION_HANDOFF_COMPLETE = {
32578
+ mention: (text) => LEGION_HANDOFF_COMPLETE_MENTION.test(text) ? "legion handoff complete" : undefined,
32579
+ invocation: (words) => {
32580
+ const legion = words.findIndex((word, index) => (word === "legion" || word.endsWith("/legion")) && words[index + 1] === "handoff" && words[index + 2] === "complete");
32581
+ return legion === -1 ? undefined : words.slice(legion).join(" ");
32582
+ },
32583
+ refusal: (attempt) => `refused \`${attempt}\`: a phase is completed with the \`legion\` tool's ` + "`handoff_complete`, never a shell command: the tool call is what records the phase " + "complete. A root architect or a `task` subagent has no handoff actions: it leaves the " + "completion to the worker. Text that only names the command (a commit message, a PR body) " + "reads as the command: pass it in a file."
32584
+ };
32585
+ var PANE_RULES = {
32586
+ "phase-worker": [JJ_LOG_REWRITE, LEGION_HANDOFF_COMPLETE],
32587
+ "root-architect": [LEGION_HANDOFF_COMPLETE]
32588
+ };
32589
+ function refusedCommand(command, commands, rule) {
32535
32590
  if (commands === undefined) {
32536
- return jjLogRewriteMention(command) === undefined ? undefined : command.trim();
32591
+ return rule.mention(command) === undefined ? undefined : command.trim();
32537
32592
  }
32538
32593
  for (const words of commands) {
32539
- const mentioned = words.find((word) => jjLogRewriteMention(word) !== undefined);
32594
+ const mentioned = words.find((word) => rule.mention(word) !== undefined);
32540
32595
  if (mentioned !== undefined)
32541
32596
  return mentioned;
32542
- const jj = words.findIndex((word) => word === "jj" || word.endsWith("/jj"));
32543
- if (jj === -1)
32544
- continue;
32545
- const args = words.slice(jj + 1);
32546
- const rewritesLog = args.some((arg) => JJ_LOG_REWRITE_WORDS.includes(arg)) || args.some((arg) => JJ_OP_WORDS.includes(arg)) && args.some((arg) => JJ_OP_LOG_REWRITE_WORDS.includes(arg));
32547
- if (rewritesLog)
32548
- return words.slice(jj).join(" ");
32597
+ const invocation = rule.invocation(words);
32598
+ if (invocation !== undefined)
32599
+ return invocation;
32549
32600
  }
32550
32601
  return;
32551
32602
  }
32552
- function jjLogRewriteAttempt(toolCall) {
32603
+ function paneRuleRefusal(toolCall, rules) {
32604
+ if (rules.length === 0)
32605
+ return;
32553
32606
  const { toolName, input } = toolCall;
32554
32607
  if (toolName === "bash") {
32555
- return typeof input.command === "string" ? jjLogRewriteInvocation(input.command) : undefined;
32608
+ if (typeof input.command !== "string")
32609
+ return;
32610
+ const commands = splitShellCommands(input.command);
32611
+ for (const rule of rules) {
32612
+ const attempt = refusedCommand(input.command, commands, rule);
32613
+ if (attempt !== undefined)
32614
+ return rule.refusal(attempt);
32615
+ }
32616
+ return;
32556
32617
  }
32557
32618
  let text;
32558
32619
  if (toolName === "eval" && typeof input.code === "string")
@@ -32561,8 +32622,12 @@ function jjLogRewriteAttempt(toolCall) {
32561
32622
  text = [input.application, ...Array.isArray(input.args) ? input.args : [], input.text].filter((part) => typeof part === "string").join(" ");
32562
32623
  } else
32563
32624
  return;
32564
- const mention = jjLogRewriteMention(text);
32565
- return mention === undefined ? undefined : `${toolName}: jj ${mention}`;
32625
+ for (const rule of rules) {
32626
+ const mention = rule.mention(text);
32627
+ if (mention !== undefined)
32628
+ return rule.refusal(`${toolName}: ${mention}`);
32629
+ }
32630
+ return;
32566
32631
  }
32567
32632
  var LEGION_LOADED_MARKER = Symbol.for("legion.pi-envoy.legion-loaded");
32568
32633
  var CODE_MUTATION_TOOLS = ["edit", "write", "apply_patch"];
@@ -32581,7 +32646,7 @@ function legionExtension(pi) {
32581
32646
  let capability;
32582
32647
  let bootstrap;
32583
32648
  const checkSubagentSession = subagentSessionCheck();
32584
- let phaseWorkerPane;
32649
+ let paneRules;
32585
32650
  let phaseStall = "closed";
32586
32651
  const phaseWorkerSession = (context) => capability !== undefined && capability.sessionID === context.sessionManager.getSessionId() && capability.role !== "architect";
32587
32652
  const advancePhaseStall = (input) => {
@@ -32626,11 +32691,13 @@ function legionExtension(pi) {
32626
32691
  console.error(`[legion] ${endpoint} after role ${role} was ${reason} failed; the daemon's held work stays undelivered until the next regain or boot: ${messageFor(error48)}`);
32627
32692
  }
32628
32693
  };
32694
+ const typescriptController = typescriptControllerDaemon(rerunReadyAfterRegain, package_default.version);
32695
+ const goController = goControllerDaemon(goRoleDaemon, persistedTranscript);
32629
32696
  const controllerSession = createControllerSession(async (context) => {
32630
32697
  const persisted = await persistedTranscript(context);
32631
32698
  recordBootstrappedSession(persisted.sessionFile);
32632
32699
  return persisted;
32633
- }, checkSubagentSession, rerunReadyAfterRegain, package_default.version);
32700
+ }, checkSubagentSession, () => process.env.LEGION_DAEMON_API === "go" ? goController : typescriptController);
32634
32701
  const reclaimArchitect = async () => {
32635
32702
  if (capability === undefined || capability.kind !== "root-architect") {
32636
32703
  throw new Error("Legion root architect is not available for reclamation");
@@ -32828,6 +32895,10 @@ function legionExtension(pi) {
32828
32895
  return;
32829
32896
  phaseStall = restorePhaseStall(context.sessionManager.getBranch?.() ?? []);
32830
32897
  if (process.env.LEGION_DAEMON_API === "go") {
32898
+ if (classifySession(process.env).kind === "controller") {
32899
+ await controllerSession.handleSessionStart(context);
32900
+ return;
32901
+ }
32831
32902
  await bootstrapGoClaim(context, {
32832
32903
  capability: () => capability,
32833
32904
  setCapability: (next) => {
@@ -32870,16 +32941,10 @@ function legionExtension(pi) {
32870
32941
  toolCallId: toolCall.toolCallId,
32871
32942
  toolName: toolCall.toolName
32872
32943
  });
32873
- phaseWorkerPane ??= classifySession(process.env).kind === "phase-worker";
32874
- if (phaseWorkerPane) {
32875
- const jjAttempt = jjLogRewriteAttempt(toolCall);
32876
- if (jjAttempt !== undefined) {
32877
- return {
32878
- block: true,
32879
- reason: `refused \`${jjAttempt}\`: jj undo, jj abandon, and jj op restore/revert/abandon/undo ` + "rewrite the jj operation log, which every Legion issue workspace shares (each is a jj " + "workspace of one clone), so they rewrite other trees' commits too. Recover forward with " + "a new commit or `jj restore <paths>` of files; anything else, stop and send the owning " + 'architect the `jj -R "$LEGION_WORKSPACE" log` evidence.'
32880
- };
32881
- }
32882
- }
32944
+ paneRules ??= PANE_RULES[classifySession(process.env).kind] ?? [];
32945
+ const refusal = paneRuleRefusal(toolCall, paneRules);
32946
+ if (refusal !== undefined)
32947
+ return { block: true, reason: refusal };
32883
32948
  if (await checkSubagentSession(context))
32884
32949
  return;
32885
32950
  const sessionID = context.sessionManager.getSessionId();
@@ -59,23 +59,26 @@ command run `/legion-claim-controller` again.
59
59
  This handshake lets the daemon redeliver held controller work. It does not turn the controller
60
60
  into a state holder: daemon state and the Dispatch project remain authoritative.
61
61
 
62
- ### Started by the operator (runtime: kubernetes)
63
-
64
- When the daemon runs inside a Kubernetes cluster it cannot open a terminal anywhere, so nobody
65
- launched your pane: the operator ran `legion controller start --config controller.yaml
66
- [--daemon-url <port-forward>]` on their own machine, and you are that foreground OMP session.
67
- The command fetched a fresh controller secret from the daemon with the operator's token, wrote it
68
- to a 0600 file under `LEGION_STATE_DIR` (`~/.local/state/legion/<project>-controller` by default)
69
- beside the `gh` shim and the `legion` launcher, and started you with `LEGION_CONTROLLER=1` and
70
- the same environment a tmux controller pane carries — so the extension claims the role and calls
71
- `/controller/ready` exactly as under tmux, and nothing changes in how you handle wakes. The
72
- daemon records you as `controllerLocator: {runtime: "kubernetes", external: true, sessionId,
73
- registeredAt}` and reads your liveness from the Envoy role registry (the holder of
62
+ ### Started by the operator
63
+
64
+ When the daemon cannot open a terminal for you — the TypeScript daemon under `runtime: kubernetes`,
65
+ and the Go daemon under either runtime, since it launches no controller — nobody launched your
66
+ pane: the operator ran `legion controller start --config controller.yaml [--daemon-url <url>]` on
67
+ their own machine, and you are that foreground OMP session. The command fetched a fresh controller
68
+ secret from the daemon with the operator's token, wrote it to a 0600 file under `LEGION_STATE_DIR`
69
+ (`~/.local/state/legion/<project>-controller` by default) beside the `gh` shim and the `legion`
70
+ launcher, and started you with `LEGION_CONTROLLER=1` and the same environment a tmux controller pane
71
+ carries, so nothing changes in how you handle wakes. Under the TypeScript daemon the extension
72
+ claims the role and calls `/controller/ready` exactly as under tmux; under the Go daemon
73
+ (`LEGION_DAEMON_API=go` in your environment) it registers on `/legion/v1/claims/register` with the
74
+ secret, then claims the role. The daemon records you as `controllerLocator: {runtime, external:
75
+ true, sessionId, registeredAt}`, `runtime` being the daemon's own (`kubernetes`, or `tmux` under the
76
+ Go daemon). The TypeScript daemon reads your liveness from the Envoy role registry (the holder of
74
77
  `legion-<project>-controller` and its `last_seen`), not from a pane: keep the session running.
75
78
  Exiting it leaves the project without a controller until the operator runs the command again —
76
- the daemon logs `controller not registered; run legion controller start` once per boot-timeout
77
- interval and launches nothing itself. `legion state` and `legion status <KEY> <status>` work here
78
- over `LEGION_DAEMON_URL` (the port-forward). A second `legion controller start` replaces you: it
79
+ the TypeScript daemon logs `controller not registered; run legion controller start` once per
80
+ boot-timeout interval and launches nothing itself. `legion state` and `legion status <KEY>
81
+ <status>` work here over `LEGION_DAEMON_URL`. A second `legion controller start` replaces you: it
79
82
  mints a new secret, so your grants stop working and the role moves to the new session.
80
83
 
81
84
  ## Deployment instructions
@@ -507,6 +507,14 @@ Write the phase-specific handoff: call the `legion` tool with `op: "handoff_writ
507
507
  and `data`: a JSON object of the phase-specific fields only. It runs `legion handoff write` in
508
508
  `$LEGION_WORKSPACE` and returns its output.
509
509
 
510
+ A handoff built from the one already on disk (a test handoff that accumulates review rounds can
511
+ pass 128 KiB) can instead be piped from bash, so you never re-emit the whole payload:
512
+ `cd -- "$LEGION_WORKSPACE" && bun -e 'const h = await Bun.file(".legion/<phase>.json").json(); delete h.schemaVersion; delete h.phase; delete h.completed; <your edit to h>; console.log(JSON.stringify(h))' | legion handoff write --phase <phase>`.
513
+ The program is single-quoted, so strings in your edit take double quotes. It is `bun` because the
514
+ worker image a pod runs ships `bun` and not `jq`, and a devbox pane has the `bun` Legion builds
515
+ with. With `--data` omitted, `legion handoff write` reads the JSON object from stdin. The CLI adds
516
+ `schemaVersion`, `phase` and `completed` itself and refuses them in the data, hence the `delete`s.
517
+
510
518
  `handoff_write` validates the payload against the phase's schema before writing: an
511
519
  implement handoff without a well-formed `proof`, or a test handoff that reports no failure and
512
520
  carries no `proof` of its own, exits 1 naming the field and writes nothing.
@@ -558,9 +566,9 @@ approved head and restarts the review loop this rule exists to end.
558
566
  ## Completion: report to the architect, then stay
559
567
 
560
568
  Report completion to the architect: call the `legion` tool with `op: "handoff_complete"` and
561
- `summary`: two sentences for the architect. A worker never runs `legion handoff` from bash: the
562
- tool call is what the extension records, and a turn that ends with the phase still open gets one
563
- reminder.
569
+ `summary`: two sentences for the architect. A worker never runs `legion handoff complete` from
570
+ bash, where the extension refuses it: the tool call is what the extension records, and a turn that
571
+ ends with the phase still open gets one reminder.
564
572
 
565
573
  This publishes your phase's completion to the architect's role and clears the daemon's
566
574
  record of this issue's active phase. Do not add pipeline labels, run a controller loop, or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion-envoy",
3
- "version": "1.67.0",
3
+ "version": "1.68.1",
4
4
  "type": "module",
5
5
  "omp": {
6
6
  "extensions": [
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "legion": {
15
15
  "daemonApiVersion": 8,
16
- "goDaemonApiVersion": 3
16
+ "goDaemonApiVersion": 4
17
17
  },
18
18
  "repository": {
19
19
  "type": "git",