@sjawhar/pi-legion 8.15.0 → 8.16.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/legion.js CHANGED
@@ -13,10 +13,11 @@ var __export = (target, all) => {
13
13
  set: __exportSetter.bind(all, name)
14
14
  });
15
15
  };
16
+ var __require = import.meta.require;
16
17
 
17
18
  // extensions/legion.ts
18
19
  import { randomUUID } from "crypto";
19
- import path4 from "path";
20
+ import path5 from "path";
20
21
 
21
22
  // ../envoy-client/src/dispatch-config.ts
22
23
  import { readFileSync as readFileSync2 } from "fs";
@@ -13559,6 +13560,9 @@ config(en_default());
13559
13560
  function messageFor(error48) {
13560
13561
  return error48 instanceof Error ? error48.message : String(error48);
13561
13562
  }
13563
+ function hasErrnoCode(error48, code) {
13564
+ return error48 instanceof Error && "code" in error48 && error48.code === code;
13565
+ }
13562
13566
 
13563
13567
  // ../envoy-client/src/secret-file.ts
13564
13568
  import { readFileSync } from "fs";
@@ -15219,6 +15223,385 @@ function subagentSessionCheck() {
15219
15223
  // extensions/legion.ts
15220
15224
  import { logger as logger3 } from "@oh-my-pi/pi-utils";
15221
15225
 
15226
+ // src/capability-report.ts
15227
+ import { readdir, stat } from "fs/promises";
15228
+ import path3 from "path";
15229
+ var CAPABILITY_CHECK_TIMEOUT_MS = 8000;
15230
+ var CAPABILITY_REPORT_TIMEOUT_MS = 1e4;
15231
+ var WEB_SEARCH_PROBE_QUERY = "jujutsu version control";
15232
+ var ENVOY_TOOL_NAMES = [
15233
+ "envoy_subscribe",
15234
+ "envoy_unsubscribe",
15235
+ "envoy_list",
15236
+ "envoy_inbox",
15237
+ "envoy_send",
15238
+ "envoy_publish",
15239
+ "envoy_role_set",
15240
+ "envoy_role_get",
15241
+ "envoy_whoami",
15242
+ "envoy_sessions"
15243
+ ];
15244
+ var LIVE_CAPABILITIES = [
15245
+ "subagents",
15246
+ "web-search",
15247
+ "mcp",
15248
+ "repository-extensions",
15249
+ "dispatch-envoy-tools",
15250
+ "github"
15251
+ ];
15252
+ var DEFAULT_BUDGETS = {
15253
+ checkMs: CAPABILITY_CHECK_TIMEOUT_MS,
15254
+ reportMs: CAPABILITY_REPORT_TIMEOUT_MS
15255
+ };
15256
+ var ok = (detail) => ({ ok: true, detail });
15257
+ var failed = (detail) => ({ ok: false, detail });
15258
+ function firstLine(text) {
15259
+ return text.trim().split(`
15260
+ `, 1)[0] ?? "";
15261
+ }
15262
+ function commandFailure(result) {
15263
+ return firstLine(result.stderr) || `exit code ${result.code ?? "none"}`;
15264
+ }
15265
+ function whichOnPath(name) {
15266
+ return Bun.which(name, { PATH: process.env.PATH ?? "" }) ?? name;
15267
+ }
15268
+ var checkSubagents = async (host2, input) => {
15269
+ if (!input.agentsExposed)
15270
+ return failed("pi.agents is not exposed");
15271
+ if (!input.activeTools.includes("task"))
15272
+ return failed("task is not a registered tool");
15273
+ const [discovered, disabled] = await Promise.all([
15274
+ host2.discoverAgents(input.cwd),
15275
+ host2.disabledAgents()
15276
+ ]);
15277
+ const names = new Set(discovered.agents.map((agent) => agent.name));
15278
+ const missing = input.promptAgents.filter((agent) => !names.has(agent));
15279
+ const off = input.promptAgents.filter((agent) => disabled.includes(agent));
15280
+ const problems = [];
15281
+ if (missing.length > 0) {
15282
+ problems.push(`agents the prompts dispatch that the task tool does not resolve: ${missing.join(", ")}`);
15283
+ }
15284
+ if (off.length > 0)
15285
+ problems.push(`disabled by task.disabledAgents: ${off.join(", ")}`);
15286
+ if (problems.length > 0)
15287
+ return failed(problems.join("; "));
15288
+ return ok(`task resolves ${names.size} agents, every one the prompts dispatch`);
15289
+ };
15290
+ var checkWebSearch = async (host2, input, signal) => {
15291
+ if (!input.activeTools.includes("web_search")) {
15292
+ return failed("web_search is not a registered tool");
15293
+ }
15294
+ const started = Date.now();
15295
+ const result = await host2.runSearchQuery({ query: WEB_SEARCH_PROBE_QUERY, limit: 1 }, { sessionId: input.sessionId, signal });
15296
+ const elapsed = Date.now() - started;
15297
+ const details = result.details ?? {};
15298
+ if (details.error !== undefined && details.error !== null) {
15299
+ return failed(`web_search failed: ${String(details.error)}`);
15300
+ }
15301
+ const provider = details.response?.provider ?? details.provider;
15302
+ if (provider === undefined || provider === null || provider === "none") {
15303
+ return failed("web_search failed: no provider answered");
15304
+ }
15305
+ const text = result.content?.[0]?.text ?? "";
15306
+ if (text.startsWith("Error"))
15307
+ return failed(`web_search failed: ${firstLine(text)}`);
15308
+ return ok(`web_search registered; provider ${String(provider)} answered in ${elapsed} ms`);
15309
+ };
15310
+ function mcpErrorFor(errors3, name) {
15311
+ for (const entry of errors3) {
15312
+ if (typeof entry !== "object" || entry === null) {
15313
+ if (!String(entry).includes(name))
15314
+ continue;
15315
+ return String(entry);
15316
+ }
15317
+ const record2 = entry;
15318
+ const names = [record2.server, record2.name, record2.path];
15319
+ if (!names.some((value) => value === name || value === `mcp:${name}`))
15320
+ continue;
15321
+ const message = record2.error ?? record2.message;
15322
+ return message === undefined ? String(entry) : String(message);
15323
+ }
15324
+ return;
15325
+ }
15326
+ var checkMCP = async (host2, input, signal) => {
15327
+ const { configs, sources } = await host2.loadMCPConfigs(input.cwd);
15328
+ const configured = Object.keys(configs);
15329
+ if (configured.length === 0)
15330
+ return ok("no MCP server configured");
15331
+ const { manager, errors: errors3 } = await host2.discoverMCPServers(input.cwd);
15332
+ let torn;
15333
+ const tearDown = () => {
15334
+ torn ??= manager.disconnectAll().catch(() => {
15335
+ return;
15336
+ });
15337
+ return torn;
15338
+ };
15339
+ signal.addEventListener("abort", tearDown, { once: true });
15340
+ if (signal.aborted)
15341
+ tearDown();
15342
+ try {
15343
+ await manager.waitForPendingConnections();
15344
+ const connected = [];
15345
+ const unconnected = [];
15346
+ for (const name of configured) {
15347
+ const status = manager.getConnectionStatus(name);
15348
+ if (status === "connected") {
15349
+ const source = sources[name]?.path;
15350
+ connected.push(source === undefined ? name : `${name} (${path3.relative(input.cwd, source)})`);
15351
+ } else {
15352
+ unconnected.push(`${name} (${mcpErrorFor(errors3, name) ?? `status ${status}`})`);
15353
+ }
15354
+ }
15355
+ if (unconnected.length === 0)
15356
+ return ok(`connected: ${connected.join(", ")}`);
15357
+ const suffix = connected.length === 0 ? "" : `; connected: ${connected.join(", ")}`;
15358
+ return failed(`not connected: ${unconnected.join(", ")}${suffix}`);
15359
+ } finally {
15360
+ signal.removeEventListener("abort", tearDown);
15361
+ await tearDown();
15362
+ }
15363
+ };
15364
+ var EXTENSION_MODULE = /\.(?:ts|js|mjs|cjs)$/;
15365
+ var EXTENSION_INDEXES = ["index.ts", "index.js", "index.mjs", "index.cjs"];
15366
+ var SKILL_DIRECTORIES = [path3.join(".omp", "skills"), path3.join(".claude", "skills")];
15367
+ async function namesIn(directory) {
15368
+ try {
15369
+ return await readdir(directory);
15370
+ } catch (error48) {
15371
+ if (hasErrnoCode(error48, "ENOENT") || hasErrnoCode(error48, "ENOTDIR"))
15372
+ return [];
15373
+ throw error48;
15374
+ }
15375
+ }
15376
+ async function kindOf(file2) {
15377
+ try {
15378
+ const stats = await stat(file2);
15379
+ return stats.isFile() ? "file" : stats.isDirectory() ? "directory" : undefined;
15380
+ } catch (error48) {
15381
+ if (hasErrnoCode(error48, "ENOENT") || hasErrnoCode(error48, "ENOTDIR"))
15382
+ return;
15383
+ throw error48;
15384
+ }
15385
+ }
15386
+ async function repositoryEntries(cwd) {
15387
+ const entries = [];
15388
+ const extensions = path3.join(cwd, ".omp", "extensions");
15389
+ for (const name of await namesIn(extensions)) {
15390
+ const candidate = path3.join(extensions, name);
15391
+ const kind = await kindOf(candidate);
15392
+ if (kind === "file" && EXTENSION_MODULE.test(name)) {
15393
+ entries.push({ path: candidate, directory: false });
15394
+ } else if (kind === "directory") {
15395
+ const indexes = await Promise.all(EXTENSION_INDEXES.map((index) => kindOf(path3.join(candidate, index))));
15396
+ if (indexes.includes("file"))
15397
+ entries.push({ path: candidate, directory: true });
15398
+ }
15399
+ }
15400
+ for (const skills of SKILL_DIRECTORIES) {
15401
+ for (const name of await namesIn(path3.join(cwd, skills))) {
15402
+ const skill = path3.join(cwd, skills, name, "SKILL.md");
15403
+ if (await kindOf(skill) === "file")
15404
+ entries.push({ path: skill, directory: false });
15405
+ }
15406
+ }
15407
+ return entries;
15408
+ }
15409
+ var checkRepositoryExtensions = async (host2, input) => {
15410
+ const entries = await repositoryEntries(input.cwd);
15411
+ if (entries.length === 0) {
15412
+ return ok("the repository carries no .omp/extensions, .omp/skills or .claude/skills");
15413
+ }
15414
+ const [extensionPaths, loaded] = await Promise.all([
15415
+ host2.discoverExtensionPaths(input.cwd),
15416
+ host2.loadSkills(input.cwd)
15417
+ ]);
15418
+ const discovered = new Set([...extensionPaths, ...loaded.skills.map((skill) => skill.filePath)].map((file2) => path3.resolve(file2)));
15419
+ const isDiscovered = (entry) => {
15420
+ const resolved = path3.resolve(entry.path);
15421
+ if (discovered.has(resolved))
15422
+ return true;
15423
+ if (!entry.directory)
15424
+ return false;
15425
+ const prefix = resolved + path3.sep;
15426
+ return [...discovered].some((file2) => file2.startsWith(prefix));
15427
+ };
15428
+ const relative = (selected) => selected.map((entry) => path3.relative(input.cwd, entry.path)).sort().join(", ");
15429
+ const found = [];
15430
+ const lost = [];
15431
+ for (const entry of entries)
15432
+ (isDiscovered(entry) ? found : lost).push(entry);
15433
+ if (lost.length === 0)
15434
+ return ok(`discovered: ${relative(found)}`);
15435
+ const suffix = found.length === 0 ? "" : `; discovered: ${relative(found)}`;
15436
+ return failed(`not discovered: ${relative(lost)}${suffix}`);
15437
+ };
15438
+ var checkDispatchEnvoyTools = async (host2, input, signal) => {
15439
+ const missing = ENVOY_TOOL_NAMES.filter((tool) => !input.activeTools.includes(tool));
15440
+ if (missing.length > 0)
15441
+ return failed(`Envoy tools missing: ${missing.join(", ")}`);
15442
+ const result = await host2.run("dispatch", ["read", "--issue", input.issue], {
15443
+ cwd: input.cwd,
15444
+ signal
15445
+ });
15446
+ if (result.code !== 0)
15447
+ return failed(`dispatch read failed: ${commandFailure(result)}`);
15448
+ return ok(`ten Envoy tools registered; dispatch read ${input.issue} answered from ${whichOnPath("dispatch")}`);
15449
+ };
15450
+ var checkGitHub = async (host2, input, signal) => {
15451
+ const result = await host2.run("gh", ["api", "graphql", "-f", "query={viewer{login}}", "--jq", ".data.viewer.login"], { cwd: input.cwd, signal });
15452
+ if (result.code !== 0)
15453
+ return failed(`gh viewer login failed: ${commandFailure(result)}`);
15454
+ return ok(`gh viewer login: ${result.stdout.trim()}`);
15455
+ };
15456
+ var CHECKS = {
15457
+ subagents: checkSubagents,
15458
+ "web-search": checkWebSearch,
15459
+ mcp: checkMCP,
15460
+ "repository-extensions": checkRepositoryExtensions,
15461
+ "dispatch-envoy-tools": checkDispatchEnvoyTools,
15462
+ github: checkGitHub
15463
+ };
15464
+ var MAX_DETAIL_BYTES = 1024;
15465
+ var HELD = "\uFFFF";
15466
+ function isSpace(code) {
15467
+ return code >= 9 && code <= 13 || code === 32 || code === 133 || code === 160 || code === 5760 || code >= 8192 && code <= 8202 || code === 8232 || code === 8233 || code === 8239 || code === 8287 || code === 12288;
15468
+ }
15469
+ function truncateUtf8(text, max) {
15470
+ let bytes = 0;
15471
+ let end = 0;
15472
+ for (const char of text) {
15473
+ const code = char.codePointAt(0) ?? 0;
15474
+ bytes += code < 128 ? 1 : code < 2048 ? 2 : code < 65536 ? 3 : 4;
15475
+ if (bytes > max)
15476
+ break;
15477
+ end += char.length;
15478
+ }
15479
+ return text.slice(0, end);
15480
+ }
15481
+ var CREDENTIAL_RULES = [
15482
+ [/(?<![a-z0-9+.-])([0-9+.-]*[a-z][a-z0-9+.-]*:\/\/)[^ /?#]+@/gi, "$1***@"],
15483
+ [
15484
+ /([?&](?:token|key|secret|password|access_token|refresh_token|id_token|api_key|apikey|client_secret|sig|x-amz-security-token|x-amz-signature)=)[^ &#"'<>]+/gi,
15485
+ "$1***"
15486
+ ],
15487
+ [
15488
+ /(authorization["']?[ \uffff]*[:=][ \uffff]*)(?:(["'])((?:bearer|basic|token|digest)[ \uffff]+)?[^"']+|(\[?)((?:bearer|basic|token|digest)[ \uffff]+)?[^)\]}>]+)/gi,
15489
+ "$1$2$3$4$5***"
15490
+ ],
15491
+ [/(x-api-key["']?[ \uffff]*[:=][ \uffff]*["']?)[A-Za-z0-9._~+/=*\uffff-]+/gi, "$1***"],
15492
+ [/\b(bearer[ \uffff]+)[A-Za-z0-9._~+/=*\uffff-]+/gi, "$1***"],
15493
+ [/\b(gh[pousr]_|github_pat_)[A-Za-z0-9._\uffff-]{20,}/g, "$1***"],
15494
+ [
15495
+ /(?<![A-Za-z0-9_\uffff-])eyJ[A-Za-z0-9_\uffff-]{8,}\.[A-Za-z0-9_\uffff-]{8,}\.[A-Za-z0-9_\uffff-]*/g,
15496
+ "***"
15497
+ ],
15498
+ [/\b(sk-|dsp_)[A-Za-z0-9_\uffff-]{20,}/g, "$1***"],
15499
+ [/\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, "$1***"]
15500
+ ];
15501
+ function cleanDetail(text) {
15502
+ let marked = "";
15503
+ for (const char of text) {
15504
+ const code = char.codePointAt(0) ?? 0;
15505
+ const control = code < 32 || code >= 127 && code < 160;
15506
+ marked += isSpace(code) ? " " : control ? HELD : char;
15507
+ }
15508
+ const redacted = CREDENTIAL_RULES.reduce((line2, [pattern, replacement]) => line2.replace(pattern, replacement), marked);
15509
+ const line = redacted.replaceAll(HELD, " ").split(" ").filter((field) => field !== "").join(" ");
15510
+ return truncateUtf8(line, MAX_DETAIL_BYTES);
15511
+ }
15512
+ function budgetLabel(ms) {
15513
+ return ms % 1000 === 0 ? `${ms / 1000} s` : `${ms} ms`;
15514
+ }
15515
+ async function withinBudget(work, ms, onDeadline) {
15516
+ const deadline = Promise.withResolvers();
15517
+ const timer = setTimeout(() => deadline.resolve(onDeadline()), ms);
15518
+ try {
15519
+ return await Promise.race([work, deadline.promise]);
15520
+ } finally {
15521
+ clearTimeout(timer);
15522
+ }
15523
+ }
15524
+ async function measureCapabilities(host2, input, budgets = DEFAULT_BUDGETS) {
15525
+ const started = Date.now();
15526
+ const measuredAt = new Date(started).toISOString();
15527
+ const outcomes = new Map;
15528
+ const controllers = [];
15529
+ const checks3 = LIVE_CAPABILITIES.map(async (name) => {
15530
+ const controller = new AbortController;
15531
+ controllers.push(controller);
15532
+ const outcome = await withinBudget(CHECKS[name](host2, input, controller.signal).catch((error48) => failed(firstLine(messageFor(error48)))), budgets.checkMs, () => {
15533
+ controller.abort();
15534
+ return failed(`did not finish within ${budgetLabel(budgets.checkMs)}`);
15535
+ });
15536
+ outcomes.set(name, outcome);
15537
+ });
15538
+ await withinBudget(Promise.all(checks3).then(() => {
15539
+ return;
15540
+ }), budgets.reportMs, () => {
15541
+ return;
15542
+ });
15543
+ for (const controller of controllers)
15544
+ controller.abort();
15545
+ const rows = LIVE_CAPABILITIES.map((name) => {
15546
+ const { ok: ok2, detail } = outcomes.get(name) ?? failed(`did not finish within ${budgetLabel(budgets.reportMs)}`);
15547
+ return { name, ok: ok2, detail: cleanDetail(detail) };
15548
+ });
15549
+ return { measuredAt, elapsedMs: Date.now() - started, rows };
15550
+ }
15551
+ async function runCommand(command, args, options) {
15552
+ const child = Bun.spawn([command, ...args], {
15553
+ cwd: options.cwd,
15554
+ env: process.env,
15555
+ stdin: "ignore",
15556
+ stdout: "pipe",
15557
+ stderr: "pipe",
15558
+ signal: options.signal
15559
+ });
15560
+ const [stdout, stderr] = await Promise.all([
15561
+ new Response(child.stdout).text(),
15562
+ new Response(child.stderr).text(),
15563
+ child.exited
15564
+ ]);
15565
+ return { code: child.exitCode, stdout, stderr };
15566
+ }
15567
+ function ompCapabilityHost() {
15568
+ const hostSettings = async () => (await import("@oh-my-pi/pi-coding-agent/config/settings")).settings;
15569
+ return {
15570
+ discoverAgents: async (cwd) => (await import("@oh-my-pi/pi-coding-agent")).discoverAgents(cwd),
15571
+ disabledAgents: async () => {
15572
+ const [{ cfgTaskDisabledAgents }, settings] = await Promise.all([
15573
+ import("@oh-my-pi/pi-coding-agent/task/settings"),
15574
+ hostSettings()
15575
+ ]);
15576
+ return cfgTaskDisabledAgents.get(settings);
15577
+ },
15578
+ runSearchQuery: async (args, options) => (await import("@oh-my-pi/pi-coding-agent")).runSearchQuery(args, options),
15579
+ loadMCPConfigs: async (cwd) => (await import("@oh-my-pi/pi-coding-agent/mcp/config")).loadAllMCPConfigs(cwd),
15580
+ discoverMCPServers: async (cwd) => (await import("@oh-my-pi/pi-coding-agent")).discoverMCPServers(cwd),
15581
+ discoverExtensionPaths: async (cwd) => {
15582
+ const [{ discoverExtensionPaths }, { cfgDisabledExtensions }, settings] = await Promise.all([
15583
+ import("@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"),
15584
+ import("@oh-my-pi/pi-coding-agent/extensibility/settings"),
15585
+ hostSettings()
15586
+ ]);
15587
+ return discoverExtensionPaths([], cwd, cfgDisabledExtensions.get(settings));
15588
+ },
15589
+ loadSkills: async (cwd) => {
15590
+ const [{ loadSkills }, { cfgDisabledExtensions, cfgSkills }, settings] = await Promise.all([
15591
+ import("@oh-my-pi/pi-coding-agent"),
15592
+ import("@oh-my-pi/pi-coding-agent/extensibility/settings"),
15593
+ hostSettings()
15594
+ ]);
15595
+ return loadSkills({
15596
+ ...cfgSkills.get(settings),
15597
+ cwd,
15598
+ disabledExtensions: cfgDisabledExtensions.get(settings)
15599
+ });
15600
+ },
15601
+ run: runCommand
15602
+ };
15603
+ }
15604
+
15222
15605
  // ../pi-shared/src/role-claim-bridge.ts
15223
15606
  function legionRoleClaimBridge() {
15224
15607
  return envoyPluginInterface().roleClaim;
@@ -15244,7 +15627,7 @@ function onEnvoyRoleRegained(listener) {
15244
15627
  // package.json
15245
15628
  var package_default = {
15246
15629
  name: "@sjawhar/pi-legion",
15247
- version: "8.15.0",
15630
+ version: "8.16.1",
15248
15631
  license: "Apache-2.0",
15249
15632
  type: "module",
15250
15633
  omp: {
@@ -15256,7 +15639,7 @@ var package_default = {
15256
15639
  ]
15257
15640
  },
15258
15641
  legion: {
15259
- daemonApiVersion: 20
15642
+ daemonApiVersion: 21
15260
15643
  },
15261
15644
  repository: {
15262
15645
  type: "git",
@@ -15394,10 +15777,17 @@ var legionLocator = exports_external.discriminatedUnion("runtime", [
15394
15777
  })
15395
15778
  })
15396
15779
  ]);
15780
+ var legionCapabilityReportView = exports_external.strictObject({
15781
+ measuredAt: timestamp,
15782
+ incarnation: exports_external.string(),
15783
+ ok: exports_external.array(nonEmptyString),
15784
+ open: exports_external.array(exports_external.strictObject({ name: nonEmptyString, detail: nonEmptyString }))
15785
+ });
15397
15786
  var legionClaimView = exports_external.strictObject({
15398
15787
  session: exports_external.string(),
15399
15788
  state: exports_external.enum(LEGION_CLAIM_STATES),
15400
- locator: legionLocator.optional()
15789
+ locator: legionLocator.optional(),
15790
+ capabilities: legionCapabilityReportView.optional()
15401
15791
  });
15402
15792
  var legionPhaseView = exports_external.strictObject({
15403
15793
  claim: legionClaimView,
@@ -15465,7 +15855,7 @@ var legionAgentSecretsLoginView = exports_external.strictObject({
15465
15855
  });
15466
15856
  var legionCapabilityState = exports_external.strictObject({
15467
15857
  name: nonEmptyString,
15468
- status: exports_external.enum(["present", "installed", "unchecked", "live", "withheld", "decided", "open"]),
15858
+ status: exports_external.enum(["present", "installed", "unchecked", "withheld", "decided", "open"]),
15469
15859
  detail: nonEmptyString,
15470
15860
  decision: nonEmptyString.optional(),
15471
15861
  configLine: nonEmptyString.optional()
@@ -15485,7 +15875,20 @@ var LegionRegisterResponse = exports_external.strictObject({
15485
15875
  issue: nonEmptyString,
15486
15876
  role: exports_external.enum(LEGION_ROLES),
15487
15877
  generation: exports_external.number().int().positive(),
15488
- secret: nonEmptyString
15878
+ secret: nonEmptyString,
15879
+ promptAgents: exports_external.array(nonEmptyString)
15880
+ });
15881
+ var LegionCapabilityReport = exports_external.strictObject({
15882
+ measuredAt: timestamp,
15883
+ elapsedMs: exports_external.number().int().nonnegative(),
15884
+ rows: exports_external.array(exports_external.strictObject({ name: nonEmptyString, ok: exports_external.boolean(), detail: exports_external.string() }))
15885
+ });
15886
+ var LegionReadyRequest = exports_external.strictObject({
15887
+ claimToken: nonEmptyString,
15888
+ sessionId: nonEmptyString,
15889
+ secret: nonEmptyString,
15890
+ generation: exports_external.number().int().positive(),
15891
+ capabilities: LegionCapabilityReport.optional()
15489
15892
  });
15490
15893
  var LegionControllerRegisterResponse = exports_external.strictObject({
15491
15894
  claimToken: nonEmptyString,
@@ -15618,10 +16021,10 @@ class LegionDaemonApiError extends Error {
15618
16021
  status;
15619
16022
  code;
15620
16023
  detail;
15621
- constructor(method, path3, status, code, detail) {
15622
- super(`${method} ${path3} failed with ${status}${code === undefined ? "" : ` ${code}`}: ${detail}`);
16024
+ constructor(method, path4, status, code, detail) {
16025
+ super(`${method} ${path4} failed with ${status}${code === undefined ? "" : ` ${code}`}: ${detail}`);
15623
16026
  this.method = method;
15624
- this.path = path3;
16027
+ this.path = path4;
15625
16028
  this.status = status;
15626
16029
  this.code = code;
15627
16030
  this.detail = detail;
@@ -15633,10 +16036,10 @@ class LegionDaemonContractError extends Error {
15633
16036
  method;
15634
16037
  path;
15635
16038
  status;
15636
- constructor(method, path3, status, problem) {
15637
- super(`${method} ${path3} answered ${status} with a body the daemon API contract does not describe: ${problem}`);
16039
+ constructor(method, path4, status, problem) {
16040
+ super(`${method} ${path4} answered ${status} with a body the daemon API contract does not describe: ${problem}`);
15638
16041
  this.method = method;
15639
- this.path = path3;
16042
+ this.path = path4;
15640
16043
  this.status = status;
15641
16044
  this.name = "LegionDaemonContractError";
15642
16045
  }
@@ -15657,8 +16060,8 @@ function parseStrictly(schema, body) {
15657
16060
  }
15658
16061
  function createLegionDaemonClient(baseUrl, fetchFn = fetch) {
15659
16062
  const endpoint = baseUrl.replace(/\/+$/, "");
15660
- const call = async (method, path3, body) => {
15661
- const response = await fetchFn(`${endpoint}${path3}`, body === undefined ? undefined : {
16063
+ const call = async (method, path4, body) => {
16064
+ const response = await fetchFn(`${endpoint}${path4}`, body === undefined ? undefined : {
15662
16065
  method,
15663
16066
  headers: { "Content-Type": "application/json" },
15664
16067
  body: JSON.stringify(body)
@@ -15669,23 +16072,23 @@ function createLegionDaemonClient(baseUrl, fetchFn = fetch) {
15669
16072
  const refusal = parseStrictly(LegionErrorResponse, text);
15670
16073
  if ("value" in refusal) {
15671
16074
  const code = "code" in refusal.value && typeof refusal.value.code === "string" ? refusal.value.code : undefined;
15672
- throw new LegionDaemonApiError(method, path3, response.status, code, refusal.value.error);
16075
+ throw new LegionDaemonApiError(method, path4, response.status, code, refusal.value.error);
15673
16076
  }
15674
- throw new LegionDaemonApiError(method, path3, response.status, undefined, `${JSON.stringify(text)} is not the daemon's refusal shape (${refusal.problem})`);
16077
+ throw new LegionDaemonApiError(method, path4, response.status, undefined, `${JSON.stringify(text)} is not the daemon's refusal shape (${refusal.problem})`);
15675
16078
  };
15676
- const read = async (method, path3, schema, body) => {
15677
- const { status, text } = await call(method, path3, body);
16079
+ const read = async (method, path4, schema, body) => {
16080
+ const { status, text } = await call(method, path4, body);
15678
16081
  const parsed = parseStrictly(schema, text);
15679
16082
  if ("problem" in parsed) {
15680
- throw new LegionDaemonContractError(method, path3, status, parsed.problem);
16083
+ throw new LegionDaemonContractError(method, path4, status, parsed.problem);
15681
16084
  }
15682
16085
  return parsed.value;
15683
16086
  };
15684
- const post = (path3, request, response, body) => read("POST", path3, response, request.parse(body));
15685
- const acknowledge = async (path3, body) => {
15686
- const { status, text } = await call("POST", path3, body);
16087
+ const post = (path4, request, response, body) => read("POST", path4, response, request.parse(body));
16088
+ const acknowledge = async (path4, body) => {
16089
+ const { status, text } = await call("POST", path4, body);
15687
16090
  if (status !== 204 || text !== "") {
15688
- throw new LegionDaemonContractError("POST", path3, status, "the route answers 204 with no body");
16091
+ throw new LegionDaemonContractError("POST", path4, status, "the route answers 204 with no body");
15689
16092
  }
15690
16093
  };
15691
16094
  return {
@@ -15712,16 +16115,16 @@ function createLegionDaemonClient(baseUrl, fetchFn = fetch) {
15712
16115
  // src/jj-attribution.ts
15713
16116
  import { execFile as execFile2 } from "child_process";
15714
16117
  import { mkdir, rename, writeFile } from "fs/promises";
15715
- import path3 from "path";
16118
+ import path4 from "path";
15716
16119
  import { promisify as promisify2 } from "util";
15717
16120
  var UUID = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
15718
16121
  var TOP_LEVEL_TRANSCRIPT = new RegExp(`^\\d{4}-\\d{2}-\\d{2}T[\\d-]+Z_(${UUID})\\.jsonl$`);
15719
16122
  var ATTRIBUTION_OVERLAY = /^omp-attribution-.*\.toml$/;
15720
16123
  function sessionIdFromTranscript(sessionFile) {
15721
- return TOP_LEVEL_TRANSCRIPT.exec(path3.basename(sessionFile))?.[1];
16124
+ return TOP_LEVEL_TRANSCRIPT.exec(path4.basename(sessionFile))?.[1];
15722
16125
  }
15723
16126
  function withoutOverlays(chain) {
15724
- return chain.split(":").filter((entry) => entry && !ATTRIBUTION_OVERLAY.test(path3.basename(entry))).join(":");
16127
+ return chain.split(":").filter((entry) => entry && !ATTRIBUTION_OVERLAY.test(path4.basename(entry))).join(":");
15725
16128
  }
15726
16129
  async function userConfigChain() {
15727
16130
  try {
@@ -15737,7 +16140,7 @@ async function exportJjSessionAttribution(sessionFile, stateDir, env = process.e
15737
16140
  const id = sessionIdFromTranscript(sessionFile);
15738
16141
  if (!id)
15739
16142
  return;
15740
- const overlay = path3.join(stateDir, `omp-attribution-${id}.toml`);
16143
+ const overlay = path4.join(stateDir, `omp-attribution-${id}.toml`);
15741
16144
  if (env.JJ_CONFIG?.split(":").includes(overlay))
15742
16145
  return;
15743
16146
  const base = (env.JJ_CONFIG ? withoutOverlays(env.JJ_CONFIG) : "") || await resolveBase();
@@ -15808,12 +16211,10 @@ function createClaimSession(deps) {
15808
16211
  agentId,
15809
16212
  pluginContract: package_default.legion.daemonApiVersion
15810
16213
  }).catch((error48) => exitOnRegistrationRefusal(error48, deps.exitProcess));
15811
- const ready = {
15812
- claimToken: claim.claimToken,
15813
- sessionId: sessionID,
15814
- secret: claim.secret,
15815
- generation: claim.generation
15816
- };
16214
+ const measuring = Promise.resolve().then(() => deps.measureCapabilities(context, { issue: claim.issue, promptAgents: claim.promptAgents })).catch((error48) => {
16215
+ console.error(`[legion] capability measurement failed; ready carries no report: ${messageFor(error48)}`);
16216
+ return;
16217
+ });
15817
16218
  try {
15818
16219
  const stateDir = requiredEnvironment(process.env, "LEGION_STATE_DIR");
15819
16220
  await exportJjSessionAttribution(sessionFile, stateDir);
@@ -15825,6 +16226,14 @@ function createClaimSession(deps) {
15825
16226
  secret: claim.secret
15826
16227
  };
15827
16228
  await claimEnvoyRole(sessionID, claim.claimToken, context);
16229
+ const capabilities = await measuring;
16230
+ const ready = {
16231
+ claimToken: claim.claimToken,
16232
+ sessionId: sessionID,
16233
+ secret: claim.secret,
16234
+ generation: claim.generation,
16235
+ ...capabilities === undefined ? {} : { capabilities }
16236
+ };
15828
16237
  await callReadyWithRetry("claims/ready", () => daemon.ready(ready));
15829
16238
  onEnvoyRoleRegained(async (role, reason) => {
15830
16239
  if (role !== claim.claimToken)
@@ -16406,13 +16815,17 @@ var exitProcess = (code) => process.exit(code);
16406
16815
  function setLegionBootstrapExitForTests(hook) {
16407
16816
  exitProcess = hook;
16408
16817
  }
16818
+ var capabilityHost = ompCapabilityHost;
16819
+ function setLegionCapabilityHostForTests(host2) {
16820
+ capabilityHost = host2;
16821
+ }
16409
16822
  async function persistedTranscript(context) {
16410
16823
  await context.sessionManager.ensureOnDisk();
16411
16824
  const sessionFile = context.sessionManager.getSessionFile();
16412
16825
  if (!sessionFile?.endsWith(".jsonl")) {
16413
16826
  throw new Error("Legion session must have a persisted transcript");
16414
16827
  }
16415
- const agentId = path4.basename(sessionFile, ".jsonl");
16828
+ const agentId = path5.basename(sessionFile, ".jsonl");
16416
16829
  if (!agentId)
16417
16830
  throw new Error("Legion session transcript has no agent id");
16418
16831
  return { sessionFile, agentId };
@@ -16462,6 +16875,14 @@ function legionExtension(pi) {
16462
16875
  const claimSession = createClaimSession({
16463
16876
  daemon: roleDaemon,
16464
16877
  persistedTranscript,
16878
+ measureCapabilities: (context, input) => measureCapabilities(capabilityHost(), {
16879
+ cwd: context.cwd,
16880
+ sessionId: context.sessionManager.getSessionId(),
16881
+ issue: input.issue,
16882
+ promptAgents: input.promptAgents,
16883
+ activeTools: pi.getActiveTools(),
16884
+ agentsExposed: pi.agents !== undefined
16885
+ }),
16465
16886
  exitProcess: (code) => exitProcess(code)
16466
16887
  });
16467
16888
  const controllerSession = createControllerSession({
@@ -16620,6 +17041,7 @@ function legionExtension(pi) {
16620
17041
  });
16621
17042
  }
16622
17043
  export {
17044
+ setLegionCapabilityHostForTests,
16623
17045
  setLegionBootstrapExitForTests,
16624
17046
  legionExtension as default
16625
17047
  };
@@ -193,12 +193,17 @@ unsent:
193
193
  `controller_wake_interval_seconds` (an hour by default), whatever the slots. An earlier walk
194
194
  that found nothing, a day with no event, and [a tree waiting on a root
195
195
  claim](#trees-waiting-on-a-root-claim-go-daemon) all get a turn from it. Its payload's
196
- `openCapabilities`, when present, names the deployment capabilities (the secrets broker, model
197
- fallback, every role's CPU and memory limits) the deployment's own configuration leaves open with
198
- no decision recorded. `read_state` lists every row under `capabilities`, each open one
199
- with its `detail` and the `configLine` to write into `legion.yaml`
200
- (`capabilities.decided.<name>: "<reason>"`); name them in the day's report. A gap is the
201
- operator's to close or to decide, never yours, and it never stops the walk.
196
+ `openCapabilities`, when present, names the open capability rows: the deployment capabilities
197
+ (the secrets broker, model fallback, every role's CPU and memory limits) the deployment's own
198
+ configuration leaves open with no decision recorded, and the live capabilities (`subagents`,
199
+ `web-search`, `mcp`, `repository-extensions`, `dispatch-envoy-tools`, `github`) a running
200
+ session's own check at its start failed. `read_state` lists every row under `capabilities`, each
201
+ open one with its `detail`: on a deployment row, with the `configLine` to write into
202
+ `legion.yaml` (`capabilities.decided.<name>: "<reason>"`); on a live row, naming each failing
203
+ session (its pod or pane, its role, its issue) and the fact its check found, with no
204
+ `configLine`, since nothing in `legion.yaml` decides a live row. Name them in the day's report.
205
+ A gap is the operator's to close or to decide — a live one the operator's or the issue's
206
+ architect's to fix in the session's environment — never yours, and it never stops the walk.
202
207
 
203
208
  **Scope first.** The scope the deployment instructions state decides which issues are candidates
204
209
  at all, before anything below. When they say you hand Legion no issue yourself, or that Legion
@@ -350,9 +355,12 @@ every pull request by its URL.
350
355
  when it made one: what it took, and how many candidates each row of the table skipped, so a
351
356
  reader can see why a free slot stays empty.
352
357
  - **Open capabilities.** Each row of `capabilities` in `read_state` whose `status` is
353
- `open` (the `tick` payload's `openCapabilities`): its name, its `detail`, and its `configLine`,
354
- the `legion.yaml` line that records a decision. The operator closes or decides it; you only
355
- report it, and a report with none says nothing of them.
358
+ `open` (the `tick` payload's `openCapabilities`): its name, its `detail`, and, on a deployment
359
+ row, its `configLine`, the `legion.yaml` line that records a decision. A live row a running
360
+ session failed has no `configLine`: its `detail` names the session (pod or pane), the role, the
361
+ issue and the fact, and nothing in `legion.yaml` decides it. The operator closes or decides a
362
+ deployment row, and the operator or the issue's architect fixes a live row's session
363
+ environment; you only report either, and a report with none says nothing of them.
356
364
 
357
365
  A day with nothing finished says so in one sentence. When the lists do not fit, keep the counts
358
366
  and the highest-priority issues.
@@ -394,7 +402,7 @@ priority first, then board rank ([Keeping the slots full](#keeping-the-slots-ful
394
402
  | New issue created in the Dispatch project, status `triage`: `triage on <KEY>` (payload `{kind: "triage"}`) on the controller topic, for an unrecorded root carrying the `legion` label only ("Issues handed to Legion" above); the start procedure above catches one sent while no controller ran | issue key + triage context (incl. pre-existing children) | Triage: `set_status` to `todo` to admit, or to `backlog`/`icebox` to park |
395
403
  | `slot-free on <KEY>` from the Go daemon (payload `{kind: "slot-free"}`) | the root whose slot the daemon released with no waiting root to take it | Verify a free slot in `read_state`, then fill it ([Keeping the slots full](#keeping-the-slots-full-go-daemon)) |
396
404
  | `todo on <KEY>` from the Go daemon (payload `{kind: "todo"}`) | an issue not handed to Legion that changed while in `todo` and a slot stood free, sent half a minute later | Verify a free slot, then walk the whole `todo` list ([Keeping the slots full](#keeping-the-slots-full-go-daemon)) |
397
- | `tick on <PROJECT>` from the Go daemon (payload `{kind: "tick", openCapabilities?: [<name>, …]}`) | the project key; the daemon's periodic wake, whatever the slots; `openCapabilities` names the deployment capabilities with no decision, each with its detail and `capabilities.decided.<name>` line under `capabilities` in `read_state` | Recheck the trees waiting on a claim, then walk if a slot is free — a gap never stops the walk; post the day's report if this is the day's first turn, naming each open capability in it (the operator closes or decides it) |
405
+ | `tick on <PROJECT>` from the Go daemon (payload `{kind: "tick", openCapabilities?: [<name>, …]}`) | the project key; the daemon's periodic wake, whatever the slots; `openCapabilities` names the deployment capabilities with no decision, each with its detail and `capabilities.decided.<name>` line under `capabilities` in `read_state`, and the live capabilities a running session's check failed, each with the detail naming the session (pod or pane), role, issue and fact, and no `configLine` | Recheck the trees waiting on a claim, then walk if a slot is free — a gap never stops the walk; post the day's report if this is the day's first turn, naming each open capability in it (the operator closes or decides a deployment row; the operator or the issue's architect fixes a live row's session; you take no other action) |
398
406
  | Architect escalation (controller-actionable only: re-file a child as a root issue, capacity, cross-tree conflicts) | request + context | Judge and act; the owning architect writes an issue-design decision as a decision block and opens `dispatch ask` only for a human to-do |
399
407
  | Mention | Slack/GitHub PR @mention text | Answer, or route to the owning issue's architect role |
400
408
  | `held on <KEY>` from the Go daemon (payload `{kind: "held", phase, role?, reason?}`) | the held issue, the phase it left, and the role whose claim failed, or `reason: "escalated"` | Verify the hold in `read_state` (the issue's phase is `held`). Without `reason`, a phase worker's launches or prompts ran out and the tree's architect decides retry or escalate: no action. With `reason: "escalated"` (on the record, `issues.<KEY>.holdReason` is `escalated`), the architect sent it to you: handle it as an architect escalation below. Parking the tree is `set_status` of the root to `backlog`; setting the root back to `todo` later re-admits it as a new generation, which starts again from its architect |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion",
3
- "version": "8.15.0",
3
+ "version": "8.16.1",
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": 20
15
+ "daemonApiVersion": 21
16
16
  },
17
17
  "repository": {
18
18
  "type": "git",