Compare commits
27 Commits
8fc816bcd9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 55ed1bf5a9 | |||
| ad2e6f5f4a | |||
| 2738061b5d | |||
| ffc9b331ea | |||
| 729c8fdd5f | |||
| e56b710344 | |||
| d782308b42 | |||
| 8e8752e519 | |||
| 58f6b108c3 | |||
| cb26a044d3 | |||
| af643c452d | |||
| 1e80216b07 | |||
| eb67944e68 | |||
| 7bc0d0772c | |||
| ede3c0583f | |||
| aafc68e911 | |||
| 8c85c00ae9 | |||
| c5828e0591 | |||
| 5fd8de031d | |||
| de99b4a89e | |||
| f5d799c64b | |||
| e143495d6c | |||
| 3c4eaec76b | |||
| 007ba81c02 | |||
| 3977ed6822 | |||
| 289ea1344c | |||
| 63da676b5a |
@@ -7,6 +7,7 @@ keys:
|
|||||||
- &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
|
- &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
|
||||||
# Generated on the machine it names.
|
# Generated on the machine it names.
|
||||||
- &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
|
- &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
|
||||||
|
- &pikachu age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
|
||||||
|
|
||||||
creation_rules:
|
creation_rules:
|
||||||
# Material belonging to one machine.
|
# Material belonging to one machine.
|
||||||
@@ -18,9 +19,16 @@ creation_rules:
|
|||||||
- *admin
|
- *admin
|
||||||
- *neogaia
|
- *neogaia
|
||||||
|
|
||||||
|
- path_regex: secrets/pikachu\.yaml$
|
||||||
|
key_groups:
|
||||||
|
- age:
|
||||||
|
- *admin
|
||||||
|
- *pikachu
|
||||||
|
|
||||||
# Material common to every machine, so it is stored once rather than per host.
|
# Material common to every machine, so it is stored once rather than per host.
|
||||||
- path_regex: secrets/shared\.yaml$
|
- path_regex: secrets/shared\.yaml$
|
||||||
key_groups:
|
key_groups:
|
||||||
- age:
|
- age:
|
||||||
- *admin
|
- *admin
|
||||||
- *neogaia
|
- *neogaia
|
||||||
|
- *pikachu
|
||||||
|
|||||||
12
AGENTS.md
12
AGENTS.md
@@ -18,6 +18,10 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
- Subagent completion delivery is non-blocking through immediate spawn, milestone notifications, retained terminal entries, and `subagent_list` or `subagent_result` retrieval.
|
||||||
|
`subagent_wait` intentionally blocks the parent tool call until its condition or timeout, so do not use it merely to keep background work alive during an interactive workflow.
|
||||||
|
- Nixvim's flake input following the root nixpkgs source does not make its Home Manager module reuse the host's `pkgs` instance.
|
||||||
|
Keep `programs.nixvim.nixpkgs.useGlobalPackages = true` so Nixvim uses the shared package set without warning that its source default was affected.
|
||||||
- This host has no `python` or `python3` command on its ordinary `PATH`.
|
- This host has no `python` or `python3` command on its ordinary `PATH`.
|
||||||
For ad hoc Python, use Nix explicitly, such as `nix shell nixpkgs#python3 -c python3 <script>`.
|
For ad hoc Python, use Nix explicitly, such as `nix shell nixpkgs#python3 -c python3 <script>`.
|
||||||
- ADR bodies are immutable records of decisions as they were made, while frontmatter is mutable.
|
- ADR bodies are immutable records of decisions as they were made, while frontmatter is mutable.
|
||||||
@@ -81,4 +85,10 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla
|
|||||||
- Flake-managed Pi extension, prompt, and skill directories may still be written directly for throwaway development or local experiments.
|
- Flake-managed Pi extension, prompt, and skill directories may still be written directly for throwaway development or local experiments.
|
||||||
The risk is that a later Home Manager activation can overwrite or hide those unmanaged files, so finished work must be promoted into the dotfiles module before it counts as deployed.
|
The risk is that a later Home Manager activation can overwrite or hide those unmanaged files, so finished work must be promoted into the dotfiles module before it counts as deployed.
|
||||||
- Pi's tool discovery checks `~/.pi/agent/bin` before `PATH`, and downloaded generic Linux binaries there can be unusable on NixOS with the stub-ld error.
|
- Pi's tool discovery checks `~/.pi/agent/bin` before `PATH`, and downloaded generic Linux binaries there can be unusable on NixOS with the stub-ld error.
|
||||||
A copied or patched Pi launcher that only prepends Nix `fd`/`rg` to `PATH` may still break `@` autocomplete unless the local tool path is removed or Pi validates the local binary before using it.
|
This flake patches Pi to validate local tool binaries before selecting them, so it falls back to usable `fd`/`rg` from `PATH` instead.
|
||||||
|
Stale unpatched launchers are the remaining failure mode for broken `@` autocomplete.
|
||||||
|
- Nix flake evaluation ignores untracked files in this checkout.
|
||||||
|
Keep a new auto-loaded module staged or committed until it is removed, otherwise `nix flake check` and `nixos-rebuild --flake` evaluate without it and report its options as missing.
|
||||||
|
- The current Steam desktop client is an XWayland application.
|
||||||
|
Its CEF windows do not support Ozone and Steam composites them into an SDL surface with X11 extensions, so SDL Wayland selectors do not make the visible client native Wayland.
|
||||||
|
Keep fractional scaling sharp with Hyprland's `xwayland.force_zero_scaling` and Steam's own `STEAM_FORCE_DESKTOPUI_SCALING` instead.
|
||||||
|
|||||||
5
base.nix
5
base.nix
@@ -59,7 +59,10 @@ in
|
|||||||
users.users.${user.name} = {
|
users.users.${user.name} = {
|
||||||
isNormalUser = true;
|
isNormalUser = true;
|
||||||
description = user.description;
|
description = user.description;
|
||||||
extraGroups = [ "wheel" ];
|
extraGroups = [
|
||||||
|
"wheel"
|
||||||
|
"storage"
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
# The shared write group.
|
# The shared write group.
|
||||||
|
|||||||
8
flake.lock
generated
8
flake.lock
generated
@@ -562,11 +562,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785622772,
|
"lastModified": 1785695024,
|
||||||
"narHash": "sha256-0mOr+Jxerr4SU1ksLoSkztYlZ9P/nMB6RlgWygoCHWc=",
|
"narHash": "sha256-DLLk6X5zu3cRT50p18uHVdwjGVtiS0t/661M34q02zU=",
|
||||||
"ref": "refs/heads/main",
|
"ref": "refs/heads/main",
|
||||||
"rev": "40b16b87963b085817bfb82c26eef7d0408fa8a5",
|
"rev": "9b2a6bcd583d7d6bf7e5377c3632f601692df209",
|
||||||
"revCount": 52,
|
"revCount": 54,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.alexion.dev/alexion/skills"
|
"url": "https://git.alexion.dev/alexion/skills"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -100,7 +100,11 @@
|
|||||||
|
|
||||||
# `nix flake check` builds each host's toplevel.
|
# `nix flake check` builds each host's toplevel.
|
||||||
checks.x86_64-linux = lib.mapAttrs (
|
checks.x86_64-linux = lib.mapAttrs (
|
||||||
_name: host: host.config.system.build.toplevel
|
name: host:
|
||||||
|
if host.config.warnings == [] then
|
||||||
|
host.config.system.build.toplevel
|
||||||
|
else
|
||||||
|
throw "Host ${name} has evaluation warnings:\n${lib.concatStringsSep "\n" host.config.warnings}"
|
||||||
) self.nixosConfigurations;
|
) self.nixosConfigurations;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
config,
|
|
||||||
inputs,
|
inputs,
|
||||||
pkgs,
|
pkgs,
|
||||||
...
|
...
|
||||||
@@ -39,9 +38,6 @@
|
|||||||
modules.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml;
|
modules.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml;
|
||||||
modules.ssh.userKey.sopsFile = ../../secrets/neogaia.yaml;
|
modules.ssh.userKey.sopsFile = ../../secrets/neogaia.yaml;
|
||||||
|
|
||||||
# A machine the operator works from, so it admits the workstation keys alone.
|
|
||||||
modules.ssh.authorizedKeys = config.modules.ssh.workstationKeys;
|
|
||||||
|
|
||||||
modules.toolkit.enable = true;
|
modules.toolkit.enable = true;
|
||||||
|
|
||||||
# The walking-skeleton guest, enabled like any module: proves the guest path
|
# The walking-skeleton guest, enabled like any module: proves the guest path
|
||||||
@@ -68,9 +64,12 @@
|
|||||||
modules.agents.herdr.enable = true;
|
modules.agents.herdr.enable = true;
|
||||||
modules.agents.tools.gitea-axi.enable = true;
|
modules.agents.tools.gitea-axi.enable = true;
|
||||||
modules.agents.pi.enable = true;
|
modules.agents.pi.enable = true;
|
||||||
|
modules.agents.pi.subagents.maxConcurrent = 8;
|
||||||
|
modules.agents.pi.subagents.recentTerminalTtlMs = 15 * 60 * 1000;
|
||||||
|
|
||||||
modules.desktop.enable = true;
|
modules.desktop.enable = true;
|
||||||
modules.desktop.obsidian.enable = true;
|
modules.desktop.obsidian.enable = true;
|
||||||
|
modules.desktop.steam.enable = true;
|
||||||
|
|
||||||
time.timeZone = "America/New_York";
|
time.timeZone = "America/New_York";
|
||||||
i18n.defaultLocale = "en_GB.UTF-8";
|
i18n.defaultLocale = "en_GB.UTF-8";
|
||||||
|
|||||||
54
hosts/pikachu/default.nix
Normal file
54
hosts/pikachu/default.nix
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{ pkgs, ... }:
|
||||||
|
# pikachu — AZW ME Pro server.
|
||||||
|
# Disk layout is in ./disk.nix.
|
||||||
|
# `fileSystems` for the root disk are derived from it.
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
./hardware-configuration.nix
|
||||||
|
./disk.nix
|
||||||
|
];
|
||||||
|
|
||||||
|
system.stateVersion = "26.05";
|
||||||
|
|
||||||
|
boot.loader.systemd-boot.enable = true;
|
||||||
|
boot.loader.efi.canTouchEfiVariables = true;
|
||||||
|
|
||||||
|
hardware.cpu.intel.updateMicrocode = true;
|
||||||
|
hardware.enableRedistributableFirmware = true;
|
||||||
|
|
||||||
|
zramSwap.enable = true;
|
||||||
|
|
||||||
|
systemd.network = {
|
||||||
|
enable = true;
|
||||||
|
networks."10-uplink" = {
|
||||||
|
matchConfig.MACAddress = "78:55:36:07:af:49";
|
||||||
|
networkConfig.DHCP = "yes";
|
||||||
|
linkConfig.RequiredForOnline = "routable";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
networking.useDHCP = false;
|
||||||
|
|
||||||
|
boot.zfs.forceImportRoot = false;
|
||||||
|
|
||||||
|
modules.zfs = {
|
||||||
|
enable = true;
|
||||||
|
hostId = "2346edbd";
|
||||||
|
pools.pikachu = { };
|
||||||
|
};
|
||||||
|
|
||||||
|
modules.ssh.enable = true;
|
||||||
|
modules.ssh.hostKeys.sopsFile = ../../secrets/pikachu.yaml;
|
||||||
|
modules.ssh.userKey.sopsFile = ../../secrets/pikachu.yaml;
|
||||||
|
|
||||||
|
modules.git.enable = true;
|
||||||
|
modules.toolkit.enable = true;
|
||||||
|
|
||||||
|
environment.systemPackages = with pkgs; [
|
||||||
|
pciutils
|
||||||
|
smartmontools
|
||||||
|
usbutils
|
||||||
|
];
|
||||||
|
|
||||||
|
time.timeZone = "America/New_York";
|
||||||
|
i18n.defaultLocale = "en_GB.UTF-8";
|
||||||
|
}
|
||||||
32
hosts/pikachu/disk.nix
Normal file
32
hosts/pikachu/disk.nix
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{ ... }:
|
||||||
|
# pikachu's install layout for disko: one NVMe boot disk with an EFI system partition and ext4 root.
|
||||||
|
# The existing 8 TB ZFS mirror is imported by name and is never declared here.
|
||||||
|
{
|
||||||
|
disko.devices.disk.main = {
|
||||||
|
type = "disk";
|
||||||
|
device = "/dev/nvme0n1";
|
||||||
|
content = {
|
||||||
|
type = "gpt";
|
||||||
|
partitions = {
|
||||||
|
ESP = {
|
||||||
|
size = "2G";
|
||||||
|
type = "EF00";
|
||||||
|
content = {
|
||||||
|
type = "filesystem";
|
||||||
|
format = "vfat";
|
||||||
|
mountpoint = "/boot";
|
||||||
|
mountOptions = [ "umask=0077" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
root = {
|
||||||
|
size = "100%";
|
||||||
|
content = {
|
||||||
|
type = "filesystem";
|
||||||
|
format = "ext4";
|
||||||
|
mountpoint = "/";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
18
hosts/pikachu/hardware-configuration.nix
Normal file
18
hosts/pikachu/hardware-configuration.nix
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{ lib, modulesPath, ... }:
|
||||||
|
# Hardware detected from the Proxmox inventory for this machine.
|
||||||
|
# disko derives the root disk filesystems, none declared here.
|
||||||
|
{
|
||||||
|
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
|
||||||
|
|
||||||
|
boot.initrd.availableKernelModules = [
|
||||||
|
"ahci"
|
||||||
|
"nvme"
|
||||||
|
"sd_mod"
|
||||||
|
"xhci_pci"
|
||||||
|
];
|
||||||
|
boot.initrd.kernelModules = [ ];
|
||||||
|
boot.kernelModules = [ "kvm-intel" ];
|
||||||
|
boot.extraModulePackages = [ ];
|
||||||
|
|
||||||
|
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
|
||||||
|
}
|
||||||
1
hosts/pikachu/ssh_host_ed25519_key.pub
Normal file
1
hosts/pikachu/ssh_host_ed25519_key.pub
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu
|
||||||
1
hosts/pikachu/ssh_host_rsa_key.pub
Normal file
1
hosts/pikachu/ssh_host_rsa_key.pub
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy/riwm7dflA3mT+3a0/2CIoS2LbAsK/vn35kOoNeuzn0yhiF+imexP6tkB3S2t+H5ybRzkbbuNZcynFfeCqthFc8kvbdCnt8Diqoeg96fZ6ecvh5QE5yH9op8534EySetZ/exakFLnF+6EiWMuWUW3DFwsc2kcgDJObqSE8gTx/d7JK953MiTFmSJBFyg1RtQ3ZnMT+iCrvY2dyCLQai7VeF8koVKF2c0leAq2Hc75rb/L9md8MoJa64iPiz7hwTCin3xoFyaY/5hNVvyqFd5PivgR69gLdJkuVsUYO2mJzhur8cYmJD+pGjJ0U45hyE9TMrCFjeJHHuvSt3+2kph62wv95jLNk0WmMlwgyunISxENCSVVtNYdBMXhUh8VhEAW17QpVUg9EnPvxOdTKEjrvfOZYASWUa51JKbgBgexVgFbxdjDZR88DZa31AVBts/cx/59gXTUahFXMYLdZgssx+5uibZQWnvCyfUV9WLbfmK1lgL6hzReg1VkQ87iGr6skjtQYemJxRaFNA1+Q5f3kmG3KncuK/594a3qXYP4gC6A2blf8om1YZ4aXXh6f+GFKLjoEw1vvM2rJ+rjzfymwDX+pxVQ9L13OEtVZc9Ez76pOkbm1hqdbL0gY45+0cpxodhWV0wMQJBDXL1MHP8qcs+/vw0GxVK5l1SnWBGlw== root@pikachu
|
||||||
254
modules/agents/pi/extensions/compact-status.ts
Normal file
254
modules/agents/pi/extensions/compact-status.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||||
|
|
||||||
|
type QuotaState =
|
||||||
|
| { status: "idle" | "loading" }
|
||||||
|
| { status: "ok"; detail: string; refreshedAt: number; weeklyRemaining?: number; shortRemaining?: number }
|
||||||
|
| { status: "missing" | "error"; detail: string; refreshedAt?: number };
|
||||||
|
|
||||||
|
const CODEX_USAGE_ENDPOINTS = [
|
||||||
|
"https://chatgpt.com/backend-api/wham/usage",
|
||||||
|
"https://chatgpt.com/backend-api/codex/usage",
|
||||||
|
];
|
||||||
|
const QUOTA_REFRESH_MS = 5 * 60 * 1000;
|
||||||
|
const REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
let quotaState: QuotaState = { status: "idle" };
|
||||||
|
let quotaRefreshPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function shortCwd(cwd: string): string {
|
||||||
|
const home = process.env.HOME;
|
||||||
|
if (home && cwd.startsWith(`${home}/`)) return `~/${basename(cwd)}`;
|
||||||
|
return basename(cwd) || cwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitBranch(cwd: string): string | null {
|
||||||
|
try {
|
||||||
|
const out = execFileSync("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
}).trim();
|
||||||
|
return out || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function authPath(): string {
|
||||||
|
return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "auth.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCodexCredentials(): { access: string; accountId?: string } | null {
|
||||||
|
const file = authPath();
|
||||||
|
if (!existsSync(file)) return null;
|
||||||
|
try {
|
||||||
|
const auth = JSON.parse(readFileSync(file, "utf8"));
|
||||||
|
const credential = auth?.["openai-codex"];
|
||||||
|
if (credential?.type !== "oauth" || typeof credential.access !== "string") return null;
|
||||||
|
if (typeof credential.expires === "number" && credential.expires <= Date.now() + 30_000) return null;
|
||||||
|
return {
|
||||||
|
access: credential.access,
|
||||||
|
accountId: typeof credential.accountId === "string" ? credential.accountId : undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown): number | undefined {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string" && value.trim() !== "") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowSeconds(raw: Record<string, unknown>): number | undefined {
|
||||||
|
const seconds = numberValue(raw.limit_window_seconds ?? raw.windowSeconds);
|
||||||
|
if (seconds !== undefined) return seconds;
|
||||||
|
const mins = numberValue(raw.windowDurationMins ?? raw.window_duration_mins);
|
||||||
|
return mins === undefined ? undefined : mins * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usedPercent(raw: Record<string, unknown>): number | undefined {
|
||||||
|
const value = numberValue(raw.used_percent ?? raw.usedPercent);
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
return Math.max(0, Math.min(100, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectWindows(raw: unknown, out: Array<{ seconds?: number; used: number; key: string }> = [], key = "root") {
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
raw.forEach((item, index) => collectWindows(item, out, `${key}.${index}`));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const obj = objectValue(raw);
|
||||||
|
if (!obj) return out;
|
||||||
|
const used = usedPercent(obj);
|
||||||
|
if (used !== undefined) out.push({ seconds: windowSeconds(obj), used, key });
|
||||||
|
for (const [childKey, value] of Object.entries(obj)) {
|
||||||
|
if (value && typeof value === "object") collectWindows(value, out, `${key}.${childKey}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickQuotaWindows(raw: unknown): { weeklyRemaining?: number; shortRemaining?: number } | null {
|
||||||
|
const windows = collectWindows(raw);
|
||||||
|
if (windows.length === 0) return null;
|
||||||
|
const weekly = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 604_800) <= 60 * 60)
|
||||||
|
?? windows.find((window) => /week|weekly|secondary/i.test(window.key));
|
||||||
|
const short = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 18_000) <= 60 * 30)
|
||||||
|
?? windows.find((window) => /five|session|primary|short/i.test(window.key));
|
||||||
|
return {
|
||||||
|
weeklyRemaining: weekly ? Math.max(0, Math.min(100, 100 - weekly.used)) : undefined,
|
||||||
|
shortRemaining: short ? Math.max(0, Math.min(100, 100 - short.used)) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFg(theme: any, color: string, text: string): string {
|
||||||
|
try {
|
||||||
|
return theme.fg(color, text);
|
||||||
|
} catch {
|
||||||
|
return theme.fg("accent", text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextColor(percent: number): string {
|
||||||
|
if (percent >= 90) return "error";
|
||||||
|
if (percent >= 70) return "warning";
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotaColor(percent: number): string {
|
||||||
|
if (percent >= 80) return "error";
|
||||||
|
if (percent >= 50) return "warning";
|
||||||
|
return "border";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bar(theme: any, width: number, percent: number | null, glyph: string, colorForPercent: (percent: number) => string): string {
|
||||||
|
const barWidth = Math.max(12, width);
|
||||||
|
if (percent === null) return theme.fg("muted", glyph.repeat(barWidth));
|
||||||
|
const clamped = Math.max(0, Math.min(100, percent));
|
||||||
|
const filled = Math.max(0, Math.min(barWidth, Math.round((clamped / 100) * barWidth)));
|
||||||
|
const empty = Math.max(0, barWidth - filled);
|
||||||
|
return safeFg(theme, colorForPercent(clamped), glyph.repeat(filled)) + theme.fg("dim", glyph.repeat(empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCodexQuota(force = false): Promise<void> {
|
||||||
|
const fresh = quotaState.status === "ok" && Date.now() - quotaState.refreshedAt < QUOTA_REFRESH_MS;
|
||||||
|
if (!force && fresh) return;
|
||||||
|
if (quotaRefreshPromise) return quotaRefreshPromise;
|
||||||
|
|
||||||
|
quotaState = { status: "loading" };
|
||||||
|
quotaRefreshPromise = (async () => {
|
||||||
|
const credentials = readCodexCredentials();
|
||||||
|
if (!credentials) {
|
||||||
|
quotaState = { status: "missing", detail: "OpenAI Codex OAuth credentials were not found or are expired" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError = "quota unavailable";
|
||||||
|
for (const endpoint of CODEX_USAGE_ENDPOINTS) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { Authorization: `Bearer ${credentials.access}` };
|
||||||
|
if (credentials.accountId) headers["ChatGPT-Account-Id"] = credentials.accountId;
|
||||||
|
const response = await fetch(endpoint, { headers, signal: controller.signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
lastError = `${response.status} ${response.statusText}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const windows = pickQuotaWindows(await response.json());
|
||||||
|
if (!windows || (windows.weeklyRemaining === undefined && windows.shortRemaining === undefined)) {
|
||||||
|
lastError = "response had no recognized quota windows";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const details = [];
|
||||||
|
if (windows.weeklyRemaining !== undefined) details.push(`weekly ${Math.round(windows.weeklyRemaining)}%`);
|
||||||
|
if (windows.shortRemaining !== undefined) details.push(`short ${Math.round(windows.shortRemaining)}%`);
|
||||||
|
quotaState = {
|
||||||
|
status: "ok",
|
||||||
|
detail: `Codex quota remaining: ${details.join(", ")}`,
|
||||||
|
weeklyRemaining: windows.weeklyRemaining,
|
||||||
|
shortRemaining: windows.shortRemaining,
|
||||||
|
refreshedAt: Date.now(),
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quotaState = { status: "error", detail: `Codex quota failed: ${lastError}`, refreshedAt: Date.now() };
|
||||||
|
})().finally(() => {
|
||||||
|
quotaRefreshPromise = null;
|
||||||
|
});
|
||||||
|
return quotaRefreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLines(ctx: any, theme: any, width: number): string[] {
|
||||||
|
const cwd = ctx.sessionManager?.getCwd?.() ?? ctx.cwd ?? process.cwd();
|
||||||
|
const branch = gitBranch(cwd);
|
||||||
|
const where = branch ? ` ${shortCwd(cwd)} ${branch}` : ` ${shortCwd(cwd)}`;
|
||||||
|
const model = ctx.model?.id ?? process.env.PI_MODEL ?? "no-model";
|
||||||
|
const thinking = ctx.thinkingLevel ?? process.env.PI_REASONING_LEVEL ?? "off";
|
||||||
|
const left = theme.fg("accent", where);
|
||||||
|
const right = theme.fg("dim", `${model} • ${thinking}`);
|
||||||
|
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
|
||||||
|
const contextPercentRaw = ctx.getContextUsage?.()?.percent;
|
||||||
|
const contextPercent = typeof contextPercentRaw === "number" && Number.isFinite(contextPercentRaw) ? contextPercentRaw : null;
|
||||||
|
const quotaConsumed = quotaState.status === "ok" && quotaState.weeklyRemaining !== undefined
|
||||||
|
? 100 - quotaState.weeklyRemaining
|
||||||
|
: null;
|
||||||
|
return [
|
||||||
|
truncateToWidth(left + pad + right, width),
|
||||||
|
bar(theme, width, contextPercent, "▃", contextColor),
|
||||||
|
bar(theme, width, quotaConsumed, "▔", quotaColor),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCompactStatusUi(ctx: any) {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
ctx.ui.setWidget("compact-status", (_tui: any, theme: any) => ({
|
||||||
|
invalidate() {},
|
||||||
|
render(width: number) {
|
||||||
|
return statusLines(ctx, theme, width);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
ctx.ui.setFooter(() => ({ invalidate() {}, render: () => [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function compactStatus(pi: ExtensionAPI) {
|
||||||
|
function refreshUi(ctx: any) {
|
||||||
|
setCompactStatusUi(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.on("session_start", (_event, ctx) => {
|
||||||
|
refreshUi(ctx);
|
||||||
|
void fetchCodexQuota(false).then(() => refreshUi(ctx));
|
||||||
|
});
|
||||||
|
pi.on("model_select", (_event, ctx) => refreshUi(ctx));
|
||||||
|
pi.on("agent_settled", (_event, ctx) => refreshUi(ctx));
|
||||||
|
|
||||||
|
pi.registerCommand("codex-quota", {
|
||||||
|
description: "Refresh and show ChatGPT Codex quota",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
refreshUi(ctx);
|
||||||
|
await fetchCodexQuota(true);
|
||||||
|
refreshUi(ctx);
|
||||||
|
const level = quotaState.status === "ok" ? "info" : quotaState.status === "missing" ? "warning" : "error";
|
||||||
|
ctx.ui.notify(quotaState.status === "idle" || quotaState.status === "loading" ? "Codex quota refresh in progress" : quotaState.detail, level);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
141
modules/agents/pi/extensions/subagents/agents.ts
Normal file
141
modules/agents/pi/extensions/subagents/agents.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
import type { ContextMode } from "./types.ts";
|
||||||
|
import type { Diagnostics } from "./config.ts";
|
||||||
|
|
||||||
|
export interface AgentDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
body: string;
|
||||||
|
context?: ContextMode;
|
||||||
|
model?: string;
|
||||||
|
thinking?: string;
|
||||||
|
tools?: string;
|
||||||
|
allowedContexts?: ContextMode[];
|
||||||
|
hidden?: boolean;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAgents(cwd: string, projectTrusted: boolean, diagnostics: Diagnostics, agentDir = defaultAgentDir()): Map<string, AgentDefinition> {
|
||||||
|
const user = loadTier(join(agentDir, "agents"), "user", diagnostics);
|
||||||
|
const project = projectTrusted ? loadTier(join(cwd, ".pi", "agents"), "project", diagnostics) : new Map<string, AgentDefinition>();
|
||||||
|
return new Map([...user, ...project]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTier(dir: string, tier: string, diagnostics: Diagnostics): Map<string, AgentDefinition> {
|
||||||
|
const agents = new Map<string, AgentDefinition>();
|
||||||
|
if (!existsSync(dir)) return agents;
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
const parsed = parseAgent(path, diagnostics);
|
||||||
|
if (!parsed) continue;
|
||||||
|
if (agents.has(parsed.name)) {
|
||||||
|
diagnostics.warnings.push(`Duplicate ${tier} agent '${parsed.name}' ignored at ${path}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const stem = basename(entry.name, ".md");
|
||||||
|
if (stem !== parsed.name) diagnostics.warnings.push(`${tier} agent file '${entry.name}' name '${parsed.name}' does not match filename`);
|
||||||
|
agents.set(parsed.name, parsed);
|
||||||
|
}
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAgent(path: string, diagnostics: Diagnostics): AgentDefinition | undefined {
|
||||||
|
try {
|
||||||
|
const text = readFileSync(path, "utf8");
|
||||||
|
const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/u.exec(text);
|
||||||
|
if (!match) {
|
||||||
|
diagnostics.warnings.push(`Agent ${path} missing YAML frontmatter`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const frontmatter = parseFrontmatter(match[1]);
|
||||||
|
const name = stringField(frontmatter, "name");
|
||||||
|
const description = stringField(frontmatter, "description");
|
||||||
|
if (!name || !/^[a-z0-9-]+$/.test(name)) {
|
||||||
|
diagnostics.warnings.push(`Agent ${path} has invalid name`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!description) {
|
||||||
|
diagnostics.warnings.push(`Agent ${path} has invalid description`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const context = contextField(frontmatter.context);
|
||||||
|
const allowedContexts = contextsField(frontmatter.allowedContexts);
|
||||||
|
if (frontmatter.context !== undefined && !context) diagnostics.warnings.push(`Agent ${path} has invalid context`);
|
||||||
|
if (frontmatter.allowedContexts !== undefined && !allowedContexts) diagnostics.warnings.push(`Agent ${path} has invalid allowedContexts`);
|
||||||
|
if (context && allowedContexts && !allowedContexts.includes(context)) diagnostics.warnings.push(`Agent ${path} context is outside allowedContexts`);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
body: match[2].trim(),
|
||||||
|
context,
|
||||||
|
model: stringField(frontmatter, "model"),
|
||||||
|
thinking: stringField(frontmatter, "thinking"),
|
||||||
|
tools: stringField(frontmatter, "tools"),
|
||||||
|
allowedContexts,
|
||||||
|
hidden: booleanField(frontmatter, "hidden"),
|
||||||
|
source: path,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
diagnostics.warnings.push(`Failed to load agent ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontmatter(text: string): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
const lines = text.split(/\r?\n/u);
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (!line.trim() || line.trimStart().startsWith("#")) continue;
|
||||||
|
const scalar = /^(\w+):\s*(.*?)\s*$/u.exec(line);
|
||||||
|
if (!scalar) continue;
|
||||||
|
const [, key, raw] = scalar;
|
||||||
|
if (raw !== "") {
|
||||||
|
result[key] = parseScalar(raw);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const values: string[] = [];
|
||||||
|
while (i + 1 < lines.length) {
|
||||||
|
const item = /^\s+-\s*(.*?)\s*$/u.exec(lines[i + 1]);
|
||||||
|
if (!item) break;
|
||||||
|
values.push(String(parseScalar(item[1])));
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
result[key] = values;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScalar(raw: string): string | boolean {
|
||||||
|
const unquoted = raw.replace(/^['"]|['"]$/gu, "");
|
||||||
|
if (unquoted === "true") return true;
|
||||||
|
if (unquoted === "false") return false;
|
||||||
|
return unquoted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(record: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
const value = record[key];
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanField(record: Record<string, unknown>, key: string): boolean | undefined {
|
||||||
|
const value = record[key];
|
||||||
|
return typeof value === "boolean" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextField(value: unknown): ContextMode | undefined {
|
||||||
|
return value === "independent" || value === "fork" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextsField(value: unknown): ContextMode[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
const contexts = value.map(contextField);
|
||||||
|
return contexts.every(Boolean) ? (contexts as ContextMode[]) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultAgentDir(): string {
|
||||||
|
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
||||||
|
}
|
||||||
139
modules/agents/pi/extensions/subagents/config.test.ts
Normal file
139
modules/agents/pi/extensions/subagents/config.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { loadAgents } from "./agents.ts";
|
||||||
|
import { BUILT_IN_TOOL_PROFILES, loadConfig, resolveSpawn, type Diagnostics } from "./config.ts";
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "subagents-config-"));
|
||||||
|
const agentDir = join(root, "agent");
|
||||||
|
const cwd = join(root, "project");
|
||||||
|
mkdirSync(agentDir, { recursive: true });
|
||||||
|
mkdirSync(cwd, { recursive: true });
|
||||||
|
return { root, agentDir, cwd };
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnostics(): Diagnostics {
|
||||||
|
return { warnings: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("missing config files and agent directories are normal", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
const diag = diagnostics();
|
||||||
|
|
||||||
|
const config = loadConfig(cwd, true, diag, agentDir);
|
||||||
|
const agents = loadAgents(cwd, true, diag, agentDir);
|
||||||
|
|
||||||
|
assert.equal(config.defaultContext, "independent");
|
||||||
|
assert.equal(config.defaultTools, "read-only");
|
||||||
|
assert.equal(config.recentTerminalTtlMs, 300000);
|
||||||
|
assert.equal(agents.size, 0);
|
||||||
|
assert.deepEqual(diag.warnings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("global and trusted project config merge in order", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
||||||
|
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ defaultTools: "global-profile", recentTerminalTtlMs: 1000, toolProfiles: { "global-profile": { activeTools: ["read"] } } }));
|
||||||
|
writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ defaultTools: "project-profile", recentTerminalTtlMs: 2000, toolProfiles: { "project-profile": { activeTools: ["ls"] } } }));
|
||||||
|
|
||||||
|
const config = loadConfig(cwd, true, diagnostics(), agentDir);
|
||||||
|
|
||||||
|
assert.equal(config.defaultTools, "project-profile");
|
||||||
|
assert.equal(config.recentTerminalTtlMs, 2000);
|
||||||
|
assert.deepEqual(config.toolProfiles["global-profile"].activeTools, ["read"]);
|
||||||
|
assert.deepEqual(config.toolProfiles["project-profile"].activeTools, ["ls"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recent terminal ttl preserves zero and rejects invalid values", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ recentTerminalTtlMs: 0 }));
|
||||||
|
const zeroDiag = diagnostics();
|
||||||
|
|
||||||
|
const zeroConfig = loadConfig(cwd, true, zeroDiag, agentDir);
|
||||||
|
|
||||||
|
assert.equal(zeroConfig.recentTerminalTtlMs, 0);
|
||||||
|
assert.deepEqual(zeroDiag.warnings, []);
|
||||||
|
|
||||||
|
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ recentTerminalTtlMs: -1 }));
|
||||||
|
const invalidDiag = diagnostics();
|
||||||
|
|
||||||
|
const invalidConfig = loadConfig(cwd, true, invalidDiag, agentDir);
|
||||||
|
|
||||||
|
assert.equal(invalidConfig.recentTerminalTtlMs, 300000);
|
||||||
|
assert.ok(invalidDiag.warnings.some((warning) => warning.includes("Invalid global recentTerminalTtlMs ignored")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("project config is ignored when project is untrusted", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
||||||
|
writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ defaultTools: "project-profile", toolProfiles: { "project-profile": { activeTools: ["ls"] } } }));
|
||||||
|
|
||||||
|
const config = loadConfig(cwd, false, diagnostics(), agentDir);
|
||||||
|
|
||||||
|
assert.equal(config.defaultTools, "read-only");
|
||||||
|
assert.equal(config.toolProfiles["project-profile"], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("agents load with project precedence over user", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
mkdirSync(join(agentDir, "agents"), { recursive: true });
|
||||||
|
mkdirSync(join(cwd, ".pi", "agents"), { recursive: true });
|
||||||
|
writeFileSync(join(agentDir, "agents", "review.md"), "---\nname: review\ndescription: User review\ntools: read-only\n---\nuser body\n");
|
||||||
|
writeFileSync(join(cwd, ".pi", "agents", "review.md"), "---\nname: review\ndescription: Project review\ntools: full-tools\n---\nproject body\n");
|
||||||
|
|
||||||
|
const agents = loadAgents(cwd, true, diagnostics(), agentDir);
|
||||||
|
|
||||||
|
assert.equal(agents.get("review")?.description, "Project review");
|
||||||
|
assert.equal(agents.get("review")?.body, "project body");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duplicate same-tier definitions and invalid frontmatter produce diagnostics", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
const dir = join(agentDir, "agents");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(join(dir, "one.md"), "---\nname: same\ndescription: One\n---\none\n");
|
||||||
|
writeFileSync(join(dir, "two.md"), "---\nname: same\ndescription: Two\n---\ntwo\n");
|
||||||
|
writeFileSync(join(dir, "bad.md"), "---\nname: Bad Name\n---\nbad\n");
|
||||||
|
const diag = diagnostics();
|
||||||
|
|
||||||
|
const agents = loadAgents(cwd, true, diag, agentDir);
|
||||||
|
|
||||||
|
assert.equal(agents.size, 1);
|
||||||
|
assert.ok(diag.warnings.some((warning) => warning.includes("Duplicate user agent 'same'")));
|
||||||
|
assert.ok(diag.warnings.some((warning) => warning.includes("invalid name")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("named spawn resolves overrides, frontmatter, config, and defaults", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
mkdirSync(join(agentDir, "agents"), { recursive: true });
|
||||||
|
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ defaultTools: "local-review", toolProfiles: { "local-review": { activeTools: ["read"] } } }));
|
||||||
|
writeFileSync(join(agentDir, "agents", "review.md"), "---\nname: review\ndescription: Review\ncontext: independent\nmodel: inherit\nthinking: high\ntools: local-review\n---\nagent body\n");
|
||||||
|
const diag = diagnostics();
|
||||||
|
const config = loadConfig(cwd, true, diag, agentDir);
|
||||||
|
const agents = loadAgents(cwd, true, diag, agentDir);
|
||||||
|
|
||||||
|
const resolved = resolveSpawn({ agent: "review", prompt: "check this", label: "Review migration", thinking: "low" }, config, agents);
|
||||||
|
|
||||||
|
assert.equal(resolved.prompt, "check this");
|
||||||
|
assert.equal(resolved.label, "Review migration");
|
||||||
|
assert.equal(resolved.context, "independent");
|
||||||
|
assert.equal(resolved.model, "inherit");
|
||||||
|
assert.equal(resolved.thinking, "low");
|
||||||
|
assert.equal(resolved.tools, "local-review");
|
||||||
|
assert.deepEqual(resolved.toolProfile.activeTools, ["read"]);
|
||||||
|
assert.equal(resolved.agentBody, "agent body");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("built-in tool profile names cannot be overridden", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ toolProfiles: { "read-only": { activeTools: ["bash"] } } }));
|
||||||
|
const diag = diagnostics();
|
||||||
|
|
||||||
|
const config = loadConfig(cwd, true, diag, agentDir);
|
||||||
|
|
||||||
|
assert.deepEqual(config.toolProfiles["read-only"], BUILT_IN_TOOL_PROFILES["read-only"]);
|
||||||
|
assert.ok(diag.warnings.some((warning) => warning.includes("Ignoring global override for built-in tool profile 'read-only'")));
|
||||||
|
});
|
||||||
182
modules/agents/pi/extensions/subagents/config.ts
Normal file
182
modules/agents/pi/extensions/subagents/config.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { ContextMode, SpawnRequest, ToolProfile } from "./types.ts";
|
||||||
|
import type { AgentDefinition } from "./agents.ts";
|
||||||
|
|
||||||
|
export interface Diagnostics {
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentsConfig {
|
||||||
|
defaultContext: ContextMode;
|
||||||
|
defaultTools: string;
|
||||||
|
maxConcurrent: number;
|
||||||
|
recentTerminalTtlMs: number;
|
||||||
|
ui: {
|
||||||
|
enabled: boolean;
|
||||||
|
defaultExpanded: boolean;
|
||||||
|
};
|
||||||
|
toolProfiles: Record<string, ToolProfile>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedSpawnRequest extends SpawnRequest {
|
||||||
|
prompt: string;
|
||||||
|
context: ContextMode;
|
||||||
|
tools: string;
|
||||||
|
toolProfile: ToolProfile;
|
||||||
|
agentBody?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BUILT_IN_TOOL_PROFILES: Record<string, ToolProfile> = {
|
||||||
|
none: { activeTools: [] },
|
||||||
|
"read-only": { activeTools: ["read", "grep", "find", "ls"] },
|
||||||
|
"read-only-with-safe-bash": { activeTools: ["read", "grep", "find", "ls", "bash"] },
|
||||||
|
"full-tools": { activeTools: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: SubagentsConfig = {
|
||||||
|
defaultContext: "independent",
|
||||||
|
defaultTools: "read-only",
|
||||||
|
maxConcurrent: 3,
|
||||||
|
recentTerminalTtlMs: 5 * 60 * 1000,
|
||||||
|
ui: { enabled: true, defaultExpanded: false },
|
||||||
|
toolProfiles: { ...BUILT_IN_TOOL_PROFILES },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadConfig(cwd: string, projectTrusted: boolean, diagnostics: Diagnostics, agentDir = defaultAgentDir()): SubagentsConfig {
|
||||||
|
let config = cloneConfig(DEFAULT_CONFIG);
|
||||||
|
config = mergeConfig(config, readConfig(join(agentDir, "subagents.json"), diagnostics, "global"), diagnostics, "global");
|
||||||
|
if (projectTrusted) {
|
||||||
|
config = mergeConfig(config, readConfig(join(cwd, ".pi", "subagents.json"), diagnostics, "project"), diagnostics, "project");
|
||||||
|
}
|
||||||
|
if (!config.toolProfiles[config.defaultTools]) {
|
||||||
|
diagnostics.warnings.push(`Unknown defaultTools profile '${config.defaultTools}', using read-only`);
|
||||||
|
config.defaultTools = "read-only";
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSpawn(request: SpawnRequest, config: SubagentsConfig, agents: Map<string, AgentDefinition>): ResolvedSpawnRequest {
|
||||||
|
const prompt = typeof request.prompt === "string" ? request.prompt.trim() : "";
|
||||||
|
if (!prompt) throw new Error("prompt is required");
|
||||||
|
const agent = request.agent ? agents.get(request.agent) : undefined;
|
||||||
|
if (request.agent && !agent) throw new Error(`unknown subagent agent: ${request.agent}`);
|
||||||
|
|
||||||
|
const context = request.context ?? agent?.context ?? config.defaultContext;
|
||||||
|
if (context !== "independent" && context !== "fork") throw new Error(`unsupported context: ${context}`);
|
||||||
|
if (agent?.allowedContexts && !agent.allowedContexts.includes(context)) {
|
||||||
|
throw new Error(`agent '${agent.name}' does not allow ${context} context`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = request.tools ?? agent?.tools ?? config.defaultTools;
|
||||||
|
const toolProfile = config.toolProfiles[tools];
|
||||||
|
if (!toolProfile) throw new Error(`unknown tool profile: ${tools}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...request,
|
||||||
|
prompt,
|
||||||
|
agent: agent?.name ?? request.agent,
|
||||||
|
context,
|
||||||
|
model: request.model ?? agent?.model,
|
||||||
|
thinking: request.thinking ?? agent?.thinking,
|
||||||
|
tools,
|
||||||
|
toolProfile,
|
||||||
|
agentBody: agent?.body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConfig(path: string, diagnostics: Diagnostics, label: string): Partial<SubagentsConfig> | undefined {
|
||||||
|
if (!existsSync(path)) return undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
return normalizeConfig(parsed, diagnostics, label);
|
||||||
|
} catch (error) {
|
||||||
|
diagnostics.warnings.push(`Invalid ${label} subagents.json: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfig(raw: unknown, diagnostics: Diagnostics, label: string): Partial<SubagentsConfig> | undefined {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
diagnostics.warnings.push(`Invalid ${label} subagents.json: root must be an object`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const input = raw as Record<string, unknown>;
|
||||||
|
const config: Partial<SubagentsConfig> = {};
|
||||||
|
if (input.defaultContext === "independent" || input.defaultContext === "fork") config.defaultContext = input.defaultContext;
|
||||||
|
else if (input.defaultContext !== undefined) diagnostics.warnings.push(`Invalid ${label} defaultContext ignored`);
|
||||||
|
if (typeof input.defaultTools === "string") config.defaultTools = input.defaultTools;
|
||||||
|
else if (input.defaultTools !== undefined) diagnostics.warnings.push(`Invalid ${label} defaultTools ignored`);
|
||||||
|
if (typeof input.maxConcurrent === "number" && Number.isInteger(input.maxConcurrent) && input.maxConcurrent > 0) config.maxConcurrent = input.maxConcurrent;
|
||||||
|
else if (input.maxConcurrent !== undefined) diagnostics.warnings.push(`Invalid ${label} maxConcurrent ignored`);
|
||||||
|
if (typeof input.recentTerminalTtlMs === "number" && Number.isInteger(input.recentTerminalTtlMs) && input.recentTerminalTtlMs >= 0) {
|
||||||
|
config.recentTerminalTtlMs = input.recentTerminalTtlMs;
|
||||||
|
} else if (input.recentTerminalTtlMs !== undefined) diagnostics.warnings.push(`Invalid ${label} recentTerminalTtlMs ignored`);
|
||||||
|
if (input.ui !== undefined) config.ui = normalizeUi(input.ui, diagnostics, label);
|
||||||
|
if (input.toolProfiles !== undefined) config.toolProfiles = normalizeProfiles(input.toolProfiles, diagnostics, label);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUi(raw: unknown, diagnostics: Diagnostics, label: string): SubagentsConfig["ui"] | undefined {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
diagnostics.warnings.push(`Invalid ${label} ui ignored`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const input = raw as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
enabled: typeof input.enabled === "boolean" ? input.enabled : DEFAULT_CONFIG.ui.enabled,
|
||||||
|
defaultExpanded: typeof input.defaultExpanded === "boolean" ? input.defaultExpanded : DEFAULT_CONFIG.ui.defaultExpanded,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProfiles(raw: unknown, diagnostics: Diagnostics, label: string): Record<string, ToolProfile> {
|
||||||
|
const profiles: Record<string, ToolProfile> = {};
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
diagnostics.warnings.push(`Invalid ${label} toolProfiles ignored`);
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
for (const [name, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
if (name in BUILT_IN_TOOL_PROFILES) {
|
||||||
|
diagnostics.warnings.push(`Ignoring ${label} override for built-in tool profile '${name}'`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const profile = normalizeProfile(value);
|
||||||
|
if (!profile) {
|
||||||
|
diagnostics.warnings.push(`Invalid ${label} tool profile '${name}' ignored`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
profiles[name] = profile;
|
||||||
|
}
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProfile(raw: unknown): ToolProfile | undefined {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||||
|
const activeTools = (raw as { activeTools?: unknown }).activeTools;
|
||||||
|
if (!Array.isArray(activeTools) || !activeTools.every((tool) => typeof tool === "string")) return undefined;
|
||||||
|
return { activeTools };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeConfig(base: SubagentsConfig, override: Partial<SubagentsConfig> | undefined, diagnostics: Diagnostics, label: string): SubagentsConfig {
|
||||||
|
if (!override) return base;
|
||||||
|
const merged = cloneConfig(base);
|
||||||
|
if (override.defaultContext) merged.defaultContext = override.defaultContext;
|
||||||
|
if (override.defaultTools) merged.defaultTools = override.defaultTools;
|
||||||
|
if (override.maxConcurrent) merged.maxConcurrent = override.maxConcurrent;
|
||||||
|
if (override.recentTerminalTtlMs !== undefined) merged.recentTerminalTtlMs = override.recentTerminalTtlMs;
|
||||||
|
if (override.ui) merged.ui = { ...merged.ui, ...override.ui };
|
||||||
|
if (override.toolProfiles) merged.toolProfiles = { ...merged.toolProfiles, ...override.toolProfiles };
|
||||||
|
for (const key of Object.keys(merged.toolProfiles)) {
|
||||||
|
if (key in BUILT_IN_TOOL_PROFILES) merged.toolProfiles[key] = BUILT_IN_TOOL_PROFILES[key];
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneConfig(config: SubagentsConfig): SubagentsConfig {
|
||||||
|
return { ...config, ui: { ...config.ui }, toolProfiles: { ...config.toolProfiles } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultAgentDir(): string {
|
||||||
|
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
||||||
|
}
|
||||||
326
modules/agents/pi/extensions/subagents/index.ts
Normal file
326
modules/agents/pi/extensions/subagents/index.ts
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { Type } from "typebox";
|
||||||
|
import { loadAgents } from "./agents.ts";
|
||||||
|
import { loadConfig, resolveSpawn, type Diagnostics } from "./config.ts";
|
||||||
|
import { SubprocessRpcRunner } from "./runner.ts";
|
||||||
|
import { Supervisor } from "./supervisor.ts";
|
||||||
|
import { milestoneNotification } from "./status.ts";
|
||||||
|
import type { SpawnRequest, SubagentStatus } from "./types.ts";
|
||||||
|
import { widget } from "./ui.ts";
|
||||||
|
|
||||||
|
let supervisor: Supervisor | undefined;
|
||||||
|
let lastDiagnostics: Diagnostics = { warnings: [] };
|
||||||
|
let lastStatuses: SubagentStatus[] = [];
|
||||||
|
let uiExpanded = false;
|
||||||
|
|
||||||
|
export default function subagents(pi: ExtensionAPI) {
|
||||||
|
const getSupervisor = (ctx: ExtensionContext): Supervisor => {
|
||||||
|
if (supervisor) return supervisor;
|
||||||
|
const diagnostics: Diagnostics = { warnings: [] };
|
||||||
|
const cwd = cwdOf(ctx);
|
||||||
|
const config = loadConfig(cwd, isProjectTrusted(ctx), diagnostics);
|
||||||
|
lastDiagnostics = diagnostics;
|
||||||
|
uiExpanded = config.ui.defaultExpanded;
|
||||||
|
supervisor = new Supervisor(new SubprocessRpcRunner(), cwd, {
|
||||||
|
maxConcurrent: config.maxConcurrent,
|
||||||
|
recentTerminalTtlMs: config.recentTerminalTtlMs,
|
||||||
|
onMilestone: (status, event) => {
|
||||||
|
pi.appendEntry("subagent_milestone", { event, status });
|
||||||
|
const notification = milestoneNotification(status, event);
|
||||||
|
if (notification) ctx.ui?.notify?.(notification.message, notification.level);
|
||||||
|
},
|
||||||
|
onChange: (statuses) => {
|
||||||
|
lastStatuses = statuses;
|
||||||
|
updateUi(ctx, config.ui.enabled);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
updateUi(ctx, config.ui.enabled);
|
||||||
|
return supervisor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolve = (ctx: ExtensionContext, request: SpawnRequest): SpawnRequest => {
|
||||||
|
const diagnostics: Diagnostics = { warnings: [] };
|
||||||
|
const cwd = cwdOf(ctx);
|
||||||
|
const trusted = isProjectTrusted(ctx);
|
||||||
|
const config = loadConfig(cwd, trusted, diagnostics);
|
||||||
|
const agents = loadAgents(cwd, trusted, diagnostics);
|
||||||
|
lastDiagnostics = diagnostics;
|
||||||
|
const resolved = resolveSpawn(request, config, agents);
|
||||||
|
if (resolved.context === "fork") resolved.parentSessionFile = ctx.sessionManager.getSessionFile();
|
||||||
|
return resolved;
|
||||||
|
};
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_spawn",
|
||||||
|
label: "Spawn subagent",
|
||||||
|
description: "Start one ad hoc independent subagent and return immediately with its child id",
|
||||||
|
parameters: Type.Object({
|
||||||
|
prompt: Type.String({ description: "Prompt for the delegated subagent" }),
|
||||||
|
label: Type.Optional(Type.String({ description: "Human-readable label for this work item" })),
|
||||||
|
agent: Type.Optional(Type.String({ description: "Named agent definition to use" })),
|
||||||
|
context: Type.Optional(Type.Union([Type.Literal("independent"), Type.Literal("fork")])),
|
||||||
|
model: Type.Optional(Type.String({ description: "Optional model selector for the child" })),
|
||||||
|
thinking: Type.Optional(Type.String({ description: "Optional thinking level for the child" })),
|
||||||
|
tools: Type.Optional(Type.String({ description: "Tool profile name" })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const accepted = getSupervisor(ctx).spawn(resolve(ctx, params as SpawnRequest));
|
||||||
|
ctx.ui?.notify?.(`Started subagent ${accepted.label}`, "info");
|
||||||
|
return textResult(accepted);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_batch",
|
||||||
|
label: "Spawn subagent batch",
|
||||||
|
description: "Start multiple subagents and return immediately with accepted child ids and per-entry failures",
|
||||||
|
parameters: Type.Object({
|
||||||
|
subagents: Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
prompt: Type.String({ description: "Prompt for the delegated subagent" }),
|
||||||
|
label: Type.Optional(Type.String({ description: "Human-readable label for this work item" })),
|
||||||
|
agent: Type.Optional(Type.String({ description: "Named agent definition to use" })),
|
||||||
|
context: Type.Optional(Type.Union([Type.Literal("independent"), Type.Literal("fork")])),
|
||||||
|
model: Type.Optional(Type.String({ description: "Optional model selector for the child" })),
|
||||||
|
thinking: Type.Optional(Type.String({ description: "Optional thinking level for the child" })),
|
||||||
|
tools: Type.Optional(Type.String({ description: "Tool profile name" })),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const requests = Array.isArray((params as { subagents?: unknown }).subagents) ? ((params as { subagents: SpawnRequest[] }).subagents) : [];
|
||||||
|
const accepted: SpawnRequest[] = [];
|
||||||
|
const failed: Array<{ index: number; error: string }> = [];
|
||||||
|
requests.forEach((request, index) => {
|
||||||
|
try {
|
||||||
|
accepted.push(resolve(ctx, request));
|
||||||
|
} catch (error) {
|
||||||
|
failed.push({ index, error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const result = getSupervisor(ctx).spawnBatch(accepted);
|
||||||
|
return textResult({ accepted: result.accepted, failed: [...failed, ...result.failed] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_list",
|
||||||
|
label: "List subagents",
|
||||||
|
description: "List active and terminal subagents for this parent session until terminal entries are cleared",
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||||
|
return textResult(getSupervisor(ctx).list());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_status",
|
||||||
|
label: "Get subagent status",
|
||||||
|
description: "Get current lifecycle status for one subagent",
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: "Subagent id returned by subagent_spawn" }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
return textResult(getSupervisor(ctx).status(String((params as { id: unknown }).id)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_result",
|
||||||
|
label: "Get subagent result",
|
||||||
|
description: "Return still-running before completion and the final answer after completion",
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: "Subagent id returned by subagent_spawn" }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
return textResult(getSupervisor(ctx).result(String((params as { id: unknown }).id)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_wait",
|
||||||
|
label: "Wait for subagents",
|
||||||
|
description: "Block until multiple subagents are terminal or a timeout expires. Prefer setting timeoutMs so the parent turn cannot hang forever",
|
||||||
|
parameters: Type.Object({
|
||||||
|
ids: Type.Array(Type.String({ description: "Subagent id returned by subagent_spawn or subagent_batch" })),
|
||||||
|
timeoutMs: Type.Optional(Type.Number({ description: "Maximum milliseconds to wait. Omit or use 0 to wait indefinitely" })),
|
||||||
|
mode: Type.Optional(Type.Union([Type.Literal("all"), Type.Literal("any")], { description: "Wait for all ids by default, or return after any id is terminal" })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||||
|
const input = params as { ids?: unknown; timeoutMs?: unknown; mode?: unknown };
|
||||||
|
const ids = Array.isArray(input.ids) ? input.ids.map(String) : [];
|
||||||
|
const timeoutMs = typeof input.timeoutMs === "number" && Number.isFinite(input.timeoutMs) ? input.timeoutMs : undefined;
|
||||||
|
const mode = input.mode === "any" ? "any" : "all";
|
||||||
|
return textResult(await getSupervisor(ctx).wait(ids, { timeoutMs, mode, signal }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_cancel",
|
||||||
|
label: "Cancel subagent",
|
||||||
|
description: "Cancel a running subagent",
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: "Subagent id returned by subagent_spawn" }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
return textResult(await getSupervisor(ctx).cancel(String((params as { id: unknown }).id)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_clear",
|
||||||
|
label: "Clear terminal subagents",
|
||||||
|
description: "Remove terminal subagents from the current-session visible work set. Omitting ids clears all terminal children",
|
||||||
|
parameters: Type.Object({
|
||||||
|
ids: Type.Optional(Type.Array(Type.String({ description: "Subagent id returned by subagent_spawn or subagent_batch" }))),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const input = params as { ids?: unknown };
|
||||||
|
const ids = Array.isArray(input.ids) ? input.ids.map(String) : undefined;
|
||||||
|
return textResult({ cleared: getSupervisor(ctx).clearTerminal(ids) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-spawn", {
|
||||||
|
description: "Start an ad hoc independent subagent",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const accepted = getSupervisor(ctx).spawn(resolve(ctx, parseSpawnArgs(args)));
|
||||||
|
ctx.ui.notify(`Started subagent ${accepted.label}`, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-batch", {
|
||||||
|
description: "Start ad hoc independent subagents split by |",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const requests = args
|
||||||
|
.split("|")
|
||||||
|
.map((prompt) => prompt.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((prompt) => resolve(ctx, { prompt }));
|
||||||
|
ctx.ui.notify(JSON.stringify(getSupervisor(ctx).spawnBatch(requests), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-list", {
|
||||||
|
description: "Show subagent status records",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
ctx.ui.notify(JSON.stringify(getSupervisor(ctx).list(), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-clear", {
|
||||||
|
description: "Clear terminal subagent records. Pass ids to clear selected terminal records only",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const ids = args.trim().split(/\s+/u).filter(Boolean);
|
||||||
|
ctx.ui.notify(JSON.stringify({ cleared: getSupervisor(ctx).clearTerminal(ids.length > 0 ? ids : undefined) }, null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-status", {
|
||||||
|
description: "Show a subagent status by id",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
ctx.ui.notify(JSON.stringify(getSupervisor(ctx).status(args.trim()), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-result", {
|
||||||
|
description: "Show a subagent result by id",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
ctx.ui.notify(JSON.stringify(getSupervisor(ctx).result(args.trim()), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-wait", {
|
||||||
|
description: "Wait for subagent ids separated by spaces",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const { ids, timeoutMs, mode } = parseWaitArgs(args);
|
||||||
|
ctx.ui.notify(JSON.stringify(await getSupervisor(ctx).wait(ids, { timeoutMs, mode }), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-ui", {
|
||||||
|
description: "Toggle the bundled subagent status inspector",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
uiExpanded = !uiExpanded;
|
||||||
|
updateUi(ctx, true);
|
||||||
|
ctx.ui.notify(`Subagent inspector ${uiExpanded ? "expanded" : "collapsed"}`, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-diagnostics", {
|
||||||
|
description: "Show subagent configuration diagnostics from the last load",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
ctx.ui.notify(JSON.stringify(lastDiagnostics, null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-cancel", {
|
||||||
|
description: "Cancel a running subagent by id",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
ctx.ui.notify(JSON.stringify(await getSupervisor(ctx).cancel(args.trim()), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_shutdown", async () => {
|
||||||
|
await supervisor?.shutdown();
|
||||||
|
supervisor = undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUi(ctx: ExtensionContext, enabled: boolean) {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
ctx.ui.setWidget("subagents", enabled ? widget(lastStatuses, uiExpanded) : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSpawnArgs(args: string): SpawnRequest {
|
||||||
|
const parts = args.trim().split(/\s+/u);
|
||||||
|
const request: Partial<SpawnRequest> = {};
|
||||||
|
while (parts.length >= 2 && parts[0].startsWith("--")) {
|
||||||
|
const flag = parts.shift();
|
||||||
|
const value = parts.shift();
|
||||||
|
if (flag === "--agent") request.agent = value;
|
||||||
|
else if (flag === "--label") request.label = value;
|
||||||
|
else if (flag === "--context" && (value === "independent" || value === "fork")) request.context = value;
|
||||||
|
else if (flag === "--tools") request.tools = value;
|
||||||
|
else if (flag === "--model") request.model = value;
|
||||||
|
else if (flag === "--thinking") request.thinking = value;
|
||||||
|
}
|
||||||
|
return { ...request, prompt: parts.join(" ") || args } as SpawnRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWaitArgs(args: string): { ids: string[]; timeoutMs?: number; mode?: "all" | "any" } {
|
||||||
|
const parts = args.trim().split(/\s+/u).filter(Boolean);
|
||||||
|
let timeoutMs: number | undefined;
|
||||||
|
let mode: "all" | "any" | undefined;
|
||||||
|
const ids: string[] = [];
|
||||||
|
while (parts.length > 0) {
|
||||||
|
const part = parts.shift();
|
||||||
|
if (!part) continue;
|
||||||
|
if (part === "--timeout-ms" && parts[0]) {
|
||||||
|
const parsed = Number(parts.shift());
|
||||||
|
if (Number.isFinite(parsed)) timeoutMs = parsed;
|
||||||
|
} else if (part === "--mode" && (parts[0] === "all" || parts[0] === "any")) {
|
||||||
|
mode = parts.shift() as "all" | "any";
|
||||||
|
} else {
|
||||||
|
ids.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ids, timeoutMs, mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProjectTrusted(ctx: ExtensionContext): boolean {
|
||||||
|
const value = (ctx as unknown as { isProjectTrusted?: () => boolean }).isProjectTrusted?.();
|
||||||
|
return value === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cwdOf(ctx: ExtensionContext): string {
|
||||||
|
const sessionCwd = (ctx as unknown as { sessionManager?: { getCwd?: () => string }; cwd?: string }).sessionManager?.getCwd?.();
|
||||||
|
return sessionCwd ?? (ctx as unknown as { cwd?: string }).cwd ?? process.cwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
function textResult(value: unknown) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
|
||||||
|
details: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
118
modules/agents/pi/extensions/subagents/runner.test.ts
Normal file
118
modules/agents/pi/extensions/subagents/runner.test.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import childProcess from "node:child_process";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import test from "node:test";
|
||||||
|
import type { RunnerEvents } from "./types.ts";
|
||||||
|
|
||||||
|
class FakeStream extends EventEmitter {
|
||||||
|
setEncoding(_encoding: BufferEncoding): void {}
|
||||||
|
|
||||||
|
write(_chunk: string, callback?: (error?: Error | null) => void): boolean {
|
||||||
|
callback?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
end(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function events(): RunnerEvents {
|
||||||
|
return {
|
||||||
|
accepted: () => {},
|
||||||
|
running: () => {},
|
||||||
|
settling: () => {},
|
||||||
|
completed: () => {},
|
||||||
|
failed: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("child RPC process forwards structured activity before collecting the final result", async (t) => {
|
||||||
|
const running: unknown[] = [];
|
||||||
|
const completed: Array<{ result: string; stopReason?: string }> = [];
|
||||||
|
const fakeChild = new EventEmitter() as EventEmitter & {
|
||||||
|
stdout: FakeStream;
|
||||||
|
stderr: FakeStream;
|
||||||
|
stdin: FakeStream;
|
||||||
|
killed: boolean;
|
||||||
|
pid?: number;
|
||||||
|
kill(signal?: NodeJS.Signals): boolean;
|
||||||
|
};
|
||||||
|
fakeChild.stdout = new FakeStream();
|
||||||
|
fakeChild.stderr = new FakeStream();
|
||||||
|
fakeChild.stdin = new FakeStream();
|
||||||
|
fakeChild.killed = false;
|
||||||
|
fakeChild.kill = () => {
|
||||||
|
fakeChild.killed = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
t.mock.method(fakeChild.stdin, "write", (chunk, callback?: (error?: Error | null) => void) => {
|
||||||
|
const request = JSON.parse(String(chunk)) as { id: string; type: string };
|
||||||
|
callback?.();
|
||||||
|
if (request.type === "get_last_assistant_text") {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
fakeChild.stdout.emit("data", `${JSON.stringify({ id: request.id, type: "response", success: true, data: { text: "final answer" } })}\n`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
t.mock.method(childProcess, "spawn", () => fakeChild as unknown as childProcess.ChildProcessWithoutNullStreams);
|
||||||
|
|
||||||
|
const { SubprocessRpcRunner } = await import("./runner.ts");
|
||||||
|
const runner = new SubprocessRpcRunner();
|
||||||
|
await runner.start("child-1", { prompt: "work", label: "Review migration" }, "/tmp", {
|
||||||
|
...events(),
|
||||||
|
running: (event) => running.push(event),
|
||||||
|
completed: (result, stopReason) => completed.push({ result, stopReason }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstActivity = { type: "message_start", role: "assistant", message: { id: "msg-1" } };
|
||||||
|
const secondActivity = { type: "tool_execution_start", tool: "read", input: { path: "runner.ts" } };
|
||||||
|
const settledActivity = { type: "agent_settled" };
|
||||||
|
fakeChild.stdout.emit("data", `${JSON.stringify(firstActivity)}\n${JSON.stringify(secondActivity)}\n${JSON.stringify(settledActivity)}\n`);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.deepEqual(running, [firstActivity, secondActivity, settledActivity]);
|
||||||
|
assert.deepEqual(completed, [{ result: "final answer", stopReason: "agent_settled" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("child RPC process disables discovery while explicitly loading subagents extension", async (t) => {
|
||||||
|
const calls: Array<{ command: string; args: string[] }> = [];
|
||||||
|
const fakeChild = new EventEmitter() as EventEmitter & {
|
||||||
|
stdout: FakeStream;
|
||||||
|
stderr: FakeStream;
|
||||||
|
stdin: FakeStream;
|
||||||
|
killed: boolean;
|
||||||
|
pid?: number;
|
||||||
|
kill(signal?: NodeJS.Signals): boolean;
|
||||||
|
};
|
||||||
|
fakeChild.stdout = new FakeStream();
|
||||||
|
fakeChild.stderr = new FakeStream();
|
||||||
|
fakeChild.stdin = new FakeStream();
|
||||||
|
fakeChild.killed = false;
|
||||||
|
fakeChild.kill = () => {
|
||||||
|
fakeChild.killed = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const spawn = t.mock.method(childProcess, "spawn", (command, args) => {
|
||||||
|
calls.push({ command: String(command), args: Array.isArray(args) ? args.map(String) : [] });
|
||||||
|
return fakeChild as unknown as childProcess.ChildProcessWithoutNullStreams;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { SubprocessRpcRunner } = await import("./runner.ts");
|
||||||
|
const runner = new SubprocessRpcRunner();
|
||||||
|
await runner.start("child-1", { prompt: "work", label: "Review migration" }, "/tmp", events());
|
||||||
|
|
||||||
|
assert.equal(spawn.mock.callCount(), 1);
|
||||||
|
const args = calls[0].args;
|
||||||
|
const noExtensionsIndex = args.indexOf("--no-extensions");
|
||||||
|
const extensionIndex = args.indexOf("--extension");
|
||||||
|
|
||||||
|
const nameIndex = args.indexOf("--name");
|
||||||
|
|
||||||
|
assert.notEqual(noExtensionsIndex, -1, "child args keep automatic extension discovery disabled");
|
||||||
|
assert.notEqual(nameIndex, -1, "child args include a process name");
|
||||||
|
assert.equal(args[nameIndex + 1], "subagent Review migration");
|
||||||
|
assert.notEqual(extensionIndex, -1, "child args explicitly load the subagents extension entry");
|
||||||
|
assert.equal(args[extensionIndex + 1], fileURLToPath(new URL("./index.ts", import.meta.url)));
|
||||||
|
assert.ok(noExtensionsIndex < extensionIndex);
|
||||||
|
});
|
||||||
218
modules/agents/pi/extensions/subagents/runner.ts
Normal file
218
modules/agents/pi/extensions/subagents/runner.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { ChildHandle, ChildRunner, RunnerEvents, SpawnRequest } from "./types.ts";
|
||||||
|
|
||||||
|
interface PendingResponse {
|
||||||
|
resolve(value: unknown): void;
|
||||||
|
reject(error: Error): void;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcLine {
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
command?: string;
|
||||||
|
success?: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RpcChildHandle implements ChildHandle {
|
||||||
|
private buffer = "";
|
||||||
|
private nextRequest = 0;
|
||||||
|
private settled = false;
|
||||||
|
private finishing = false;
|
||||||
|
private cancelling = false;
|
||||||
|
private killed = false;
|
||||||
|
private readonly pending = new Map<string, PendingResponse>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly child: ChildProcessWithoutNullStreams,
|
||||||
|
private readonly events: RunnerEvents,
|
||||||
|
) {
|
||||||
|
child.stdout.setEncoding("utf8");
|
||||||
|
child.stderr.setEncoding("utf8");
|
||||||
|
child.stdout.on("data", (chunk) => this.onStdout(chunk));
|
||||||
|
child.stderr.on("data", (chunk) => this.events.running(`stderr: ${String(chunk).trim().slice(0, 200)}`));
|
||||||
|
child.on("error", (error) => this.fail(error.message));
|
||||||
|
child.on("close", (code, signal) => {
|
||||||
|
for (const pending of this.pending.values()) {
|
||||||
|
pending.reject(new Error(`RPC process closed before ${pending.command} response`));
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
if (!this.settled) this.fail(`RPC process closed with code ${code ?? "null"} signal ${signal ?? "null"}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async prompt(message: string): Promise<void> {
|
||||||
|
await this.send("prompt", { message });
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(): Promise<void> {
|
||||||
|
if (this.cancelling) return;
|
||||||
|
this.cancelling = true;
|
||||||
|
try {
|
||||||
|
await Promise.race([this.send("abort", {}), delay(200)]);
|
||||||
|
} catch {}
|
||||||
|
this.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onStdout(chunk: string) {
|
||||||
|
this.buffer += chunk;
|
||||||
|
while (true) {
|
||||||
|
const newline = this.buffer.indexOf("\n");
|
||||||
|
if (newline === -1) return;
|
||||||
|
const line = this.buffer.slice(0, newline).replace(/\r$/, "");
|
||||||
|
this.buffer = this.buffer.slice(newline + 1);
|
||||||
|
if (line.trim() === "") continue;
|
||||||
|
this.onLine(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onLine(line: string) {
|
||||||
|
let payload: RpcLine;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
this.events.running(`non-json rpc output: ${line.slice(0, 200)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "response" && payload.id) {
|
||||||
|
const pending = this.pending.get(payload.id);
|
||||||
|
if (!pending) return;
|
||||||
|
this.pending.delete(payload.id);
|
||||||
|
if (payload.success) pending.resolve(payload.data);
|
||||||
|
else pending.reject(new Error(payload.error ?? payload.message ?? `${pending.command} failed`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "agent_started") {
|
||||||
|
this.events.running(payload as Record<string, unknown>);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "agent_settled") {
|
||||||
|
this.events.running(payload as Record<string, unknown>);
|
||||||
|
this.finish().catch((error) => this.fail(error instanceof Error ? error.message : String(error)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type) this.events.running(payload as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async finish() {
|
||||||
|
if (this.settled || this.finishing) return;
|
||||||
|
this.finishing = true;
|
||||||
|
this.events.settling();
|
||||||
|
const result = await this.send("get_last_assistant_text", {});
|
||||||
|
const text = typeof result === "string" ? result : result && typeof result === "object" && "text" in result ? String((result as { text: unknown }).text) : "";
|
||||||
|
this.settled = true;
|
||||||
|
this.events.completed(text, "agent_settled");
|
||||||
|
this.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private terminate() {
|
||||||
|
if (this.killed) return;
|
||||||
|
this.killed = true;
|
||||||
|
this.child.stdin.end();
|
||||||
|
if (this.child.killed) return;
|
||||||
|
if (process.platform !== "win32" && this.child.pid) {
|
||||||
|
try {
|
||||||
|
process.kill(-this.child.pid, "SIGTERM");
|
||||||
|
} catch {
|
||||||
|
this.child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.child.killed || !this.child.pid) return;
|
||||||
|
try {
|
||||||
|
process.kill(-this.child.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
this.child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
}, 2_000).unref();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail(error: string) {
|
||||||
|
if (this.settled) return;
|
||||||
|
this.settled = true;
|
||||||
|
this.events.failed(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(command: string, body: Record<string, unknown>): Promise<unknown> {
|
||||||
|
const id = `subagent-${++this.nextRequest}`;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(id, { resolve, reject, command });
|
||||||
|
this.child.stdin.write(`${JSON.stringify({ id, type: command, ...body })}\n`, (error) => {
|
||||||
|
if (!error) return;
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubprocessRpcRunner implements ChildRunner {
|
||||||
|
async start(id: string, request: SpawnRequest, cwd: string, events: RunnerEvents): Promise<ChildHandle> {
|
||||||
|
const args = [process.argv[1], "--mode", "rpc", "--no-extensions", "--extension", subagentsExtensionPath(), "--name", `subagent ${request.label ?? id}`, ...contextArgs(request), ...toolArgs(request), ...modelArgs(request)];
|
||||||
|
const child = spawn(process.execPath, args, {
|
||||||
|
cwd,
|
||||||
|
env: childEnvironment(),
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
detached: process.platform !== "win32",
|
||||||
|
});
|
||||||
|
const handle = new RpcChildHandle(child, events);
|
||||||
|
events.accepted();
|
||||||
|
void handle.prompt(independentPrompt(request)).catch((error) => events.failed(error instanceof Error ? error.message : String(error)));
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentsExtensionPath(): string {
|
||||||
|
return fileURLToPath(new URL("./index.ts", import.meta.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextArgs(request: SpawnRequest): string[] {
|
||||||
|
if (request.context !== "fork" || !request.parentSessionFile) return [];
|
||||||
|
return ["--fork", request.parentSessionFile];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolArgs(request: SpawnRequest): string[] {
|
||||||
|
const activeTools = request.toolProfile?.activeTools;
|
||||||
|
if (activeTools === undefined || activeTools === null) return [];
|
||||||
|
if (activeTools.length === 0) return ["--no-tools"];
|
||||||
|
return ["--tools", activeTools.join(",")];
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelArgs(request: SpawnRequest): string[] {
|
||||||
|
const args: string[] = [];
|
||||||
|
if (request.model && request.model !== "inherit") args.push("--model", request.model);
|
||||||
|
if (request.thinking) args.push("--thinking", request.thinking);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function childEnvironment(): NodeJS.ProcessEnv {
|
||||||
|
const env = { ...process.env };
|
||||||
|
delete env.PI_SESSION_ID;
|
||||||
|
delete env.PI_SESSION_FILE;
|
||||||
|
delete env.PI_PROVIDER;
|
||||||
|
delete env.PI_MODEL;
|
||||||
|
delete env.PI_REASONING_LEVEL;
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function independentPrompt(request: SpawnRequest): string {
|
||||||
|
const base = request.agentBody ? `${request.agentBody}\n\n` : "";
|
||||||
|
if (request.context === "fork") {
|
||||||
|
return `${base}You are running as a delegated subagent in fork context.\nUse the inherited parent session context, then return a concise final answer for the parent agent.\n\nTask:\n${request.prompt}`;
|
||||||
|
}
|
||||||
|
return `${base}You are running as a delegated subagent in independent context.\nDo not assume access to the parent conversation transcript.\nReturn a concise final answer for the parent agent.\n\nTask:\n${request.prompt}`;
|
||||||
|
}
|
||||||
58
modules/agents/pi/extensions/subagents/status.ts
Normal file
58
modules/agents/pi/extensions/subagents/status.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { SUBAGENT_STATES, SUBAGENT_TERMINAL_STATES } from "./types.ts";
|
||||||
|
import type { ChildRecord, SpawnAccepted, SubagentResult, SubagentState, SubagentStatus } from "./types.ts";
|
||||||
|
|
||||||
|
export function toAccepted(status: SubagentStatus): SpawnAccepted {
|
||||||
|
return {
|
||||||
|
id: status.id,
|
||||||
|
label: status.label,
|
||||||
|
context: status.context,
|
||||||
|
tools: status.tools,
|
||||||
|
state: status.state,
|
||||||
|
hint: `Use subagent_status or subagent_result with id ${status.id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneStatus(status: SubagentStatus): SubagentStatus {
|
||||||
|
return {
|
||||||
|
...status,
|
||||||
|
currentActivity: status.currentActivity ? { ...status.currentActivity } : undefined,
|
||||||
|
activityHistory: status.activityHistory.map((event) => ({ ...event })),
|
||||||
|
elapsedMs: elapsedMs(status),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneResult(record: ChildRecord): SubagentResult {
|
||||||
|
const status = cloneStatus(record.status);
|
||||||
|
const terminal = isTerminalState(status.state);
|
||||||
|
return {
|
||||||
|
id: status.id,
|
||||||
|
label: status.label,
|
||||||
|
state: status.state,
|
||||||
|
running: !terminal,
|
||||||
|
resultAvailable: status.resultAvailable,
|
||||||
|
result: record.result,
|
||||||
|
error: status.error,
|
||||||
|
completedAt: status.completedAt,
|
||||||
|
elapsedMs: status.elapsedMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTerminalState(state: SubagentState): boolean {
|
||||||
|
return (SUBAGENT_TERMINAL_STATES as readonly string[]).includes(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function milestoneNotification(status: SubagentStatus, event: string): { message: string; level: "info" | "error" } | undefined {
|
||||||
|
if (!isSubagentState(event) || !isTerminalState(event)) return undefined;
|
||||||
|
return { message: `Subagent ${status.label} ${event}`, level: event === "completed" ? "info" : "error" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSubagentState(value: string): value is SubagentState {
|
||||||
|
return (SUBAGENT_STATES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elapsedMs(status: Pick<SubagentStatus, "startedAt" | "completedAt">): number {
|
||||||
|
const start = Date.parse(status.startedAt);
|
||||||
|
const end = status.completedAt ? Date.parse(status.completedAt) : Date.now();
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end)) return 0;
|
||||||
|
return Math.max(0, end - start);
|
||||||
|
}
|
||||||
469
modules/agents/pi/extensions/subagents/supervisor.test.ts
Normal file
469
modules/agents/pi/extensions/subagents/supervisor.test.ts
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { milestoneNotification } from "./status.ts";
|
||||||
|
import { Supervisor } from "./supervisor.ts";
|
||||||
|
import type { ChildHandle, ChildRunner, RunnerEvents, SpawnRequest } from "./types.ts";
|
||||||
|
import { widget } from "./ui.ts";
|
||||||
|
|
||||||
|
class FakeHandle implements ChildHandle {
|
||||||
|
cancelCalls = 0;
|
||||||
|
|
||||||
|
async cancel(): Promise<void> {
|
||||||
|
this.cancelCalls += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeRunner implements ChildRunner {
|
||||||
|
starts: Array<{ id: string; request: SpawnRequest; events: RunnerEvents; handle: FakeHandle }> = [];
|
||||||
|
autoAccept = true;
|
||||||
|
|
||||||
|
async start(id: string, request: SpawnRequest, _cwd: string, events: RunnerEvents): Promise<ChildHandle> {
|
||||||
|
const handle = new FakeHandle();
|
||||||
|
this.starts.push({ id, request, events, handle });
|
||||||
|
if (this.autoAccept) events.accepted(`session-${id}`);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
async function spawnStarted(supervisor: Supervisor, prompt = "work") {
|
||||||
|
const accepted = supervisor.spawn({ prompt });
|
||||||
|
await sleep(0);
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("cancel is idempotent and reaches cancelled", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
const first = await supervisor.cancel(accepted.id);
|
||||||
|
const second = await supervisor.cancel(accepted.id);
|
||||||
|
|
||||||
|
assert.equal(first.state, "cancelled");
|
||||||
|
assert.equal(second.state, "cancelled");
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("startup timeout reaches timed_out", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
runner.autoAccept = false;
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { timeouts: { startMs: 5 } });
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
await sleep(20);
|
||||||
|
|
||||||
|
const status = supervisor.status(accepted.id);
|
||||||
|
assert.equal(status.state, "timed_out");
|
||||||
|
assert.equal(status.stopReason, "start_timeout");
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runtime timeout reaches timed_out", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { timeouts: { runMs: 5 } });
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
await sleep(20);
|
||||||
|
|
||||||
|
const status = supervisor.status(accepted.id);
|
||||||
|
assert.equal(status.state, "timed_out");
|
||||||
|
assert.equal(status.stopReason, "run_timeout");
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("activity exposes ordered transcript events while status and list keep only summaries", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
runner.starts[0].events.running({ type: "message_started", role: "assistant" });
|
||||||
|
runner.starts[0].events.running({
|
||||||
|
type: "message_delta",
|
||||||
|
role: "assistant",
|
||||||
|
assistantMessageEvent: { type: "content_delta", delta: "private transcript body" },
|
||||||
|
});
|
||||||
|
runner.starts[0].events.running({ type: "tool_started", tool: "read", input: { path: "secret-notes.md" } });
|
||||||
|
runner.starts[0].events.running({ type: "tool_completed", tool: "read", output: "secret file contents" });
|
||||||
|
|
||||||
|
type ActivityStatus = ReturnType<Supervisor["status"]> & {
|
||||||
|
activityHistory: Array<{ type: string; summary: string }>;
|
||||||
|
currentActivity: { summary: string };
|
||||||
|
};
|
||||||
|
const activity = supervisor.activity(accepted.id);
|
||||||
|
const status = supervisor.status(accepted.id) as ActivityStatus;
|
||||||
|
const listed = supervisor.list().find((item) => item.id === accepted.id) as ActivityStatus | undefined;
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
activity.map((event) => event.type),
|
||||||
|
["queued", "starting", "prompt accepted", "message_started", "message_delta", "tool_started", "tool_completed"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(activity[4], {
|
||||||
|
type: "message_delta",
|
||||||
|
summary: "assistant message content_delta",
|
||||||
|
at: activity[4].at,
|
||||||
|
role: "assistant",
|
||||||
|
tool: undefined,
|
||||||
|
phase: "content_delta",
|
||||||
|
text: "private transcript body",
|
||||||
|
input: undefined,
|
||||||
|
output: undefined,
|
||||||
|
error: undefined,
|
||||||
|
payload: {
|
||||||
|
type: "message_delta",
|
||||||
|
role: "assistant",
|
||||||
|
assistantMessageEvent: { type: "content_delta", delta: "private transcript body" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(activity[5], {
|
||||||
|
type: "tool_started",
|
||||||
|
summary: "read secret-notes.md",
|
||||||
|
at: activity[5].at,
|
||||||
|
role: undefined,
|
||||||
|
tool: "read",
|
||||||
|
phase: "started",
|
||||||
|
text: undefined,
|
||||||
|
input: { path: "secret-notes.md" },
|
||||||
|
output: undefined,
|
||||||
|
error: undefined,
|
||||||
|
payload: { type: "tool_started", tool: "read", input: { path: "secret-notes.md" } },
|
||||||
|
});
|
||||||
|
assert.equal(activity[6].output, "secret file contents");
|
||||||
|
|
||||||
|
assert.ok(Array.isArray(status.activityHistory), "status should expose structured activityHistory");
|
||||||
|
assert.deepEqual(status.activityHistory.map((event) => event.type), activity.map((event) => event.type));
|
||||||
|
assert.deepEqual(status.activityHistory.map((event) => event.summary), activity.map((event) => event.summary));
|
||||||
|
assert.equal(status.currentActivity.summary, "read");
|
||||||
|
assert.equal(listed?.currentActivity.summary, "read");
|
||||||
|
assert.doesNotMatch(JSON.stringify(status), /private transcript body|secret file contents/u);
|
||||||
|
assert.doesNotMatch(JSON.stringify(listed), /private transcript body|secret file contents/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status activity history keeps only the 100 most recent summaries", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
for (let index = 0; index < 150; index += 1) {
|
||||||
|
runner.starts[0].events.running(`tick ${index}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = supervisor.status(accepted.id).activityHistory;
|
||||||
|
|
||||||
|
assert.equal(history.length, 100);
|
||||||
|
assert.equal(history[0].summary, "tick 50");
|
||||||
|
assert.equal(history[99].summary, "tick 149");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("process failure reaches failed with diagnostics", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
runner.starts[0].events.failed("process closed with code 1");
|
||||||
|
|
||||||
|
const status = supervisor.status(accepted.id);
|
||||||
|
assert.equal(status.state, "failed");
|
||||||
|
assert.equal(status.error, "process closed with code 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shutdown cancels running children", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
await supervisor.shutdown();
|
||||||
|
|
||||||
|
const status = supervisor.status(accepted.id);
|
||||||
|
assert.equal(status.state, "cancelled");
|
||||||
|
assert.equal(status.stopReason, "shutdown");
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("completed children ignore later cancel", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
await supervisor.cancel(accepted.id);
|
||||||
|
|
||||||
|
const result = supervisor.result(accepted.id);
|
||||||
|
assert.equal(result.state, "completed");
|
||||||
|
assert.equal(result.result, "done");
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("explicit labels are reused across accepted status list and result surfaces", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const label = "Review risky migration";
|
||||||
|
|
||||||
|
const accepted = supervisor.spawn({ prompt: "inspect the migration plan", label } as SpawnRequest & { label: string });
|
||||||
|
await sleep(0);
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
accepted: accepted.label,
|
||||||
|
status: supervisor.status(accepted.id).label,
|
||||||
|
list: supervisor.list().find((status) => status.id === accepted.id)?.label,
|
||||||
|
result: (supervisor.result(accepted.id) as { label?: string }).label,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accepted: label,
|
||||||
|
status: label,
|
||||||
|
list: label,
|
||||||
|
result: label,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ad hoc fallback labels are prompt-derived and reused by widget and result surfaces", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const prompt = " Audit\n\tguest enablement plan ";
|
||||||
|
const label = "Audit guest enablement plan";
|
||||||
|
|
||||||
|
const accepted = supervisor.spawn({ prompt });
|
||||||
|
await sleep(0);
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
const statuses = supervisor.list();
|
||||||
|
const inspectorLines = widget(statuses, true)().render(240);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
accepted: accepted.label,
|
||||||
|
childRequest: runner.starts[0].request.label,
|
||||||
|
status: supervisor.status(accepted.id).label,
|
||||||
|
list: statuses.find((status) => status.id === accepted.id)?.label,
|
||||||
|
result: supervisor.result(accepted.id).label,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accepted: label,
|
||||||
|
childRequest: label,
|
||||||
|
status: label,
|
||||||
|
list: label,
|
||||||
|
result: label,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.ok(inspectorLines.some((line) => line.includes(`completed 0s ${label} result: available`)), inspectorLines.join("\n"));
|
||||||
|
assert.doesNotMatch(accepted.label, /^ad-hoc sg-/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("milestone notifications use the stored label", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = supervisor.spawn({ prompt: "work", label: "Review migration" });
|
||||||
|
await sleep(0);
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
|
||||||
|
assert.deepEqual(milestoneNotification(supervisor.status(accepted.id), "completed"), {
|
||||||
|
message: "Subagent Review migration completed",
|
||||||
|
level: "info",
|
||||||
|
});
|
||||||
|
assert.equal(milestoneNotification(supervisor.status(accepted.id), "running"), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shutdown clears recent terminal expiry timer", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
let changes = 0;
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", {
|
||||||
|
recentTerminalTtlMs: 5,
|
||||||
|
onChange: () => {
|
||||||
|
changes += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
await supervisor.shutdown();
|
||||||
|
const afterShutdown = changes;
|
||||||
|
await sleep(15);
|
||||||
|
|
||||||
|
assert.equal(changes, afterShutdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("batch spawn returns explicit labels on accepted child requests and statuses while preserving failures", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
|
||||||
|
const result = supervisor.spawnBatch([
|
||||||
|
{ prompt: "one", label: "Review docs" },
|
||||||
|
{ prompt: "" },
|
||||||
|
{ prompt: "two", label: "Check tests" },
|
||||||
|
]);
|
||||||
|
await sleep(0);
|
||||||
|
|
||||||
|
assert.deepEqual(result.accepted.map((accepted) => accepted.label), ["Review docs", "Check tests"]);
|
||||||
|
assert.equal(result.failed.length, 1);
|
||||||
|
assert.equal(result.failed[0].index, 1);
|
||||||
|
assert.deepEqual(runner.starts.map((start) => start.request.label), ["Review docs", "Check tests"]);
|
||||||
|
assert.deepEqual(result.accepted.map((accepted) => supervisor.status(accepted.id).label), ["Review docs", "Check tests"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maxConcurrent preserves queued records", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { maxConcurrent: 1 });
|
||||||
|
|
||||||
|
const result = supervisor.spawnBatch([{ prompt: "one" }, { prompt: "two" }]);
|
||||||
|
await sleep(0);
|
||||||
|
|
||||||
|
assert.equal(result.accepted.length, 2);
|
||||||
|
assert.equal(runner.starts.length, 1);
|
||||||
|
assert.equal(supervisor.status(result.accepted[1].id).state, "queued");
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
await sleep(0);
|
||||||
|
|
||||||
|
assert.equal(runner.starts.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearTerminal returns only removed terminal ids", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const first = await spawnStarted(supervisor, "one");
|
||||||
|
const second = await spawnStarted(supervisor, "two");
|
||||||
|
const running = await spawnStarted(supervisor, "three");
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("one done", "agent_settled");
|
||||||
|
runner.starts[1].events.completed("two done", "agent_settled");
|
||||||
|
|
||||||
|
assert.deepEqual(supervisor.clearTerminal(), [first.id, second.id]);
|
||||||
|
assert.throws(() => supervisor.status(first.id), /unknown subagent id/);
|
||||||
|
assert.throws(() => supervisor.status(second.id), /unknown subagent id/);
|
||||||
|
assert.equal(supervisor.status(running.id).state, "running");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("terminal records expire after ttl while active children remain", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 5 });
|
||||||
|
const completed = await spawnStarted(supervisor, "one");
|
||||||
|
const failed = await spawnStarted(supervisor, "two");
|
||||||
|
const running = await spawnStarted(supervisor, "three");
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("one done", "agent_settled");
|
||||||
|
runner.starts[1].events.failed("two failed");
|
||||||
|
|
||||||
|
assert.equal(supervisor.result(completed.id).result, "one done");
|
||||||
|
assert.equal(supervisor.result(failed.id).error, "two failed");
|
||||||
|
assert.equal(supervisor.status(running.id).state, "running");
|
||||||
|
|
||||||
|
await sleep(20);
|
||||||
|
|
||||||
|
const listedIds = supervisor.list().map((status) => status.id);
|
||||||
|
assert.equal(listedIds.includes(completed.id), false);
|
||||||
|
assert.equal(listedIds.includes(failed.id), false);
|
||||||
|
assert.equal(listedIds.includes(running.id), true);
|
||||||
|
assert.throws(() => supervisor.status(completed.id), /unknown subagent id/);
|
||||||
|
assert.throws(() => supervisor.status(failed.id), /unknown subagent id/);
|
||||||
|
assert.throws(() => supervisor.result(completed.id), /unknown subagent id/);
|
||||||
|
assert.throws(() => supervisor.result(failed.id), /unknown subagent id/);
|
||||||
|
assert.equal(supervisor.status(running.id).state, "running");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zero recent terminal ttl does not hide terminal statuses", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 0 });
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
|
||||||
|
assert.equal(supervisor.list().some((status) => status.id === accepted.id), true);
|
||||||
|
assert.equal(supervisor.result(accepted.id).result, "done");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait blocks until multiple subagents are terminal", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const first = await spawnStarted(supervisor, "one");
|
||||||
|
const second = await spawnStarted(supervisor, "two");
|
||||||
|
|
||||||
|
const waiting = supervisor.wait([first.id, second.id], { timeoutMs: 100 });
|
||||||
|
runner.starts[0].events.completed("one done", "agent_settled");
|
||||||
|
await sleep(0);
|
||||||
|
|
||||||
|
assert.equal(await Promise.race([waiting.then(() => "done"), sleep(10).then(() => "pending")]), "pending");
|
||||||
|
|
||||||
|
runner.starts[1].events.failed("two failed");
|
||||||
|
const result = await waiting;
|
||||||
|
|
||||||
|
assert.equal(result.timedOut, false);
|
||||||
|
assert.equal(result.ready, true);
|
||||||
|
assert.deepEqual(result.ids, [first.id, second.id]);
|
||||||
|
assert.equal(result.pending.length, 0);
|
||||||
|
assert.deepEqual(result.results.map((item) => item.state), ["completed", "failed"]);
|
||||||
|
assert.equal(result.results[0].result, "one done");
|
||||||
|
assert.equal(result.results[1].error, "two failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait returns pending statuses on timeout", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const first = await spawnStarted(supervisor, "one");
|
||||||
|
const second = await spawnStarted(supervisor, "two");
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("one done", "agent_settled");
|
||||||
|
const result = await supervisor.wait([first.id, second.id], { timeoutMs: 5 });
|
||||||
|
|
||||||
|
assert.equal(result.timedOut, true);
|
||||||
|
assert.equal(result.ready, false);
|
||||||
|
assert.deepEqual(result.results.map((item) => item.state), ["completed", "running"]);
|
||||||
|
assert.deepEqual(result.pending.map((item) => item.id), [second.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait any returns after the first terminal subagent", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const first = await spawnStarted(supervisor, "one");
|
||||||
|
const second = await spawnStarted(supervisor, "two");
|
||||||
|
|
||||||
|
const waiting = supervisor.wait([first.id, second.id], { mode: "any", timeoutMs: 100 });
|
||||||
|
runner.starts[1].events.completed("two done", "agent_settled");
|
||||||
|
const result = await waiting;
|
||||||
|
|
||||||
|
assert.equal(result.timedOut, false);
|
||||||
|
assert.equal(result.ready, true);
|
||||||
|
assert.deepEqual(result.results.map((item) => item.state), ["running", "completed"]);
|
||||||
|
assert.deepEqual(result.pending.map((item) => item.id), [first.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait rejects unknown and empty id sets", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
|
||||||
|
await assert.rejects(() => supervisor.wait([]), /at least one subagent id is required/);
|
||||||
|
await assert.rejects(() => supervisor.wait(["missing"]), /unknown subagent id: missing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait abort rejects without cancelling child", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor, "one");
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const waiting = supervisor.wait([accepted.id], { signal: controller.signal });
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await assert.rejects(waiting, /subagent wait aborted/);
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait follows queued subagents through queue start and completion", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { maxConcurrent: 1 });
|
||||||
|
const batch = supervisor.spawnBatch([{ prompt: "one" }, { prompt: "two" }]);
|
||||||
|
await sleep(0);
|
||||||
|
|
||||||
|
const waiting = supervisor.wait([batch.accepted[1].id], { timeoutMs: 100 });
|
||||||
|
assert.equal(await Promise.race([waiting.then(() => "done"), sleep(10).then(() => "pending")]), "pending");
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("one done", "agent_settled");
|
||||||
|
await sleep(0);
|
||||||
|
runner.starts[1].events.completed("two done", "agent_settled");
|
||||||
|
const result = await waiting;
|
||||||
|
|
||||||
|
assert.equal(result.timedOut, false);
|
||||||
|
assert.equal(result.ready, true);
|
||||||
|
assert.deepEqual(result.results.map((item) => item.result), ["two done"]);
|
||||||
|
});
|
||||||
558
modules/agents/pi/extensions/subagents/supervisor.ts
Normal file
558
modules/agents/pi/extensions/subagents/supervisor.ts
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
import type {
|
||||||
|
ChildHandle,
|
||||||
|
ChildRecord,
|
||||||
|
ChildRunner,
|
||||||
|
ContextMode,
|
||||||
|
RunnerActivity,
|
||||||
|
RunnerEvents,
|
||||||
|
SpawnAccepted,
|
||||||
|
SpawnRequest,
|
||||||
|
SubagentResult,
|
||||||
|
SubagentStatus,
|
||||||
|
SubagentWaitMode,
|
||||||
|
SubagentWaitResult,
|
||||||
|
} from "./types.ts";
|
||||||
|
import { cloneResult, cloneStatus, isTerminalState, toAccepted } from "./status.ts";
|
||||||
|
|
||||||
|
interface RunningChild {
|
||||||
|
record: ChildRecord;
|
||||||
|
request: SpawnRequest;
|
||||||
|
handle?: ChildHandle;
|
||||||
|
startTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
runTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
expiryTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SupervisorOptions {
|
||||||
|
maxConcurrent?: number;
|
||||||
|
recentTerminalLimit?: number;
|
||||||
|
recentTerminalTtlMs?: number;
|
||||||
|
timeouts?: {
|
||||||
|
startMs?: number;
|
||||||
|
runMs?: number;
|
||||||
|
};
|
||||||
|
onMilestone?: (status: SubagentStatus, event: string) => void;
|
||||||
|
onChange?: (statuses: SubagentStatus[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchSpawnResult {
|
||||||
|
accepted: SpawnAccepted[];
|
||||||
|
failed: Array<{ index: number; error: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUTS = {
|
||||||
|
startMs: 30_000,
|
||||||
|
runMs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_ACTIVITY_HISTORY = 100;
|
||||||
|
|
||||||
|
export class Supervisor {
|
||||||
|
private nextChild = 0;
|
||||||
|
private readonly children = new Map<string, RunningChild>();
|
||||||
|
private readonly queue: RunningChild[] = [];
|
||||||
|
private readonly waiters = new Set<() => void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly runner: ChildRunner,
|
||||||
|
private readonly cwd: string,
|
||||||
|
private readonly options: SupervisorOptions = {},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
spawn(request: SpawnRequest): SpawnAccepted {
|
||||||
|
return this.createChild(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnBatch(requests: SpawnRequest[]): BatchSpawnResult {
|
||||||
|
const accepted: SpawnAccepted[] = [];
|
||||||
|
const failed: Array<{ index: number; error: string }> = [];
|
||||||
|
requests.forEach((request, index) => {
|
||||||
|
try {
|
||||||
|
accepted.push(this.createChild(request));
|
||||||
|
} catch (error) {
|
||||||
|
failed.push({ index, error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { accepted, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): SubagentStatus[] {
|
||||||
|
const statuses = [...this.children.values()].map((child) => cloneStatus(child.record.status));
|
||||||
|
const active = statuses.filter((status) => !isTerminal(status.state));
|
||||||
|
const terminal = statuses
|
||||||
|
.filter((status) => isTerminal(status.state))
|
||||||
|
.sort((a, b) => Date.parse(b.completedAt ?? b.startedAt) - Date.parse(a.completedAt ?? a.startedAt));
|
||||||
|
return [...active, ...terminal];
|
||||||
|
}
|
||||||
|
|
||||||
|
status(id: string): SubagentStatus {
|
||||||
|
return cloneStatus(this.require(id).record.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
result(id: string): SubagentResult {
|
||||||
|
return cloneResult(this.require(id).record);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTerminal(ids?: string[]): string[] {
|
||||||
|
const selectedIds = ids ? [...new Set(ids.map((id) => id.trim()).filter(Boolean))] : undefined;
|
||||||
|
if (selectedIds) for (const id of selectedIds) this.require(id);
|
||||||
|
const cleared: string[] = [];
|
||||||
|
for (const [id, child] of this.children) {
|
||||||
|
if (selectedIds && !selectedIds.includes(id)) continue;
|
||||||
|
if (!isTerminal(child.record.status.state)) continue;
|
||||||
|
this.clearTimer(child, "expiryTimer");
|
||||||
|
cleared.push(id);
|
||||||
|
this.children.delete(id);
|
||||||
|
}
|
||||||
|
if (cleared.length > 0) this.emitChange();
|
||||||
|
return cleared;
|
||||||
|
}
|
||||||
|
|
||||||
|
async wait(
|
||||||
|
ids: string[],
|
||||||
|
options: { timeoutMs?: number; signal?: AbortSignal; mode?: SubagentWaitMode } = {},
|
||||||
|
): Promise<SubagentWaitResult> {
|
||||||
|
const uniqueIds = [...new Set(ids.map((id) => id.trim()).filter(Boolean))];
|
||||||
|
if (uniqueIds.length === 0) throw new Error("at least one subagent id is required");
|
||||||
|
for (const id of uniqueIds) this.require(id);
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const mode = options.mode ?? "all";
|
||||||
|
if (mode !== "all" && mode !== "any") throw new Error(`unknown wait mode: ${mode}`);
|
||||||
|
const deadline = options.timeoutMs && options.timeoutMs > 0 ? startedAt + options.timeoutMs : undefined;
|
||||||
|
let timedOut = false;
|
||||||
|
|
||||||
|
while (!this.waitReady(uniqueIds, mode)) {
|
||||||
|
if (options.signal?.aborted) throw new Error("subagent wait aborted");
|
||||||
|
const remainingMs = deadline === undefined ? undefined : deadline - Date.now();
|
||||||
|
if (remainingMs !== undefined && remainingMs <= 0) {
|
||||||
|
timedOut = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await this.nextChange(remainingMs, options.signal).catch((error) => {
|
||||||
|
if (error instanceof Error && error.message === "subagent wait timed out") timedOut = true;
|
||||||
|
else throw error;
|
||||||
|
});
|
||||||
|
if (timedOut) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = uniqueIds.map((id) => this.result(id));
|
||||||
|
const pending = uniqueIds
|
||||||
|
.map((id) => this.status(id))
|
||||||
|
.filter((status) => !isTerminal(status.state));
|
||||||
|
return { ids: uniqueIds, mode, ready: this.waitReady(uniqueIds, mode), results, pending, timedOut, elapsedMs: Date.now() - startedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string): Promise<SubagentStatus> {
|
||||||
|
const child = this.require(id);
|
||||||
|
if (isTerminal(child.record.status.state)) return cloneStatus(child.record.status);
|
||||||
|
await child.handle?.cancel();
|
||||||
|
this.completeWithoutResult(child, "cancelled", "cancelled");
|
||||||
|
this.pumpQueue();
|
||||||
|
return cloneStatus(child.record.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
await Promise.allSettled(
|
||||||
|
[...this.children.values()].map(async (child) => {
|
||||||
|
if (!isTerminal(child.record.status.state)) {
|
||||||
|
await child.handle?.cancel();
|
||||||
|
this.completeWithoutResult(child, "cancelled", "shutdown");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const child of this.children.values()) this.clearTimer(child, "expiryTimer");
|
||||||
|
}
|
||||||
|
|
||||||
|
private createChild(request: SpawnRequest): SpawnAccepted {
|
||||||
|
const prompt = typeof request.prompt === "string" ? request.prompt.trim() : "";
|
||||||
|
if (!prompt) throw new Error("prompt is required");
|
||||||
|
|
||||||
|
const id = this.allocateId();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const status: SubagentStatus = {
|
||||||
|
id,
|
||||||
|
label: deriveLabel(request, id),
|
||||||
|
agent: request.agent,
|
||||||
|
adHoc: !request.agent,
|
||||||
|
context: this.resolveContext(request.context),
|
||||||
|
state: "queued",
|
||||||
|
cwd: this.cwd,
|
||||||
|
model: request.model,
|
||||||
|
thinking: request.thinking,
|
||||||
|
tools: request.tools ?? "read-only",
|
||||||
|
startedAt: now,
|
||||||
|
elapsedMs: 0,
|
||||||
|
lastEvent: "queued",
|
||||||
|
lastEventAt: now,
|
||||||
|
currentActivity: { type: "queued", summary: "queued", at: now },
|
||||||
|
activityHistory: [{ type: "queued", summary: "queued", at: now }],
|
||||||
|
resultAvailable: false,
|
||||||
|
};
|
||||||
|
const child: RunningChild = { record: { status, activityEvents: [{ type: "queued", summary: "queued", at: now }] }, request: { ...request, prompt, label: status.label, context: status.context, tools: status.tools } };
|
||||||
|
this.children.set(id, child);
|
||||||
|
this.emitMilestone(child, "accepted");
|
||||||
|
this.queue.push(child);
|
||||||
|
this.pumpQueue();
|
||||||
|
return toAccepted(cloneStatus(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
private pumpQueue() {
|
||||||
|
while (this.runningCount() < this.maxConcurrent()) {
|
||||||
|
const child = this.queue.shift();
|
||||||
|
if (!child) break;
|
||||||
|
if (isTerminal(child.record.status.state)) continue;
|
||||||
|
this.start(child);
|
||||||
|
}
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private start(child: RunningChild) {
|
||||||
|
this.setState(child.record.status, "starting", "starting");
|
||||||
|
this.armStartTimer(child);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isTerminal(child.record.status.state)) return;
|
||||||
|
void this.runner
|
||||||
|
.start(child.record.status.id, child.request, this.cwd, this.eventsFor(child.record))
|
||||||
|
.then((handle) => {
|
||||||
|
child.handle = handle;
|
||||||
|
if (isTerminal(child.record.status.state)) void handle.cancel();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.fail(child.record, error instanceof Error ? error.message : String(error));
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private eventsFor(record: ChildRecord): RunnerEvents {
|
||||||
|
return {
|
||||||
|
accepted: (childSession) => {
|
||||||
|
const child = this.findChild(record);
|
||||||
|
if (child) {
|
||||||
|
this.clearTimer(child, "startTimer");
|
||||||
|
this.armRunTimer(child);
|
||||||
|
}
|
||||||
|
if (childSession) record.status.childSession = childSession;
|
||||||
|
this.setState(record.status, "running", "prompt accepted");
|
||||||
|
},
|
||||||
|
running: (event) => {
|
||||||
|
if (!isTerminal(record.status.state)) this.setState(record.status, "running", event);
|
||||||
|
},
|
||||||
|
settling: () => {
|
||||||
|
if (!isTerminal(record.status.state)) this.setState(record.status, "settling", "agent_settled");
|
||||||
|
},
|
||||||
|
completed: (result, stopReason) => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const child = this.findChild(record);
|
||||||
|
if (child) this.clearTimers(child);
|
||||||
|
record.result = result;
|
||||||
|
record.status.state = "completed";
|
||||||
|
record.status.completedAt = now;
|
||||||
|
record.status.lastEvent = "completed";
|
||||||
|
record.status.lastEventAt = now;
|
||||||
|
this.recordActivity(record, "completed", now);
|
||||||
|
record.status.stopReason = stopReason;
|
||||||
|
record.status.resultAvailable = true;
|
||||||
|
if (child) {
|
||||||
|
this.armTerminalExpiry(child);
|
||||||
|
this.emitMilestone(child, "completed");
|
||||||
|
}
|
||||||
|
this.pumpQueue();
|
||||||
|
},
|
||||||
|
failed: (error) => this.fail(record, error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail(record: ChildRecord, error: string) {
|
||||||
|
if (isTerminal(record.status.state)) return;
|
||||||
|
const child = this.findChild(record);
|
||||||
|
if (child) this.clearTimers(child);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
record.status.state = "failed";
|
||||||
|
record.status.completedAt = now;
|
||||||
|
record.status.lastEvent = "failed";
|
||||||
|
record.status.lastEventAt = now;
|
||||||
|
this.recordActivity(record, "failed", now);
|
||||||
|
record.status.error = error;
|
||||||
|
record.status.stopReason = "failed";
|
||||||
|
if (child) {
|
||||||
|
this.armTerminalExpiry(child);
|
||||||
|
this.emitMilestone(child, "failed");
|
||||||
|
}
|
||||||
|
this.pumpQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private completeWithoutResult(child: RunningChild, state: "cancelled" | "timed_out", reason: string) {
|
||||||
|
if (isTerminal(child.record.status.state)) return;
|
||||||
|
this.clearTimers(child);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
child.record.status.state = state;
|
||||||
|
child.record.status.completedAt = now;
|
||||||
|
child.record.status.lastEvent = state;
|
||||||
|
child.record.status.lastEventAt = now;
|
||||||
|
this.recordActivity(child.record, state, now);
|
||||||
|
child.record.status.stopReason = reason;
|
||||||
|
this.armTerminalExpiry(child);
|
||||||
|
this.emitMilestone(child, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private armStartTimer(child: RunningChild) {
|
||||||
|
const timeout = this.options.timeouts?.startMs ?? DEFAULT_TIMEOUTS.startMs;
|
||||||
|
if (timeout <= 0) return;
|
||||||
|
child.startTimer = setTimeout(() => {
|
||||||
|
this.timeout(child, "start_timeout");
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private armRunTimer(child: RunningChild) {
|
||||||
|
const timeout = this.options.timeouts?.runMs ?? DEFAULT_TIMEOUTS.runMs;
|
||||||
|
if (timeout <= 0) return;
|
||||||
|
child.runTimer = setTimeout(() => {
|
||||||
|
this.timeout(child, "run_timeout");
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private timeout(child: RunningChild, reason: string) {
|
||||||
|
if (isTerminal(child.record.status.state)) return;
|
||||||
|
void child.handle?.cancel();
|
||||||
|
this.completeWithoutResult(child, "timed_out", reason);
|
||||||
|
this.pumpQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private armTerminalExpiry(child: RunningChild) {
|
||||||
|
const ttl = this.options.recentTerminalTtlMs;
|
||||||
|
if (ttl === undefined || ttl <= 0) return;
|
||||||
|
this.clearTimer(child, "expiryTimer");
|
||||||
|
child.expiryTimer = setTimeout(() => {
|
||||||
|
child.expiryTimer = undefined;
|
||||||
|
const id = child.record.status.id;
|
||||||
|
if (this.children.get(id) !== child || !isTerminal(child.record.status.state)) return;
|
||||||
|
this.children.delete(id);
|
||||||
|
this.emitChange();
|
||||||
|
}, ttl);
|
||||||
|
child.expiryTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimers(child: RunningChild) {
|
||||||
|
this.clearTimer(child, "startTimer");
|
||||||
|
this.clearTimer(child, "runTimer");
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer(child: RunningChild, key: "startTimer" | "runTimer" | "expiryTimer") {
|
||||||
|
const timer = child[key];
|
||||||
|
if (!timer) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
child[key] = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findChild(record: ChildRecord): RunningChild | undefined {
|
||||||
|
return [...this.children.values()].find((child) => child.record === record);
|
||||||
|
}
|
||||||
|
|
||||||
|
activity(id: string) {
|
||||||
|
return this.require(id).record.activityEvents.map((event) => ({ ...event }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(status: SubagentStatus, state: SubagentStatus["state"], event: RunnerActivity) {
|
||||||
|
if (isTerminal(status.state)) return;
|
||||||
|
const record = this.require(status.id).record;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const activity = this.recordActivity(record, event, now);
|
||||||
|
status.state = state;
|
||||||
|
status.lastEvent = activity.type;
|
||||||
|
status.lastEventAt = now;
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordActivity(record: ChildRecord, event: RunnerActivity, at: string) {
|
||||||
|
const activity = normalizeActivity(event, at);
|
||||||
|
record.activityEvents.push(activity);
|
||||||
|
const summary = summarizeActivity(activity);
|
||||||
|
record.status.currentActivity = summary;
|
||||||
|
record.status.activityHistory.push(summary);
|
||||||
|
if (record.status.activityHistory.length > MAX_ACTIVITY_HISTORY) {
|
||||||
|
record.status.activityHistory.splice(0, record.status.activityHistory.length - MAX_ACTIVITY_HISTORY);
|
||||||
|
}
|
||||||
|
return activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private require(id: string): RunningChild {
|
||||||
|
const child = this.children.get(id);
|
||||||
|
if (!child) throw new Error(`unknown subagent id: ${id}`);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveContext(context: ContextMode | undefined): ContextMode {
|
||||||
|
if (context === undefined) return "independent";
|
||||||
|
if (context !== "independent" && context !== "fork") throw new Error(`unknown context: ${context}`);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private maxConcurrent(): number {
|
||||||
|
return Math.max(1, this.options.maxConcurrent ?? 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private runningCount(): number {
|
||||||
|
return [...this.children.values()].filter((child) => ["starting", "running", "settling"].includes(child.record.status.state)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitMilestone(child: RunningChild, event: string) {
|
||||||
|
this.options.onMilestone?.(cloneStatus(child.record.status), event);
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitChange() {
|
||||||
|
this.options.onChange?.(this.list());
|
||||||
|
for (const waiter of this.waiters) waiter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitReady(ids: string[], mode: SubagentWaitMode): boolean {
|
||||||
|
const terminal = (id: string) => isTerminal(this.require(id).record.status.state);
|
||||||
|
return mode === "all" ? ids.every(terminal) : ids.some(terminal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextChange(timeoutMs: number | undefined, signal: AbortSignal | undefined): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const cleanup = () => {
|
||||||
|
this.waiters.delete(resolveOnce);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
signal?.removeEventListener("abort", abort);
|
||||||
|
};
|
||||||
|
const resolveOnce = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const abort = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("subagent wait aborted"));
|
||||||
|
};
|
||||||
|
this.waiters.add(resolveOnce);
|
||||||
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
if (timeoutMs !== undefined) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("subagent wait timed out"));
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private allocateId(): string {
|
||||||
|
this.nextChild += 1;
|
||||||
|
return `sg-${Date.now().toString(36)}-${this.nextChild.toString(36)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveLabel(request: SpawnRequest, id: string): string {
|
||||||
|
const explicit = normalizeLabel(request.label);
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const agent = normalizeLabel(request.agent);
|
||||||
|
if (agent) return agent;
|
||||||
|
return promptLabel(request.prompt) ?? `ad-hoc ${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptLabel(prompt: string): string | undefined {
|
||||||
|
const normalized = normalizeLabel(prompt);
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
return truncateLabel(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLabel(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string") return undefined;
|
||||||
|
const normalized = value.replace(/\s+/gu, " ").trim();
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateLabel(label: string): string {
|
||||||
|
const maxLength = 80;
|
||||||
|
if (label.length <= maxLength) return label;
|
||||||
|
return `${label.slice(0, maxLength - 1).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminal(state: SubagentStatus["state"]): boolean {
|
||||||
|
return isTerminalState(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeActivity(event: RunnerActivity, at: string) {
|
||||||
|
if (typeof event === "string") return { type: event, summary: event, at };
|
||||||
|
const type = typeof event.type === "string" ? event.type : "activity";
|
||||||
|
const role = typeof event.role === "string" ? event.role : undefined;
|
||||||
|
const tool = toolFromActivity(event);
|
||||||
|
const phase = typeof event.phase === "string" ? event.phase : phaseFromType(type, event);
|
||||||
|
const text = textFromActivity(event);
|
||||||
|
const input = inputFromActivity(event);
|
||||||
|
const output = "output" in event ? event.output : "result" in event ? event.result : "partialResult" in event ? event.partialResult : undefined;
|
||||||
|
const error = typeof event.error === "string" ? event.error : undefined;
|
||||||
|
return { type, summary: summaryFor({ type, role, tool, phase, input, output, error }), at, role, tool, phase, text, input, output, error, payload: { ...event } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeActivity(activity: ReturnType<typeof normalizeActivity>) {
|
||||||
|
const { type, summary, at, role, tool, phase } = activity;
|
||||||
|
return { type, summary, at, role, tool, phase };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolFromActivity(event: Record<string, unknown>): string | undefined {
|
||||||
|
for (const key of ["tool", "toolName", "name"]) {
|
||||||
|
const value = event[key];
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseFromType(type: string, event: Record<string, unknown>): string | undefined {
|
||||||
|
const assistantEvent = event.assistantMessageEvent;
|
||||||
|
if (assistantEvent && typeof assistantEvent === "object" && !Array.isArray(assistantEvent)) {
|
||||||
|
const assistantType = (assistantEvent as { type?: unknown }).type;
|
||||||
|
if (typeof assistantType === "string") return assistantType;
|
||||||
|
}
|
||||||
|
if (type.endsWith("_start")) return "started";
|
||||||
|
if (type.endsWith("_started")) return "started";
|
||||||
|
if (type.endsWith("_update")) return "update";
|
||||||
|
if (type.endsWith("_delta")) return "delta";
|
||||||
|
if (type.endsWith("_end")) return "completed";
|
||||||
|
if (type.endsWith("_completed")) return "completed";
|
||||||
|
if (type.endsWith("_failed")) return "failed";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textFromActivity(event: Record<string, unknown>): string | undefined {
|
||||||
|
for (const key of ["text", "body", "content", "delta"]) {
|
||||||
|
const value = event[key];
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
}
|
||||||
|
const assistantEvent = event.assistantMessageEvent;
|
||||||
|
if (assistantEvent && typeof assistantEvent === "object" && !Array.isArray(assistantEvent)) {
|
||||||
|
for (const key of ["delta", "content"]) {
|
||||||
|
const value = (assistantEvent as Record<string, unknown>)[key];
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputFromActivity(event: Record<string, unknown>): unknown {
|
||||||
|
if ("input" in event) return event.input;
|
||||||
|
if ("args" in event) return event.args;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryFor(activity: { type: string; role?: string; tool?: string; phase?: string; input?: unknown; output?: unknown; error?: string }): string {
|
||||||
|
if (activity.error) return `${activity.tool ?? activity.type} failed: ${activity.error}`;
|
||||||
|
if (activity.tool) return `${activity.tool}${inputHint(activity.input)}`;
|
||||||
|
if (activity.type.startsWith("message")) return `${activity.role ?? "assistant"} message${activity.phase ? ` ${activity.phase}` : ""}`;
|
||||||
|
return activity.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputHint(input: unknown): string {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) return "";
|
||||||
|
const path = (input as { path?: unknown }).path;
|
||||||
|
if (typeof path === "string" && path.trim()) return ` ${path.trim()}`;
|
||||||
|
const command = (input as { command?: unknown }).command;
|
||||||
|
if (typeof command === "string" && command.trim()) return ` ${truncateActivityHint(command.trim())}`;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateActivityHint(value: string): string {
|
||||||
|
return value.length <= 80 ? value : `${value.slice(0, 79).trimEnd()}…`;
|
||||||
|
}
|
||||||
123
modules/agents/pi/extensions/subagents/types.ts
Normal file
123
modules/agents/pi/extensions/subagents/types.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
export type ContextMode = "independent" | "fork";
|
||||||
|
|
||||||
|
export const SUBAGENT_STATES = ["queued", "starting", "running", "settling", "completed", "failed", "cancelled", "timed_out", "orphaned"] as const;
|
||||||
|
export const SUBAGENT_TERMINAL_STATES = ["completed", "failed", "cancelled", "timed_out", "orphaned"] as const;
|
||||||
|
|
||||||
|
export type SubagentState = (typeof SUBAGENT_STATES)[number];
|
||||||
|
|
||||||
|
export interface ToolProfile {
|
||||||
|
activeTools: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnRequest {
|
||||||
|
prompt: string;
|
||||||
|
label?: string;
|
||||||
|
context?: ContextMode;
|
||||||
|
agent?: string;
|
||||||
|
model?: string;
|
||||||
|
thinking?: string;
|
||||||
|
tools?: string;
|
||||||
|
toolProfile?: ToolProfile;
|
||||||
|
agentBody?: string;
|
||||||
|
parentSessionFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnAccepted {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
context: ContextMode;
|
||||||
|
tools: string;
|
||||||
|
state: SubagentState;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentActivitySummary {
|
||||||
|
type: string;
|
||||||
|
summary: string;
|
||||||
|
at: string;
|
||||||
|
role?: string;
|
||||||
|
tool?: string;
|
||||||
|
phase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentActivityEvent extends SubagentActivitySummary {
|
||||||
|
text?: string;
|
||||||
|
input?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
error?: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentCurrentActivity extends SubagentActivitySummary {}
|
||||||
|
|
||||||
|
export type RunnerActivity = string | Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface SubagentStatus {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
agent?: string;
|
||||||
|
adHoc: boolean;
|
||||||
|
context: ContextMode;
|
||||||
|
state: SubagentState;
|
||||||
|
cwd: string;
|
||||||
|
model?: string;
|
||||||
|
thinking?: string;
|
||||||
|
tools: string;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
elapsedMs: number;
|
||||||
|
lastEvent?: string;
|
||||||
|
lastEventAt?: string;
|
||||||
|
currentActivity?: SubagentCurrentActivity;
|
||||||
|
activityHistory: SubagentActivitySummary[];
|
||||||
|
stopReason?: string;
|
||||||
|
resultAvailable: boolean;
|
||||||
|
childSession?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentResult {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
state: SubagentState;
|
||||||
|
running: boolean;
|
||||||
|
resultAvailable: boolean;
|
||||||
|
result?: string;
|
||||||
|
error?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
elapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubagentWaitMode = "all" | "any";
|
||||||
|
|
||||||
|
export interface SubagentWaitResult {
|
||||||
|
ids: string[];
|
||||||
|
mode: SubagentWaitMode;
|
||||||
|
ready: boolean;
|
||||||
|
results: SubagentResult[];
|
||||||
|
pending: SubagentStatus[];
|
||||||
|
timedOut: boolean;
|
||||||
|
elapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildRecord {
|
||||||
|
status: SubagentStatus;
|
||||||
|
activityEvents: SubagentActivityEvent[];
|
||||||
|
result?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunnerEvents {
|
||||||
|
accepted(childSession?: string): void;
|
||||||
|
running(event: RunnerActivity): void;
|
||||||
|
settling(): void;
|
||||||
|
completed(result: string, stopReason?: string): void;
|
||||||
|
failed(error: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildHandle {
|
||||||
|
cancel(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildRunner {
|
||||||
|
start(id: string, request: SpawnRequest, cwd: string, events: RunnerEvents): Promise<ChildHandle>;
|
||||||
|
}
|
||||||
89
modules/agents/pi/extensions/subagents/ui.test.ts
Normal file
89
modules/agents/pi/extensions/subagents/ui.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import type { SubagentState, SubagentStatus } from "./types.ts";
|
||||||
|
import { renderInspector, renderSummary, widget } from "./ui.ts";
|
||||||
|
|
||||||
|
function status(overrides: Partial<SubagentStatus> & { id: string; label: string; state: SubagentState }): SubagentStatus {
|
||||||
|
return {
|
||||||
|
adHoc: true,
|
||||||
|
context: "independent",
|
||||||
|
cwd: "/tmp",
|
||||||
|
elapsedMs: 0,
|
||||||
|
activityHistory: [],
|
||||||
|
resultAvailable: false,
|
||||||
|
startedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
tools: "inherit",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("compact monitor aggregates visible children by actionable lifecycle group", () => {
|
||||||
|
assert.deepEqual(renderSummary([]), []);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
renderSummary([
|
||||||
|
status({ id: "queued", label: "Queued", state: "queued" }),
|
||||||
|
status({ id: "starting", label: "Starting", state: "starting" }),
|
||||||
|
status({ id: "running", label: "Running", state: "running" }),
|
||||||
|
status({ id: "settling", label: "Settling", state: "settling" }),
|
||||||
|
status({ id: "completed", label: "Completed", state: "completed", resultAvailable: true }),
|
||||||
|
status({ id: "failed", label: "Failed", state: "failed", error: "boom" }),
|
||||||
|
status({ id: "timed-out", label: "Timed out", state: "timed_out" }),
|
||||||
|
status({ id: "cancelled", label: "Cancelled", state: "cancelled" }),
|
||||||
|
]),
|
||||||
|
["subagents: queued 1 · running 2 · settling 1 · completed 1 · failed 1 · timed out 1 · cancelled 1"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expanded monitor shows concise current activity summaries instead of raw event types", () => {
|
||||||
|
const rendered = widget([
|
||||||
|
status({
|
||||||
|
id: "sg-reading",
|
||||||
|
label: "Audit guest enablement plan",
|
||||||
|
state: "running",
|
||||||
|
elapsedMs: 12_000,
|
||||||
|
lastEvent: "message_update",
|
||||||
|
currentActivity: {
|
||||||
|
type: "message_update",
|
||||||
|
summary: "read secret-notes.md",
|
||||||
|
at: "2026-08-01T00:00:12.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
], true)().render(240);
|
||||||
|
|
||||||
|
assert.deepEqual(rendered, ["▶ running 12s Audit guest enablement plan last: read secret-notes.md"]);
|
||||||
|
assert.doesNotMatch(rendered.join("\n"), /message_update|private transcript body/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expanded monitor renders one truncated row per child with state, elapsed time, and activity marker", () => {
|
||||||
|
const lines = renderInspector([
|
||||||
|
status({
|
||||||
|
id: "sg-running",
|
||||||
|
label: "Audit unusually verbose guest enablement migration plan",
|
||||||
|
state: "running",
|
||||||
|
elapsedMs: 65_000,
|
||||||
|
lastEvent: "message_update",
|
||||||
|
}),
|
||||||
|
status({
|
||||||
|
id: "sg-completed",
|
||||||
|
label: "Summarize review",
|
||||||
|
state: "completed",
|
||||||
|
elapsedMs: 3_600_000,
|
||||||
|
lastEvent: "completed",
|
||||||
|
resultAvailable: true,
|
||||||
|
}),
|
||||||
|
status({ id: "sg-failed", label: "Run risky test", state: "failed", elapsedMs: 2_000, error: "exit 1" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(lines.length, 3);
|
||||||
|
assert.match(lines[0], /^▶ running +1m05s +Audit unusually verbose guest enablement migration plan +last: message_update$/u);
|
||||||
|
assert.equal(lines[1], "✓ completed 1h00m00s Summarize review result: available");
|
||||||
|
assert.equal(lines[2], "✗ failed 2s Run risky test error: exit 1");
|
||||||
|
|
||||||
|
const rendered = widget([
|
||||||
|
status({ id: "sg-running", label: "Audit unusually verbose guest enablement migration plan", state: "running", elapsedMs: 65_000, lastEvent: "message_update" }),
|
||||||
|
], true)().render(32);
|
||||||
|
|
||||||
|
assert.deepEqual(rendered, ["▶ running 1m05s Audit unusual…"]);
|
||||||
|
assert.ok(rendered.every((line) => line.length <= 32));
|
||||||
|
});
|
||||||
81
modules/agents/pi/extensions/subagents/ui.ts
Normal file
81
modules/agents/pi/extensions/subagents/ui.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { SubagentState, SubagentStatus } from "./types.ts";
|
||||||
|
|
||||||
|
const COMPACT_GROUPS: Array<{ label: string; states: SubagentState[] }> = [
|
||||||
|
{ label: "queued", states: ["queued"] },
|
||||||
|
{ label: "running", states: ["starting", "running"] },
|
||||||
|
{ label: "settling", states: ["settling"] },
|
||||||
|
{ label: "completed", states: ["completed"] },
|
||||||
|
{ label: "failed", states: ["failed"] },
|
||||||
|
{ label: "timed out", states: ["timed_out"] },
|
||||||
|
{ label: "cancelled", states: ["cancelled"] },
|
||||||
|
{ label: "orphaned", states: ["orphaned"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATE_PRESENTATION: Record<SubagentState, { icon: string; label: string }> = {
|
||||||
|
queued: { icon: "…", label: "queued" },
|
||||||
|
starting: { icon: "◌", label: "starting" },
|
||||||
|
running: { icon: "▶", label: "running" },
|
||||||
|
settling: { icon: "◒", label: "settling" },
|
||||||
|
completed: { icon: "✓", label: "completed" },
|
||||||
|
failed: { icon: "✗", label: "failed" },
|
||||||
|
cancelled: { icon: "■", label: "cancelled" },
|
||||||
|
timed_out: { icon: "⏱", label: "timed out" },
|
||||||
|
orphaned: { icon: "?", label: "orphaned" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function renderSummary(statuses: SubagentStatus[]): string[] {
|
||||||
|
const groups = COMPACT_GROUPS.map((group) => ({
|
||||||
|
label: group.label,
|
||||||
|
count: statuses.filter((status) => group.states.includes(status.state)).length,
|
||||||
|
})).filter((group) => group.count > 0);
|
||||||
|
|
||||||
|
if (groups.length === 0) return [];
|
||||||
|
return [`subagents: ${groups.map((group) => `${group.label} ${group.count}`).join(" · ")}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderInspector(statuses: SubagentStatus[]): string[] {
|
||||||
|
return statuses.map((status) => renderStatusRow(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function widget(statuses: SubagentStatus[], expanded: boolean) {
|
||||||
|
return () => ({
|
||||||
|
invalidate() {},
|
||||||
|
render(width: number) {
|
||||||
|
return (expanded ? renderInspector(statuses) : renderSummary(statuses)).map((line) => truncateLine(line, width));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatusRow(status: SubagentStatus): string {
|
||||||
|
const presentation = STATE_PRESENTATION[status.state];
|
||||||
|
const marker = statusMarker(status);
|
||||||
|
return `${presentation.icon} ${presentation.label.padEnd(9)} ${formatDuration(status.elapsedMs)} ${status.label}${marker ? ` ${marker}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusMarker(status: SubagentStatus): string | undefined {
|
||||||
|
if (status.error) return `error: ${status.error}`;
|
||||||
|
if (status.resultAvailable) return "result: available";
|
||||||
|
if (status.currentActivity) return `last: ${status.currentActivity.summary}`;
|
||||||
|
if (status.lastEvent) return `last: ${status.lastEvent}`;
|
||||||
|
if (status.state === "queued") return "waiting";
|
||||||
|
if (status.state === "settling") return "settling";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(elapsedMs: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.round(elapsedMs / 1000));
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) return `${hours}h${String(minutes).padStart(2, "0")}m${String(seconds).padStart(2, "0")}s`;
|
||||||
|
if (minutes > 0) return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateLine(line: string, width: number): string {
|
||||||
|
if (width <= 0) return "";
|
||||||
|
if (line.length <= width) return line;
|
||||||
|
if (width === 1) return "…";
|
||||||
|
return `${line.slice(0, width - 1)}…`;
|
||||||
|
}
|
||||||
171
modules/agents/pi/patches/pi-flex-spacer.patch
Normal file
171
modules/agents/pi/patches/pi-flex-spacer.patch
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts
|
||||||
|
--- a/packages/tui/src/tui.ts 2026-08-02 00:16:00.000000000 -0400
|
||||||
|
+++ b/packages/tui/src/tui.ts 2026-08-02 00:16:00.000000000 -0400
|
||||||
|
@@ -310,7 +310,7 @@ export class TUI extends Container {
|
||||||
|
private cursorRow = 0; // Logical cursor row (end of rendered content)
|
||||||
|
private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
||||||
|
private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
|
||||||
|
- private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
|
||||||
|
+ private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK !== "0"; // Clear empty rows when content shrinks (default: on)
|
||||||
|
private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
|
||||||
|
private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
||||||
|
private fullRedrawCount = 0;
|
||||||
|
|
||||||
|
diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts
|
||||||
|
--- a/packages/coding-agent/src/core/settings-manager.ts 2026-08-02 00:34:00.000000000 -0400
|
||||||
|
+++ b/packages/coding-agent/src/core/settings-manager.ts 2026-08-02 00:34:00.000000000 -0400
|
||||||
|
@@ -1093,11 +1093,11 @@ export class SettingsManager {
|
||||||
|
}
|
||||||
|
|
||||||
|
getClearOnShrink(): boolean {
|
||||||
|
- // Settings takes precedence, then env var, then default false
|
||||||
|
+ // Settings takes precedence, then env var, then default true
|
||||||
|
if (this.settings.terminal?.clearOnShrink !== undefined) {
|
||||||
|
return this.settings.terminal.clearOnShrink;
|
||||||
|
}
|
||||||
|
- return process.env.PI_CLEAR_ON_SHRINK === "1";
|
||||||
|
+ return process.env.PI_CLEAR_ON_SHRINK !== "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
setClearOnShrink(enabled: boolean): void {
|
||||||
|
|
||||||
|
diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
|
||||||
|
--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts 2026-08-01 18:41:36.963495957 -0400
|
||||||
|
+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts 2026-08-01 18:43:04.876341236 -0400
|
||||||
|
@@ -210,6 +210,47 @@
|
||||||
|
return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
+class FlexSpacerBottomLayout implements Component {
|
||||||
|
+ private readonly ui: TUI;
|
||||||
|
+ private readonly flowChildren: Component[];
|
||||||
|
+ private readonly pinnedChildren: Component[];
|
||||||
|
+
|
||||||
|
+ constructor(ui: TUI, flowChildren: Component[], pinnedChildren: Component[]) {
|
||||||
|
+ this.ui = ui;
|
||||||
|
+ this.flowChildren = flowChildren;
|
||||||
|
+ this.pinnedChildren = pinnedChildren;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ invalidate(): void {
|
||||||
|
+ for (const child of [...this.flowChildren, ...this.pinnedChildren]) {
|
||||||
|
+ child.invalidate();
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ private renderGroup(children: Component[], width: number): string[] {
|
||||||
|
+ const lines: string[] = [];
|
||||||
|
+ for (const child of children) {
|
||||||
|
+ for (const line of child.render(width)) {
|
||||||
|
+ lines.push(line);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ return lines;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ render(width: number): string[] {
|
||||||
|
+ const flowLines = this.renderGroup(this.flowChildren, width);
|
||||||
|
+ const pinnedLines = this.renderGroup(this.pinnedChildren, width);
|
||||||
|
+ const terminalRows = this.ui.terminal.rows;
|
||||||
|
+ const spacerRows = Math.max(0, terminalRows - flowLines.length - pinnedLines.length);
|
||||||
|
+
|
||||||
|
+ return [
|
||||||
|
+ ...flowLines,
|
||||||
|
+ ...Array.from({ length: spacerRows }, () => ""),
|
||||||
|
+ ...pinnedLines,
|
||||||
|
+ ];
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING =
|
||||||
|
"Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings.";
|
||||||
|
|
||||||
|
@@ -335,6 +376,7 @@
|
||||||
|
private fdPath: string | undefined;
|
||||||
|
private editorContainer: Container;
|
||||||
|
private footer: FooterComponent;
|
||||||
|
+ private footerContainer: Container;
|
||||||
|
private footerDataProvider: FooterDataProvider;
|
||||||
|
// Stored so the same manager can be injected into custom editors, selectors, and extension UI.
|
||||||
|
private keybindings: KeybindingsManager;
|
||||||
|
@@ -477,7 +519,9 @@
|
||||||
|
this.editorContainer = new Container();
|
||||||
|
this.editorContainer.addChild(this.editor as Component);
|
||||||
|
this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());
|
||||||
|
+ this.footerContainer = new Container();
|
||||||
|
this.footer = new FooterComponent(this.session, this.footerDataProvider);
|
||||||
|
+ this.footerContainer.addChild(this.footer);
|
||||||
|
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
|
||||||
|
|
||||||
|
// Load hide thinking block setting
|
||||||
|
@@ -704,19 +748,25 @@
|
||||||
|
console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
- // Add header container as first child. Populate it after applying theme settings.
|
||||||
|
- // Keep loaded resources before chat so restored session messages never precede them.
|
||||||
|
- this.ui.addChild(this.headerContainer);
|
||||||
|
- this.ui.addChild(this.loadedResourcesContainer);
|
||||||
|
-
|
||||||
|
- this.ui.addChild(this.chatContainer);
|
||||||
|
- this.ui.addChild(this.pendingMessagesContainer);
|
||||||
|
- this.ui.addChild(this.statusContainer);
|
||||||
|
this.renderWidgets(); // Initialize with default spacer
|
||||||
|
- this.ui.addChild(this.widgetContainerAbove);
|
||||||
|
- this.ui.addChild(this.editorContainer);
|
||||||
|
- this.ui.addChild(this.widgetContainerBelow);
|
||||||
|
- this.ui.addChild(this.footer);
|
||||||
|
+ this.ui.addChild(
|
||||||
|
+ new FlexSpacerBottomLayout(
|
||||||
|
+ this.ui,
|
||||||
|
+ [
|
||||||
|
+ this.headerContainer,
|
||||||
|
+ this.loadedResourcesContainer,
|
||||||
|
+ this.chatContainer,
|
||||||
|
+ ],
|
||||||
|
+ [
|
||||||
|
+ this.pendingMessagesContainer,
|
||||||
|
+ this.statusContainer,
|
||||||
|
+ this.widgetContainerAbove,
|
||||||
|
+ this.editorContainer,
|
||||||
|
+ this.widgetContainerBelow,
|
||||||
|
+ this.footerContainer,
|
||||||
|
+ ],
|
||||||
|
+ ),
|
||||||
|
+ );
|
||||||
|
this.ui.setFocus(this.editor);
|
||||||
|
|
||||||
|
this.setupKeyHandlers();
|
||||||
|
@@ -2033,25 +2083,25 @@
|
||||||
|
| ((tui: TUI, thm: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void })
|
||||||
|
| undefined,
|
||||||
|
): void {
|
||||||
|
- // Dispose existing custom footer
|
||||||
|
+ // Dispose existing custom footer
|
||||||
|
if (this.customFooter?.dispose) {
|
||||||
|
this.customFooter.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
- // Remove current footer from UI
|
||||||
|
+ // Remove current footer from its pinned layout slot.
|
||||||
|
if (this.customFooter) {
|
||||||
|
- this.ui.removeChild(this.customFooter);
|
||||||
|
+ this.footerContainer.removeChild(this.customFooter);
|
||||||
|
} else {
|
||||||
|
- this.ui.removeChild(this.footer);
|
||||||
|
+ this.footerContainer.removeChild(this.footer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (factory) {
|
||||||
|
// Create and add custom footer, passing the data provider
|
||||||
|
this.customFooter = factory(this.ui, theme, this.footerDataProvider);
|
||||||
|
- this.ui.addChild(this.customFooter);
|
||||||
|
+ this.footerContainer.addChild(this.customFooter);
|
||||||
|
} else {
|
||||||
|
// Restore built-in footer
|
||||||
|
this.customFooter = undefined;
|
||||||
|
- this.ui.addChild(this.footer);
|
||||||
|
+ this.footerContainer.addChild(this.footer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ui.requestRender();
|
||||||
22
modules/agents/pi/patches/pi-tool-lookup-validation.patch
Normal file
22
modules/agents/pi/patches/pi-tool-lookup-validation.patch
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts
|
||||||
|
--- a/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:36.970496010 -0400
|
||||||
|
+++ b/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:37.028186009 -0400
|
||||||
|
@@ -74,8 +74,7 @@
|
||||||
|
function commandExists(cmd: string): boolean {
|
||||||
|
try {
|
||||||
|
const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
|
||||||
|
- // Check for ENOENT error (command not found)
|
||||||
|
- return result.error === undefined || result.error === null;
|
||||||
|
+ return (result.error === undefined || result.error === null) && result.status === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
@@ -88,7 +87,7 @@
|
||||||
|
|
||||||
|
// Check our tools directory first
|
||||||
|
const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : ""));
|
||||||
|
- if (existsSync(localPath)) {
|
||||||
|
+ if (existsSync(localPath) && commandExists(localPath)) {
|
||||||
|
return localPath;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,6 +11,46 @@ let
|
|||||||
cfg = config.modules.agents.pi;
|
cfg = config.modules.agents.pi;
|
||||||
user = config.user.name;
|
user = config.user.name;
|
||||||
piDir = "${config.users.users.${user}.home}/.pi/agent";
|
piDir = "${config.users.users.${user}.home}/.pi/agent";
|
||||||
|
reservedToolProfiles = [
|
||||||
|
"none"
|
||||||
|
"read-only"
|
||||||
|
"read-only-with-safe-bash"
|
||||||
|
"full-tools"
|
||||||
|
];
|
||||||
|
subagentsConfig =
|
||||||
|
lib.optionalAttrs (cfg.subagents.defaultContext != null) {
|
||||||
|
defaultContext = cfg.subagents.defaultContext;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.subagents.defaultTools != null) {
|
||||||
|
defaultTools = cfg.subagents.defaultTools;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.subagents.maxConcurrent != null) {
|
||||||
|
maxConcurrent = cfg.subagents.maxConcurrent;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.subagents.recentTerminalTtlMs != null) {
|
||||||
|
recentTerminalTtlMs = cfg.subagents.recentTerminalTtlMs;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (
|
||||||
|
cfg.subagents.ui.enabled != null || cfg.subagents.ui.defaultExpanded != null
|
||||||
|
) {
|
||||||
|
ui =
|
||||||
|
lib.optionalAttrs (cfg.subagents.ui.enabled != null) {
|
||||||
|
enabled = cfg.subagents.ui.enabled;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.subagents.ui.defaultExpanded != null) {
|
||||||
|
defaultExpanded = cfg.subagents.ui.defaultExpanded;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.subagents.toolProfiles != { }) {
|
||||||
|
toolProfiles = cfg.subagents.toolProfiles;
|
||||||
|
};
|
||||||
|
subagentsJson = (pkgs.formats.json { }).generate "pi-subagents.json" subagentsConfig;
|
||||||
|
patchedPi = pkgs.pi-coding-agent.overrideAttrs (old: {
|
||||||
|
patches = (old.patches or [ ]) ++ [
|
||||||
|
./patches/pi-flex-spacer.patch
|
||||||
|
./patches/pi-tool-lookup-validation.patch
|
||||||
|
];
|
||||||
|
});
|
||||||
herdrPiIntegration = pkgs.stdenvNoCC.mkDerivation {
|
herdrPiIntegration = pkgs.stdenvNoCC.mkDerivation {
|
||||||
name = "herdr-pi-integration";
|
name = "herdr-pi-integration";
|
||||||
nativeBuildInputs = [ pkgs.herdr ];
|
nativeBuildInputs = [ pkgs.herdr ];
|
||||||
@@ -33,13 +73,116 @@ let
|
|||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.modules.agents.pi.enable = lib.mkEnableOption ''
|
options.modules.agents.pi = {
|
||||||
|
enable = lib.mkEnableOption ''
|
||||||
Pi, a terminal coding agent, configured via home-manager'';
|
Pi, a terminal coding agent, configured via home-manager'';
|
||||||
|
|
||||||
|
subagents = {
|
||||||
|
defaultContext = lib.mkOption {
|
||||||
|
type = lib.types.nullOr (lib.types.enum [
|
||||||
|
"independent"
|
||||||
|
"fork"
|
||||||
|
]);
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Default context mode for subagents.
|
||||||
|
Left null, the extension keeps its in-code default.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
defaultTools = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
example = "read-only-with-safe-bash";
|
||||||
|
description = ''
|
||||||
|
Default tool profile for subagents.
|
||||||
|
Left null, the extension keeps its in-code default.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
maxConcurrent = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.ints.positive;
|
||||||
|
default = null;
|
||||||
|
example = 4;
|
||||||
|
description = ''
|
||||||
|
Maximum number of child processes allowed to run concurrently.
|
||||||
|
Left null, the extension keeps its in-code default.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
recentTerminalTtlMs = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.ints.unsigned;
|
||||||
|
default = null;
|
||||||
|
example = 600000;
|
||||||
|
description = ''
|
||||||
|
Milliseconds to retain terminal subagents in the recent work set.
|
||||||
|
Zero disables time-based retention.
|
||||||
|
Left null, the extension keeps its in-code default.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
ui = {
|
||||||
|
enabled = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.bool;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Whether the extension renders its built-in subagent monitor.
|
||||||
|
Left null, the extension keeps its in-code default.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
defaultExpanded = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.bool;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Whether the built-in subagent monitor starts expanded.
|
||||||
|
Left null, the extension keeps its in-code default.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
toolProfiles = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf (
|
||||||
|
lib.types.submodule {
|
||||||
|
options.activeTools = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
description = "Pi tools made available to a child using this profile.";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
default = { };
|
||||||
|
example = {
|
||||||
|
review = {
|
||||||
|
activeTools = [
|
||||||
|
"read"
|
||||||
|
"grep"
|
||||||
|
"find"
|
||||||
|
"ls"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
description = ''
|
||||||
|
Custom named tool profiles for subagents.
|
||||||
|
The extension's reserved built-in profile names cannot be redefined.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = lib.intersectLists reservedToolProfiles (
|
||||||
|
builtins.attrNames cfg.subagents.toolProfiles
|
||||||
|
) == [ ];
|
||||||
|
message = "modules.agents.pi.subagents.toolProfiles may not redefine the reserved profiles: ${lib.concatStringsSep ", " reservedToolProfiles}.";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
home-manager.users.${user} = {
|
home-manager.users.${user} = {
|
||||||
programs.pi-coding-agent = {
|
programs.pi-coding-agent = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
package = patchedPi;
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
defaultProvider = "openai-codex";
|
defaultProvider = "openai-codex";
|
||||||
@@ -51,7 +194,8 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
home.file = {
|
home.file =
|
||||||
|
{
|
||||||
# The first declarative rollout replaces the interactive settings file.
|
# The first declarative rollout replaces the interactive settings file.
|
||||||
# Login state stays in auth.json, which this module does not manage.
|
# Login state stays in auth.json, which this module does not manage.
|
||||||
"${piDir}/settings.json".force = true;
|
"${piDir}/settings.json".force = true;
|
||||||
@@ -65,6 +209,13 @@ in
|
|||||||
source = ./prompts;
|
source = ./prompts;
|
||||||
recursive = true;
|
recursive = true;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (subagentsConfig != { }) {
|
||||||
|
# Declaring any global override makes Nix the owner of the runtime file.
|
||||||
|
"${piDir}/subagents.json" = {
|
||||||
|
source = subagentsJson;
|
||||||
|
force = true;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ in
|
|||||||
blur.enabled = cfg.blur;
|
blur.enabled = cfg.blur;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# XWayland clients render at the panel's native resolution instead of
|
||||||
|
# being raster-scaled by the compositor at the fractional monitor scale.
|
||||||
|
xwayland.force_zero_scaling = true;
|
||||||
|
|
||||||
animations = {
|
animations = {
|
||||||
enabled = true;
|
enabled = true;
|
||||||
bezier = [ "ease, 0.25, 0.1, 0.25, 1.0" ];
|
bezier = [ "ease, 0.25, 0.1, 0.25, 1.0" ];
|
||||||
|
|||||||
24
modules/desktop/steam.nix
Normal file
24
modules/desktop/steam.nix
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# Steam game launcher and runtime integration.
|
||||||
|
let
|
||||||
|
cfg = config.modules.desktop.steam;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.modules.desktop.steam.enable = lib.mkEnableOption "Steam game launcher";
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
programs.steam = {
|
||||||
|
enable = true;
|
||||||
|
package = pkgs.steam.override {
|
||||||
|
extraEnv.STEAM_FORCE_DESKTOPUI_SCALING = "1.5";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
hardware.steam-hardware.enable = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ in
|
|||||||
|
|
||||||
image = wallpaper;
|
image = wallpaper;
|
||||||
|
|
||||||
|
# Regreet is not enabled, so its styling target stays off.
|
||||||
|
targets.regreet.enable = false;
|
||||||
|
|
||||||
cursor = {
|
cursor = {
|
||||||
package = pkgs.bibata-cursors;
|
package = pkgs.bibata-cursors;
|
||||||
# Solid white with a dark outline, so it stays easy to spot against the
|
# Solid white with a dark outline, so it stays easy to spot against the
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ in
|
|||||||
"class<chromium.*>" = g "f268";
|
"class<chromium.*>" = g "f268";
|
||||||
"class<[Cc]ode>" = g "f121";
|
"class<[Cc]ode>" = g "f121";
|
||||||
"class<obsidian>" = g "f02d";
|
"class<obsidian>" = g "f02d";
|
||||||
|
"class<[Ss]team>" = g "f1b6";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ in
|
|||||||
];
|
];
|
||||||
|
|
||||||
shellAliases = {
|
shellAliases = {
|
||||||
ls = "eza -al --color=always --group-directories-first --icons=always";
|
ls = "eza -alg --color=always --group-directories-first --icons=always";
|
||||||
la = "eza -a --color=always --group-directories-first --icons=always";
|
la = "eza -a --color=always --group-directories-first --icons=always";
|
||||||
ll = "eza -l --color=always --group-directories-first --icons=always";
|
ll = "eza -lg --color=always --group-directories-first --icons=always";
|
||||||
lt = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
|
lt = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
|
||||||
"l." = "eza -a | grep -e '^\\.'";
|
"l." = "eza -a | grep -e '^\\.'";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ in
|
|||||||
home-manager.users.${user} = hm: {
|
home-manager.users.${user} = hm: {
|
||||||
programs.nixvim = {
|
programs.nixvim = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
nixpkgs.useGlobalPackages = true;
|
||||||
|
|
||||||
extraPackages = with pkgs; [
|
extraPackages = with pkgs; [
|
||||||
git # neogit and gitsigns shell out to git
|
git # neogit and gitsigns shell out to git
|
||||||
|
|||||||
352
modules/ssh.nix
352
modules/ssh.nix
@@ -8,119 +8,298 @@ let
|
|||||||
cfg = config.modules.ssh;
|
cfg = config.modules.ssh;
|
||||||
user = config.user.name;
|
user = config.user.name;
|
||||||
|
|
||||||
|
inherit (lib)
|
||||||
|
concatLists
|
||||||
|
concatStringsSep
|
||||||
|
elem
|
||||||
|
filter
|
||||||
|
genAttrs
|
||||||
|
hasAttr
|
||||||
|
imap0
|
||||||
|
listToAttrs
|
||||||
|
mapAttrs
|
||||||
|
mapAttrsToList
|
||||||
|
mkIf
|
||||||
|
mkMerge
|
||||||
|
mkOption
|
||||||
|
nameValuePair
|
||||||
|
optional
|
||||||
|
optionalAttrs
|
||||||
|
types
|
||||||
|
unique
|
||||||
|
;
|
||||||
|
|
||||||
hostKeySecret = type: "ssh-host-${type}-key";
|
hostKeySecret = type: "ssh-host-${type}-key";
|
||||||
userKeySecret = "ssh-user-ed25519-key";
|
userKeySecret = "ssh-user-ed25519-key";
|
||||||
|
|
||||||
|
targetType = types.submodule (
|
||||||
|
{ name, ... }:
|
||||||
|
{
|
||||||
|
options = {
|
||||||
|
hostName = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = name;
|
||||||
|
description = ''
|
||||||
|
The network address OpenSSH connects to for this target.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
user = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = config.user.name;
|
||||||
|
description = ''
|
||||||
|
The remote login name OpenSSH uses for this target.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
port = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 22;
|
||||||
|
description = ''
|
||||||
|
The TCP port OpenSSH uses for this target.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
aliases = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [ name ];
|
||||||
|
description = ''
|
||||||
|
Host patterns written into the generated OpenSSH client block.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
clientKey = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
The public key this target offers when it connects outward.
|
||||||
|
Other machines admit this key according to the host groups below.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
hostKeys = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [ ];
|
||||||
|
description = ''
|
||||||
|
The public keys this target presents when it accepts inbound SSH.
|
||||||
|
These keys generate system-wide known-host entries.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
keyWithoutComment = key: concatStringsSep " " (lib.take 2 (lib.splitString " " key));
|
||||||
|
|
||||||
|
targetNamesIn = names: filter (name: hasAttr name cfg.targets) names;
|
||||||
|
|
||||||
|
workstationNames = targetNamesIn cfg.hosts.workstations;
|
||||||
|
serverNames = targetNamesIn cfg.hosts.servers;
|
||||||
|
|
||||||
|
clientKeysFor = names: filter (key: key != null) (map (name: cfg.targets.${name}.clientKey) names);
|
||||||
|
|
||||||
|
currentHost = config.networking.hostName;
|
||||||
|
isServer = elem currentHost cfg.hosts.servers;
|
||||||
|
isWorkstation = elem currentHost cfg.hosts.workstations;
|
||||||
|
|
||||||
|
defaultAuthorizedKeys = lib.flatten (
|
||||||
|
clientKeysFor workstationNames
|
||||||
|
++ optional isServer (clientKeysFor serverNames)
|
||||||
|
);
|
||||||
|
|
||||||
|
outboundTargetNames =
|
||||||
|
let
|
||||||
|
groupTargets =
|
||||||
|
if isServer then
|
||||||
|
[ "gitea" ] ++ cfg.hosts.servers
|
||||||
|
else if isWorkstation then
|
||||||
|
[ "gitea" ] ++ cfg.hosts.servers ++ cfg.hosts.workstations
|
||||||
|
else
|
||||||
|
[ "gitea" ];
|
||||||
|
in
|
||||||
|
filter (name: name != currentHost) (targetNamesIn groupTargets);
|
||||||
|
|
||||||
|
sshSettingsFor = name:
|
||||||
|
let
|
||||||
|
target = cfg.targets.${name};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
header = "Host ${concatStringsSep " " target.aliases}";
|
||||||
|
HostName = target.hostName;
|
||||||
|
User = target.user;
|
||||||
|
}
|
||||||
|
// optionalAttrs (target.port != 22) { Port = target.port; };
|
||||||
|
|
||||||
|
knownHostNamesFor = target:
|
||||||
|
let
|
||||||
|
names = unique (target.aliases ++ [ target.hostName ]);
|
||||||
|
withPort = name: if target.port == 22 then name else "[${name}]:${toString target.port}";
|
||||||
|
in
|
||||||
|
map withPort names;
|
||||||
|
|
||||||
|
knownHosts = listToAttrs (
|
||||||
|
concatLists (
|
||||||
|
mapAttrsToList (
|
||||||
|
targetName: target:
|
||||||
|
imap0 (i: key:
|
||||||
|
nameValuePair "${targetName}-${toString i}" {
|
||||||
|
hostNames = knownHostNamesFor target;
|
||||||
|
publicKey = keyWithoutComment key;
|
||||||
|
}
|
||||||
|
) target.hostKeys
|
||||||
|
) cfg.targets
|
||||||
|
)
|
||||||
|
);
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.modules.ssh = {
|
options.modules.ssh = {
|
||||||
enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets";
|
enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets";
|
||||||
|
|
||||||
workstationKeys = lib.mkOption {
|
hosts = {
|
||||||
type = lib.types.listOf lib.types.str;
|
servers = mkOption {
|
||||||
default = [
|
type = types.listOf types.str;
|
||||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia"
|
default = [ "pikachu" ];
|
||||||
];
|
|
||||||
description = ''
|
description = ''
|
||||||
Client public keys of the machines the operator works from.
|
Hosts that serve durable services.
|
||||||
|
They admit workstation keys and server keys, and they receive aliases for other servers and the forge.
|
||||||
Every machine admits these, so any of them reaches the whole fleet.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
serverKeys = lib.mkOption {
|
workstations = mkOption {
|
||||||
type = lib.types.listOf lib.types.str;
|
type = types.listOf types.str;
|
||||||
|
default = [ "neogaia" ];
|
||||||
|
description = ''
|
||||||
|
Hosts the operator works from.
|
||||||
|
Their keys are admitted by every host, and they receive aliases for the whole fleet and the forge.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
targets = mkOption {
|
||||||
|
type = types.attrsOf targetType;
|
||||||
|
default = {
|
||||||
|
gitea = {
|
||||||
|
hostName = "git.alexion.dev";
|
||||||
|
port = 2022;
|
||||||
|
user = "gitea";
|
||||||
|
aliases = [
|
||||||
|
"gitea"
|
||||||
|
"git.alexion.dev"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
neogaia = {
|
||||||
|
hostName = "10.23.50.146";
|
||||||
|
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia";
|
||||||
|
hostKeys = [
|
||||||
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p neogaia"
|
||||||
|
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCo2wWUKxyAS4J5TqbWf8glDhJvS5XmdRqFhMeJwG3pOB+4AccZ1T8LU7ZN+RjtRi3j2qXBJvIHuzhtQNtmT59TxocvfobYiqOgJpvVO5K6yD8ZoUJs6ziDkIduI9w9mdRIESoi+dBbVu8n24r61cKDVh+jWX+yjzkOcWcOzqDyQhhkjqblZ1WMAdujEMuEPvif1i2LCxStUaZqRGcx09m/ME2fYcaJrpuxxxvX2+CPJNicoo6Rx9i7ZjAoNuvH+jui4KT62DzlQtQtCl2CFUOM0gCPSa+MbNQ9elfHPvGzEcwOIMo2cuy9KURUkQu+sAgaG8S1PEniDDTecskHtuRdmPZawnQGpIhzo919Q6wUgjT8scK4mmSXRWmGmkMt0GNA2tfj5tDks6r5Q8XsYqtWs4rsOEvfmxVSdM771w+fqDBAil99Jsh0ksPK9+Bwgg8cMDzLLFDn8JA5y2G1HocMMom+u5DYKwPXEKnCILkasB8y24+O3PhSu1EuWw277w6EUEXvU03rCf0Ak/ULjxp9a00EGlloEwSmFI7Aub9XHDr87IdbGInEn+PMqyBYADiN+3h6nE2JO+nMa6i/CHdebmT+T7YJvuTKHD9sjFmQsYaghlq03DZrhHcm4hgUvE1dqGojHrhk/WgA3EWTWtK/+BP0Vy2jXaaz+qAx+EGnhQ== neogaia"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
pikachu = {
|
||||||
|
hostName = "10.23.10.102";
|
||||||
|
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNqJIC6VRXyvrNf3n9su9KdPCikC3CjK/QrCK2reHdB alexion@pikachu";
|
||||||
|
hostKeys = [
|
||||||
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu"
|
||||||
|
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy/riwm7dflA3mT+3a0/2CIoS2LbAsK/vn35kOoNeuzn0yhiF+imexP6tkB3S2t+H5ybRzkbbuNZcynFfeCqthFc8kvbdCnt8Diqoeg96fZ6ecvh5QE5yH9op8534EySetZ/exakFLnF+6EiWMuWUW3DFwsc2kcgDJObqSE8gTx/d7JK953MiTFmSJBFyg1RtQ3ZnMT+iCrvY2dyCLQai7VeF8koVKF2c0leAq2Hc75rb/L9md8MoJa64iPiz7hwTCin3xoFyaY/5hNVvyqFd5PivgR69gLdJkuVsUYO2mJzhur8cYmJD+pGjJ0U45hyE9TMrCFjeJHHuvSt3+2kph62wv95jLNk0WmMlwgyunISxENCSVVtNYdBMXhUh8VhEAW17QpVUg9EnPvxOdTKEjrvfOZYASWUa51JKbgBgexVgFbxdjDZR88DZa31AVBts/cx/59gXTUahFXMYLdZgssx+5uibZQWnvCyfUV9WLbfmK1lgL6hzReg1VkQ87iGr6skjtQYemJxRaFNA1+Q5f3kmG3KncuK/594a3qXYP4gC6A2blf8om1YZ4aXXh6f+GFKLjoEw1vvM2rJ+rjzfymwDX+pxVQ9L13OEtVZc9Ez76pOkbm1hqdbL0gY45+0cpxodhWV0wMQJBDXL1MHP8qcs+/vw0GxVK5l1SnWBGlw== root@pikachu"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
description = ''
|
||||||
|
SSH targets known to the fleet.
|
||||||
|
The inventory holds connection details plus public keys used for authorization and host verification.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
extraAuthorizedKeys = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
default = [ ];
|
default = [ ];
|
||||||
description = ''
|
description = ''
|
||||||
Client public keys of the machines that serve.
|
Additional client public keys admitted by this host.
|
||||||
|
|
||||||
Only other servers admit these, so one that is compromised reaches no
|
|
||||||
machine the operator works from.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
authorizedKeys = lib.mkOption {
|
authorizedKeys = mkOption {
|
||||||
type = lib.types.listOf lib.types.str;
|
type = types.nullOr (types.listOf types.str);
|
||||||
default = cfg.workstationKeys;
|
|
||||||
defaultText = lib.literalExpression "config.modules.ssh.workstationKeys";
|
|
||||||
description = ''
|
|
||||||
Client public keys this machine admits for the primary user, drawn from
|
|
||||||
the lists above.
|
|
||||||
|
|
||||||
A machine the operator works from takes the workstation keys. One that
|
|
||||||
serves takes both, so servers reach each other. The default admits the
|
|
||||||
workstation keys, since a machine admitting none is unreachable.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
hostKeys.restore = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = true;
|
|
||||||
description = ''
|
|
||||||
Restore the host keys from secrets rather than letting the daemon
|
|
||||||
generate its own.
|
|
||||||
|
|
||||||
A machine with its own identity keeps its fingerprint across a reimage
|
|
||||||
by restoring committed keys. A guest carries no host identity, so it
|
|
||||||
turns this off and presents a self-generated key instead.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
hostKeys.sopsFile = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.path;
|
|
||||||
default = null;
|
default = null;
|
||||||
description = ''
|
description = ''
|
||||||
Encrypted file holding this host's SSH host private keys, one entry per
|
Complete override for client public keys admitted by this host.
|
||||||
key type, named `ssh-host-<type>-key`. Required when `restore` is on.
|
Leave null to derive access from `modules.ssh.hosts` and `modules.ssh.targets`.
|
||||||
|
|
||||||
These are the keys the daemon presents to identify itself to connecting
|
|
||||||
clients, not keys used to authenticate anyone to a remote server.
|
|
||||||
Restoring them from secrets rather than generating them keeps the host's
|
|
||||||
fingerprint across a reimage, so every client's `known_hosts` entry
|
|
||||||
stays valid.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
hostKeys.types = lib.mkOption {
|
extraSettings = mkOption {
|
||||||
type = lib.types.listOf lib.types.str;
|
type = types.attrsOf types.anything;
|
||||||
|
default = { };
|
||||||
|
description = ''
|
||||||
|
Additional OpenSSH client settings merged into the generated Home Manager configuration.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
hostKeys.restore = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = true;
|
||||||
|
description = ''
|
||||||
|
Restore the host keys from secrets rather than letting the daemon generate its own.
|
||||||
|
A machine with its own identity keeps its fingerprint across a reimage by restoring committed keys.
|
||||||
|
A guest carries no host identity, so it turns this off and presents a self-generated key instead.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
hostKeys.sopsFile = mkOption {
|
||||||
|
type = types.nullOr types.path;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Encrypted file holding this host's SSH host private keys, one entry per key type, named `ssh-host-<type>-key`.
|
||||||
|
Required when `restore` is on.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
hostKeys.types = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
default = [
|
default = [
|
||||||
"ed25519"
|
"ed25519"
|
||||||
"rsa"
|
"rsa"
|
||||||
];
|
];
|
||||||
description = ''
|
description = ''
|
||||||
Key types to restore, naming both the entries read from the encrypted
|
Key types to restore, naming both the entries read from the encrypted file and the algorithms the daemon offers.
|
||||||
file and the algorithms the daemon offers. Dropping a type a client has
|
|
||||||
already pinned makes the host unrecognisable to it.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
userKey.sopsFile = lib.mkOption {
|
userKey.sopsFile = mkOption {
|
||||||
type = lib.types.nullOr lib.types.path;
|
type = types.nullOr types.path;
|
||||||
default = null;
|
default = null;
|
||||||
description = ''
|
description = ''
|
||||||
Encrypted file holding this machine's SSH client private key, under the
|
Encrypted file holding this machine's SSH client private key, under the entry `ssh-user-ed25519-key`.
|
||||||
entry `ssh-user-ed25519-key`. Left unset on a machine that authenticates
|
Left unset on a machine that authenticates to no remote server, such as a guest.
|
||||||
to no remote server, such as a guest.
|
|
||||||
|
|
||||||
This is the key the primary user offers to authenticate to a remote
|
|
||||||
server, not a key the daemon presents to identify this machine.
|
|
||||||
It belongs to this machine alone, so withdrawing its access does not
|
|
||||||
re-key any other.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable (
|
config = mkIf cfg.enable (
|
||||||
lib.mkMerge [
|
mkMerge [
|
||||||
{
|
{
|
||||||
services.openssh.enable = true;
|
services.openssh.enable = true;
|
||||||
|
|
||||||
# The primary user is the only account reachable over SSH.
|
users.users.${user}.openssh.authorizedKeys.keys =
|
||||||
users.users.${user}.openssh.authorizedKeys.keys = cfg.authorizedKeys;
|
if cfg.authorizedKeys != null then
|
||||||
|
cfg.authorizedKeys
|
||||||
|
else
|
||||||
|
defaultAuthorizedKeys ++ cfg.extraAuthorizedKeys;
|
||||||
|
|
||||||
|
programs.ssh.knownHosts = knownHosts;
|
||||||
|
|
||||||
|
home-manager.users.${user}.programs.ssh = {
|
||||||
|
enable = true;
|
||||||
|
enableDefaultConfig = false;
|
||||||
|
settings =
|
||||||
|
mapAttrs (name: _: sshSettingsFor name) (genAttrs outboundTargetNames (name: name))
|
||||||
|
// cfg.extraSettings;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
# A machine with its own identity restores its host keys from secrets.
|
(mkIf cfg.hostKeys.restore {
|
||||||
(lib.mkIf cfg.hostKeys.restore {
|
|
||||||
assertions = [
|
assertions = [
|
||||||
{
|
{
|
||||||
assertion = cfg.hostKeys.sopsFile != null;
|
assertion = cfg.hostKeys.sopsFile != null;
|
||||||
@@ -128,42 +307,27 @@ in
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
# The daemon reads its host keys once at startup, so a re-key has to
|
sops.secrets = genAttrs (map hostKeySecret cfg.hostKeys.types) (_: {
|
||||||
# restart it to take effect.
|
|
||||||
sops.secrets = lib.genAttrs (map hostKeySecret cfg.hostKeys.types) (_: {
|
|
||||||
inherit (cfg.hostKeys) sopsFile;
|
inherit (cfg.hostKeys) sopsFile;
|
||||||
mode = "0400";
|
mode = "0400";
|
||||||
restartUnits = [ "sshd.service" ];
|
restartUnits = [ "sshd.service" ];
|
||||||
});
|
});
|
||||||
|
|
||||||
# An empty list is what stops the daemon generating keys of its own.
|
|
||||||
services.openssh.hostKeys = [ ];
|
services.openssh.hostKeys = [ ];
|
||||||
services.openssh.extraConfig = lib.concatMapStrings (
|
services.openssh.extraConfig = concatStringsSep "" (
|
||||||
type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n"
|
map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") cfg.hostKeys.types
|
||||||
) cfg.hostKeys.types;
|
);
|
||||||
})
|
})
|
||||||
|
|
||||||
# The client key the primary user offers to remote servers, present only on
|
(mkIf (cfg.userKey.sopsFile != null) {
|
||||||
# a machine that has one.
|
|
||||||
(lib.mkIf (cfg.userKey.sopsFile != null) {
|
|
||||||
# The primary user is the only account that authenticates with this key,
|
|
||||||
# and the mode admits no other.
|
|
||||||
# The client rereads it per connection, so no unit restarts on a re-key.
|
|
||||||
sops.secrets.${userKeySecret} = {
|
sops.secrets.${userKeySecret} = {
|
||||||
inherit (cfg.userKey) sopsFile;
|
inherit (cfg.userKey) sopsFile;
|
||||||
mode = "0400";
|
mode = "0400";
|
||||||
owner = user;
|
owner = user;
|
||||||
};
|
};
|
||||||
|
|
||||||
# The client reads the decrypted key where it is written, so no copy of it
|
home-manager.users.${user}.programs.ssh.settings."*".IdentityFile =
|
||||||
# lives in the user's home to drift from the secret.
|
config.sops.secrets.${userKeySecret}.path;
|
||||||
# Declaring no defaults of home-manager's own leaves every other directive
|
|
||||||
# at the one OpenSSH itself ships.
|
|
||||||
home-manager.users.${user}.programs.ssh = {
|
|
||||||
enable = true;
|
|
||||||
enableDefaultConfig = false;
|
|
||||||
settings."*".IdentityFile = config.sops.secrets.${userKeySecret}.path;
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
27
secrets/pikachu.yaml
Normal file
27
secrets/pikachu.yaml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
ssh-host-ed25519-key: ENC[AES256_GCM,data:nxj7nMDzeqr0CitIjns9ugFh7Sur0pVpBr8SgWbxQMpIeNvBp+ugmnAxaUq2HRoYCo68uh/YKJZgMCBPSWrEyIhUX0rXmiZFmusrviJSXrYmy+n2XvLPjuv4vPbJEfjudRdXkHPazB3JJbeVRhGgK7y7smhNZlr8lwPGo0Ui0jVqWB4MPQRP6dLKVfV5shIusOqdhg7y2JSkZLYIjOBOJjknCxGmr5+vF2qwpDJQWEyzuJv2QJ48IlU7QyHF76//6xOchteL70/lyXI4gLiGl2fEczx3KKIXE+DNetKVAQxh4QHwzz9JK8qIJMYVry2qFdGGgF8eN7YEJYA60r1pL++1l8Uq++C1Bsc9jCO91ayQb+spXK0uM3G/ZJka7brRQbvcr54MJEyH6+0uBscXw/wMJ1EBX3Np6dumQhKuALnsAIXE89bKJg1wyPwaCy8uMZMcjQhvNkG0FPSBatHz2bB7bnNuNiXyT+scYyN0huE4Ccq7/H3bHWqbipV51YBH/XZKmjDiCwu3i0t5MHmp,iv:9P6Pa3hBe0/jbacjvzV3VGJJ4+OLRarEJEkLMOftqPw=,tag:zA6ewbj2ovP9+2WcAXa9qA==,type:str]
|
||||||
|
ssh-host-rsa-key: ENC[AES256_GCM,data:C/xmdAeQlQou4wbP1EBqZ8G5/dRpmGYprj/z1g68WfCO7L+gzIgOryaEhBHugv3USAN8E3Ak4Ry+aUZCzDuIM2OeWdUm2AVT6r5o1RPYFtHSdsJUbKKB+9T3bR9GgFBRyRInSOjQhqR3lYuFLeA1xWhV8UG3Vv1tJwjLV1/JTUQf9j/NcYUr8tqjqD6hB8Fa5IEHPAUviX40R50o+EoiPIkB+qKbGru0Eg7p5K+/6d858nZc2lNgXIFnh72D0hXdWFs1BvcwGaY2WL3i7nJmOBUc3zM3/bYlU9EIscl04V6aUSXmF2sqI13Psa8lApV08MdAyUoFSHlYxlFxqe0IJ3nm0ofbJdwM6oDR6E8yQeU9vpirnYOPI6CWx6Y83LFWAVlhOZQKoLPBbiKImjM8oyoBGiIdqUrIXggoZ1u4F2/SwJtd9MwNr0+UDQWAQsYoAoBfI9LSUvEj52C2RiF28MDw2mEE8zmuHWM96KRUB7E8sGXHJrfBzDC5JuVdwmNCAOgck7ACJnHh8ZbKQYv8iGjbwVU/9z52BIUTLjJrdllSmIGWjVqOSVAUZVl7R3AK/xAaUuNNLaNzR4kk/+lQhPTuOLeFBfWbu3kryfeyJNyuHHtS21tgTcwc/bMYVq8LM8ttPn3b/BLQMl49qlBNmMk4TZ4hzBC5BMW6TgSHJRXZ7eajV8x+TM+aGu34xU0TiIy95XgTdi1ktQ0kAzB94iLTsMVhWpwoOguOlGLPGDKuDlym44T4cFRHySSHy0xlroPD1FPSvlSP7uY++55HsxX/88M8HNtCQ9h2P7jFRBYL+CpYbwgWNqtIWq8/daxCxScKZZ2/sAvFL5Ttqe//wYwNp6F3WbIxOk7yS/Ov+ADOt+RnxB2P11dkWNN7kHscGO7/sBNhDbzyEB8kHdfbEYKAl+OwUUsYqiGzdKF8E8aNa/0AAld3whrVgsUmIbFeoAkvJO6TkiZ3uuJAHdF9L8XfpWLEbu+geb6HG4+Bf5QOxxKVkSihMCimTI5vPGHuXj0/vwMihsMvhcaTmfSh2cZRAQhwIPQamEEeC+vqU2Igwcm57Cg81gMJYORLj+SaZSbCbrUxqElE/1OxUtF1KEh9njXjNK/grKnZbDDfRrhZwm3Nri1yxQxk/haHVoZttwXKo2vsWsYvpaHPJuUoQ0r/Wo8RffH2s04qauphLgWi2BUpfs+nNFBGRu6S92o/dktNQYgVeAGo7fYrM4xQX26fjQcOR7TD5bQIR2HcfHzyfH6A4c8MuInUeuf7kZFOoR1DdUFXYbvNcIgbmB/ScImkP3q18KaH+JRsVJbEEB7X4YOQ6lvHTtk0QVLv50V22BA9naFlpKGMWjq0oT5Gf40c6+opDlQWKrgwISwDvm9QJznvquj1EUlFJwVf0SJBHfeomFITZZMma2no2EIV0s/n9LvaU0b1Aim9EAqxUTgMrryLzE16dqSQKugbsn2jWMy7By3Xf8g0kstbyht5EOUZpThrwthEcGRN3xRHHke6ILckAVqEvl2wMuNMV8sd904pdYUL1wQAHKtg+fRbzu5cMY8itEs6YPsWob+HaD+n2tfHf4ByR1KBZ92nCyAWWjW6jrSDCzYCRsxvMSdsBJz9U4szSExC2qm9kQxGeedOl8/qECRZQQArBQZdavbwECGVUT3LRWQOu6bDyFgzoTzUlRxK05wr+Rpir/NmiGURGCZhmy+J2QnejlK0zOidORZAkzFcuGUPiUDGvJ99y43oSYAEYAI4UMKu42zimdVz50PbD2jpSJS6OmxUKfPfyy6CKC1lmYSB7PVGHzQ2aIw0RVMND9JNcntD5qgS2/g1CFhh9z9QyVtkOqiPICXTRXmVEQ5PW04tyzKBRkiSPoXxpC1zq77QPUAz0ZfeElqppdGyfejRiRl6m5JXJ9Pwl3/pgMEqGs0gpM/+TuT3rrYr6v602wPNq6/1XWpizReVPJZNzMrmxQwiIO285w1kvBlZ1VQnbAUiJR+pZuc9PiegSqEbfdkC8SQaGj+2M9tSDRzDGDE1y8pOLDi8Wczbn313JM+QwYDAXGccXxCEq0d3fVjb26u0ZNq2nn5zDy002aMWro+mrsLlWOf4MoAwvnyxgwEewrBEFIk1n14huFd1YpVomZuY3Y9XX1b1XoiTlhbmlP2HopobgQCD4Gt4zFb9RovNxBib+g4j2N94uoiuXOyBTT1B/HWHr6DnMxNvtHIyMOUPzR+6hwO9ntmdhPh+UZIB2b6FWZWLec55yBewzmvWn44dOdo7Z8Tp4d7vfSamyQDO49LA7Bd0CpDan7lbpw5rD4iejRCqZ/yJHTRyExSrZtcLQsScaorGCPlxHL4MUlRMynkyjPSUDRzqdJs+OnJDfUxJ566z8978rCppuYGRAGCqnoEHN9vcGsncMt9LuyUFlDaNKoOFZlMthvfQnUy47JnrP5u18Ph8qjn6A2Wh6MowsMvM67EBSlx8ZdHJC1iWqlHULW7FKPtcvKnDB/GToOqi2y9+EuMVWpoDJmTTyZFr9fmMlA0mL9tXWWG5h3G4LeLrT+LmRmk5tgL1TUJz5XDRQIQZRXxP+X7yt1bDh5PQK0vyupnWbs8KGDM+/pO8GwkwPnehBmJkkfkA6W2DIWzqRZHstdGmk1vtBqE6yAmwBOuXL+vm0vN86ulN+87LNR3OpCBRyorqundmw82vg/RdPNuA+Zxqg+VErsuTTdJmgZxN1rDU+8AiJPC8ZK7mH74lLYX2Ad86ZDriPcAi7wyYLc1qrSSHFCofb2FuM3/cJXcadSjZ//HxZh6qVOC5FW3b/YnFLZeqUgai6PO5rvzgyXusb2qHK60sEyS6pfw+ybOyZxWAc1TYqFXtOhUje8SW3R4cCG4zyCh5kUjZLiCNoNU2MIvVHjSfe9KAvTO2pcmuDIuVv9CKoko8tgoFqS80UDeP0pn99AmnTMXbhUR7eYjR9qAABF0IJJGUUIR1SbwiJrdEXil1qNcgciBI1epIXc1uBRXhcEnHI8VxVwYxF5wISjfuvp4cqJk03AkZVa4E2yydUATOCtk6fkaxVJIkitQaVgc0tqS4pX+9d35NF/njFHXksT/wMBfVJYmcgfIHJFK46igB/rwwC3PpOM+YNM8aJpLyZbbUB9CEWMtT9P5LqLWFoUaC2brIKG7vrfy2OSNOEWxiEDAzn7kY1+zpOb93J8FURpDNV+K4YUznPr1z2BxEGAKQXXx2PHQpAYYvdane5uWsZWMTsK5qQunX2UHPscr3T0A384pzXNdgnR6OrZfbcIpmvTAjxH9qzQLj49kJQ98czuutJWxgrp9J7abKeIdWGObTra5gA6bJ5kw9FZjW0xiyrOLAok/Vbmen8WCIVxToMJnXR91uuq58GxrcuZFke0NpCfEu3bpdPeKlb+sy0FREiFCpaDUNFFn5DqTNwOiW5KjGw/fG6dqC/NUhH5xdeod4dR1C4NEpYKzbWolUFf+rvFtGrRnQ0VQMXav+fpnBFiRA/ez/7oAzzwNv8Y7s3QpCUSUjsPRGWG/4pDlRSaz7rwkCfRxpkbOmPruYx3fcHigBnvIMLtKAPCx4jE7EsS6BoHNhgCMRyzkdl+MZFLKZ7JgFvgPdNcieFlptax2KaEwBIvhgQ4rimLRCv9cCM1qmmNIGB0UovqZ0Ye8tRG6LteFTgsfb3bNQ5sShaHQiuh2m9JGnI41q8CH2Gn6Mz/ftxn4yDWL/SlkjhtnRLoYvD5ro/Tf4q+exobXC0fbA1sLOnaX3atqHeQWK4Sk9L7zrDbwrJHzgmFD2PUPUhpfg9r1QMtJVkUckhQgyjrrIBfyZ4qqPmpYbH1w7cfE3C/rdprNZFKujYlA0ol8Ok1n0gyRFTeNZvd5EdrQ3D+hIKc0ID7/FmfzIccrI6x+wOE1ePQPzHay2mM8qRcjgTiQOTMaFRYjVy28ovqgixs/bylGvw5VNMGJTIqcu6fC54i3HOBhmnH1Ab/5a7PmwXUbAajw+mKddtrr0wQ7cXRncFnoJtld2AIOs7aD51C1Zkr1UqxliWObCsXJmaQoOpBScHdiKNp1mKQ1Oddu3mj0Su1e4gQR+YAwOyQ1zBU5WvNBbudUmRIsjXDS2Y6XaVN50neQ0DZ5V71259xPyawyVvrdiiUg7HOXgGFGcWbFU+p7MZaq0KE3aXgwCC+/mhFgaKgIVWir5pElNaXLUMDF+1jnblaKyHyAk0HPBgegAphVqWF3Hqp3b15jTSdWsmOugGcCO9hLk2OD99nZzuEVBLbDg0yGISPgzVNFg3+Pqcu8FqmGxLwAWNTRc0+ov7VL5Y4fJoD4QOPj19+nZyvXze/FOYo1Hp+U7PBZDIKVswcdauZG+Z2t2bEd0OxXj1NoazpycIVI/TmiTVyDsK+ijXztNnWXGG24WW1KZ3O5+MmDm7F7RDi1vCOfOOg3Y1yPVprOvltyn1uPo5Q/pPTD1FmAGpJkmdT3+dKkb9N5u6RNvRBEzFY8HCqcvKX1HulpJpz72,iv:vfjzz6U6ey9wGs6Ia3Vd5HaGBvpSq4akp4e3eLC/A3M=,tag:ar1+i09oOEusEuQnr44ASw==,type:str]
|
||||||
|
ssh-user-ed25519-key: ENC[AES256_GCM,data:uHZADD2tq8vqlOAOWYniEnj1sl2IUqepf4YvrmRX/0nTksVnoethW6Yb2VNTgu5FsKCxvO65qMZ9KYupDkw/+SCZP2VH4TyhByQSr4SYxrEiNOJLdvmL2VnOWlNNZKaswgBePQR7TBG4mSIiTXCjEAcxeHAhBxLPnwiFxK/Zd6yQhq61x1N+ZtOj0q0QpADP3vioUohdkE5ODoEOUUoQJkYpjdJl2j4PhjCq7yHMEZpeMkqjZhVX5+i8blBu43BM4bKS2tBIJW2IvPV9sHOH8/tsR6p5q0pnpcJAOyOquBSo+eR0egY+liXjSv+Vo/ZDPapl7rKs2SoJ5huUGFbWjxbXY2+t2SYyiVA8lufrVUu94RCwOOJ8UyGYzAGGrChUImVDBsgk/GWwpOvWxcqydF8d6mDdxK9Iek9HtYsS+IKXLrF8GAI9OLB9jKgHVZgyQYSnC146cwCLt99TOFVocE1lfzwpFQTwKvxkep5jBEWiyqRaRUngti0JuF0ih1BWo+LKqxIqq9+V3I39xM8PfTjgHiifnxj4wuhS,iv:32zDKpNPYMiP6Rx7L9TAhxrMwcmphIGni+0jcESoLqg=,tag:YWGE62WQNm2Cer+eG+S5Xg==,type:str]
|
||||||
|
sops:
|
||||||
|
age:
|
||||||
|
- enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyTTVLWHVaZ1NxbGEzcVBq
|
||||||
|
MmorSkhzQ2hSV01idWE4VXNyNkNvMmpkTHpzCnFDQ0l3a1o1OWhmdE9WTjlob3p4
|
||||||
|
Y3BzMlJmeks4ZllzbEY1MkVqRCtrNjgKLS0tIFVhQXpDRjRUSEViMUhPM0V2RGRt
|
||||||
|
NkRpV05YV2RaTGozTUNNM3U5dmJ3dWsKSzkXmVyPhwN58SjMYYL/YbPoHYtZ9goF
|
||||||
|
VYRLRnfU298/3R/1nLNqTlMk4MYQ6NaGf2jOYTyObGgJVccOHF6wig==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
recipient: age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
|
||||||
|
- enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGMDBPcHZ3YithYm5sM2Vh
|
||||||
|
NzJUVmpuT1FRdmw1bzI0a2p5NXFlZlRQN25NCmduQ1NKREVZT3Q5d0l4ZC9ZVUFU
|
||||||
|
all2UUZUMjZyaGxGMm5UOEQwcnNKS2sKLS0tIExPZmR5K05WZHRYRHZEeW1PRWhV
|
||||||
|
ZTZzTlhnYURJTDROTFpUTmFKc3ZkeXMKEWUCkVeHEt/Ay0lyAVjsqtLbu+pJOTJ5
|
||||||
|
dyjzy0/9Ui1IIXH36AImO5hiEZcrGePV2qPLLQc7ZLngC1jlJuEKJA==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
recipient: age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
|
||||||
|
lastmodified: "2026-08-03T01:26:32Z"
|
||||||
|
mac: ENC[AES256_GCM,data:w4i6+dwN/t/SvFTrP/YiapxOY6ecT7+cJ0v/5lYMe4m1vL2w4mJw9ZrxWifcCcTEfaa766Wc1NE+GxGrBZtlORExbtQCa4tg2GuIQCRqTEc5WSqhYrgMb4elWuYe+r3/SlAf1ldNxz+cmLVtbDpSs96bvkGsKdw/i+q9n2URJYE=,iv:MFWgMtIdpn1v0T6FPhTgBMxi+6kzf2ZAnOGaisRT0d0=,tag:qBX32/zUHSPagXU+u8G8tA==,type:str]
|
||||||
|
unencrypted_suffix: _unencrypted
|
||||||
|
version: 3.13.3
|
||||||
@@ -3,22 +3,31 @@ sops:
|
|||||||
age:
|
age:
|
||||||
- enc: |
|
- enc: |
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVYzZuaERsRjMyaTAwL3Ri
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvdGQ0ZnZ2UlVaTFVuRmJI
|
||||||
b2RhaGZ1aHNOSzVMamVxWkdKb3VKTk9QMmk4CmV0R3hYN3hkMU1tVDJLNkFlT08y
|
aVZNVjZVNGdiVGVhUTVXN3luU0V1eUZFb0ZRCkVrY3lKRE9GTUEzV0ZjUnk4UHdl
|
||||||
SUdUeUZ4d2JwNmdyOWVJcmZNcEtCb1EKLS0tIHhDV1NZWWdDZUNMYjVqYUVlc0ty
|
QnFMVDhMcGVUTWFQemxrSnNEMStpSEEKLS0tIFFIQndkZHp1NkRmRlB4RXlwdk5C
|
||||||
Y1owUFZPMXBHbDhjVWxTUjZGRk1IUzQK7VENq6TjuOFlon+CJqUxbIJZ9qka78C/
|
cTJVQW9iMWhaMlk1dUxBK1ZvQTRuV0kKm7/z24q4NcDFlVuxZViDFlJodjRzRqhY
|
||||||
LDsgaTD+7zCBPgASwPbF88pH6tdK7bvNLJnznlZdZBL12eOy25BmOQ==
|
7X9LqouIXcGhDgwq0hh+JXRfYCz9LDiUJtOLHR7Lu/oscCBCnk7N6Q==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
recipient: age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
|
recipient: age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
|
||||||
- enc: |
|
- enc: |
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHY0ZXT3lRWS9DMFA1MHhl
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTOENXYmRRNmVQeUtvOVFy
|
||||||
MDZiWHhEMy9INGtpd1ZOdzh0OFRoUlZDa0hZCkJwTUV4c01YWlE1QjNDd3pRN3F0
|
eElWVUhnUnJDTk93aVJCVmpTcHd2aVpKSFhJCk9oUk5ObHNSZHNwSisrSE9JbjRI
|
||||||
SGJWWmFTT1NMQktNejVHY1RrRlZJNFEKLS0tIHlRZG9ZV3FrQktSN2tURVV1NmlW
|
WVllODlYek1VMDZpMmh3M2JRTU92ZEkKLS0tIFVVNG9RNnlzS0ZvMUM3bVN2Mldo
|
||||||
UTBZbFlqMmFGZ0VPSlA1dmNMU2Q3TFUKtL2V8t9+Qw5vjXursvCVRatflX8JKXJr
|
N0MvcXEraDcwUHVxbTJqWGdxTGhOVG8KIhIY9QGbt/eWy9bfST4tEkjLQLylaHRm
|
||||||
VuA8oe0nKpk7wh4fCzcT7RoRKpJY0gPFjIzeTZGVfoAmZIUWMhzRuw==
|
AwYIwU1Hw6HXR1TX3t0YciI3c8HcWISruY/tBR3xIHIdBlQR5OTaeA==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
recipient: age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
|
recipient: age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
|
||||||
|
- enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4ODgyNjQ1RFMwOGpUaG5U
|
||||||
|
UDNjTENrYzY5UFVTNlRONlBhNmVUUXk2OVd3ClFQUUhFaUM4YmR6dVNlMXRwWUFY
|
||||||
|
ais3L0FyQ1ozWjFMMHhjWmU1NU5JdVUKLS0tIG1tS1NoZjhBb3JRak9XdEpOT3Vy
|
||||||
|
Uk5HdUd6NUsrZWx2Y1lrWGdHRXcxZEUK470gSumRCpgYvIWJcmylw0VTgyV3et/B
|
||||||
|
QkVLBz5x+ShVun27nN3oz8And0qXfwgXojhM3yWnBSUa9CFBsbTOJQ==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
recipient: age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
|
||||||
lastmodified: "2026-07-20T03:22:00Z"
|
lastmodified: "2026-07-20T03:22:00Z"
|
||||||
mac: ENC[AES256_GCM,data:ei7PKVAIjJ6fGkxqJFc5wdYapq1gElel3fTJ+yKhvWHU+39aKcllG66T3d9FitRztgyt69phykHdKvxDHRUwYeyl1YBzyf1ZpPU5mXJb+hkLtVB1Am7StcP+m7jFqKSmqtYhIT9OxUrH0MJ8qeoU9216otwkhhpPz2hr1s7KYFk=,iv:Pp03KmlinjJiiTZezr0LzzkcHb1a5XWgDpu38jhl9Rk=,tag:HAqFitge+KTCtDE24t8/ig==,type:str]
|
mac: ENC[AES256_GCM,data:ei7PKVAIjJ6fGkxqJFc5wdYapq1gElel3fTJ+yKhvWHU+39aKcllG66T3d9FitRztgyt69phykHdKvxDHRUwYeyl1YBzyf1ZpPU5mXJb+hkLtVB1Am7StcP+m7jFqKSmqtYhIT9OxUrH0MJ8qeoU9216otwkhhpPz2hr1s7KYFk=,iv:Pp03KmlinjJiiTZezr0LzzkcHb1a5XWgDpu38jhl9Rk=,tag:HAqFitge+KTCtDE24t8/ig==,type:str]
|
||||||
unencrypted_suffix: _unencrypted
|
unencrypted_suffix: _unencrypted
|
||||||
|
|||||||
Reference in New Issue
Block a user