@staix/agent-hub 0.12.33 → 0.12.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +5 -3
- package/docs/agent-notes/daemon.md +3 -3
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +2 -1
- package/docs/operations.md +20 -10
- package/docs/quickstart.md +4 -2
- package/docs/security.md +4 -1
- package/docs/smoke.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +246 -4
- package/docs/verification/2026-10-18-0.12.34.md +48 -0
- package/docs/verified.json +29 -18
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +22882 -16496
- package/src/adapters/codex-appserver.ts +81 -11
- package/src/adapters/definitions/builtin-metadata.ts +101 -7
- package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
- package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
- package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
- package/src/adapters/definitions/native-cli-doctor.ts +46 -0
- package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +26 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +24 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-local.ts +14 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +51 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +20 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +40 -0
- package/src/adapters/definitions/native-cli-entry.ts +19 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
- package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
- package/src/adapters/definitions/native-cli-pi.ts +33 -0
- package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
- package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
- package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
- package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
- package/src/adapters/definitions/native-cli-preview.ts +16 -0
- package/src/adapters/definitions/native-config-defaults.ts +19 -0
- package/src/adapters/definitions/native-config-policy.ts +35 -0
- package/src/adapters/definitions/native-context.ts +35 -0
- package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
- package/src/adapters/definitions/native-daemon-policy.ts +18 -0
- package/src/adapters/definitions/native-display.ts +18 -0
- package/src/adapters/definitions/native-factories.ts +48 -0
- package/src/adapters/definitions/native-policy-aux.ts +130 -0
- package/src/adapters/definitions/native-quota.ts +29 -0
- package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
- package/src/adapters/definitions/native-recovery.ts +88 -0
- package/src/adapters/definitions/qwen.ts +8 -4
- package/src/adapters/registry-types.ts +97 -10
- package/src/adapters/registry.ts +103 -3
- package/src/cli/console-state.ts +5 -5
- package/src/cli/identity.ts +6 -5
- package/src/cli/jcode-mcp.ts +2 -58
- package/src/cli/launch.ts +14 -100
- package/src/cli/main.ts +35 -272
- package/src/cli/output.ts +3 -2
- package/src/cli/preview.ts +14 -125
- package/src/cli/recovery-guidance.ts +17 -13
- package/src/cli/setup.ts +2 -80
- package/src/cli/shell-quote.ts +3 -0
- package/src/cli/terminal-recovery.ts +17 -10
- package/src/cli/upgrade-interactive.ts +4 -2
- package/src/cli/upgrade-restore-state.ts +2 -1
- package/src/cli/upgrade-runtime.ts +92 -141
- package/src/cli/upgrade.ts +8 -7
- package/src/hub/budget.ts +6 -30
- package/src/hub/conductor.ts +8 -4
- package/src/hub/config-apply.ts +9 -13
- package/src/hub/config-reload.ts +7 -2
- package/src/hub/context-window.ts +3 -27
- package/src/hub/crash.ts +2 -35
- package/src/hub/daemon.ts +226 -232
- package/src/hub/execution-budget.ts +2 -1
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/peer-catalog.ts +4 -13
- package/src/hub/permission-mode.ts +9 -12
- package/src/hub/recovery-continuity.ts +6 -5
- package/src/hub/report.ts +9 -8
- package/src/hub/roster.ts +6 -7
- package/src/hub/routing.ts +21 -10
- package/src/hub/settings.ts +6 -5
- package/src/hub/settled-roles.ts +1 -1
- package/src/hub/start-mode.ts +8 -6
- package/src/hub/tasks.ts +13 -10
- package/src/hub/tooling.ts +4 -60
- package/src/hub/ui.ts +3 -1
- package/src/memory/recall.ts +4 -5
- package/src/models/route/signals.ts +3 -1
- package/src/pi/launch.ts +2 -1
- package/src/ui/index.html +3 -2
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +16 -6
- package/templates/routing.toml +2 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
3
4
|
|
|
@@ -56,7 +57,7 @@ export class ExecutionBudget {
|
|
|
56
57
|
if (input.kind === "task" && (!Number.isSafeInteger(input.taskId) || input.taskId! < 1)) throw new Error("task budget requires a positive task id");
|
|
57
58
|
const peers = [...new Set(input.peers.filter((p) => typeof p === "string" && p.length > 0))].sort();
|
|
58
59
|
if (!peers.length) throw new Error("execution budget requires at least one peer");
|
|
59
|
-
const unsupported = peers.filter((peer) => peer
|
|
60
|
+
const unsupported = peers.filter((peer) => !definitionOf(peer)?.budget?.instrumented);
|
|
60
61
|
if (unsupported.length) throw new Error(`execution budgets require instrumented peers (pi, local); unsupported: ${unsupported.join(", ")}`);
|
|
61
62
|
const limits = validLimits(input.limits ?? {});
|
|
62
63
|
if (!Object.keys(limits).length) throw new Error("execution budget requires at least one supported limit");
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SDLC_STAGES } from "./sdlc.ts";
|
|
2
2
|
import { projectProfile, profileData, PROFILE_CLASS_NAMES, type ProfileName, type ProfileSelection } from "./profiles.ts";
|
|
3
3
|
import { catalogSection, type CatalogRole } from "./roles.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { CONDUCTOR_START_PEERS, defaultPeerRoles } from "../adapters/registry.ts";
|
|
5
5
|
// The hub's task tools, defined once: the MCP server (Claude plugin, Kimi, Codex) and the local worker both read this.
|
|
6
6
|
import { NOTE_KINDS } from "./envelope.ts";
|
|
7
7
|
|
|
@@ -63,7 +63,7 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
63
63
|
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
|
|
64
64
|
tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
|
|
65
65
|
tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
|
|
66
|
-
tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet.
|
|
66
|
+
tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. Consecutive changes_requested verdicts (three by default, `tasks.escalate_after`) move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
|
|
67
67
|
tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
|
|
68
68
|
tool("hub_memory_search", "Search daemon-scoped visible memory, fails first. Query <=200 UTF-8 bytes; default 8, max 20 lines; whole output <=4 KB. Incomplete excerpts are marked. No project argument.", { query: { type: "string", maxLength: 200, description: "At most 200 UTF-8 bytes, enforced by the daemon" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["query"]),
|
|
69
69
|
tool("hub_memory_show", "Read up to 8 visible memory ids or unique prefixes (6..64 UTF-8 bytes each). Original ids redirect; unavailable ids share one refusal. Whole output <=8 KB.", { ids: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 6, maxLength: 64, description: "6 to 64 UTF-8 bytes" } } }, ["ids"]),
|
|
@@ -80,7 +80,7 @@ export const CONDUCTOR_TOOLS: HubTool[] = [
|
|
|
80
80
|
tool("hub_task_show", "Read a task's public view and history, its done summary and check result included. PII tasks remain stubs. Only the conductor and the task's current owner and reviewer may use this tool.", { id }, ["id"]),
|
|
81
81
|
tool("hub_task_assign", "Name an owner (default role) or reviewer. Only the conductor may name a reviewer through this tool. A proposer may redirect an unaccepted proposed task's owner; a task that still waits reserves that owner. Pull-only peers receive work when named or when they claim it. With an explicit capabilities list, the conductor needs assign capability for either role, the proposer only to give ownership to another peer.", { id, peer: str, role: { type: "string", enum: ["owner", "reviewer"] } }, ["id", "peer"]),
|
|
82
82
|
tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
|
|
83
|
-
tool("hub_peer_start", "Start local, kimi, qwen, jcode, pi, claude or codex in the start mode a person set for it; you cannot choose the mode. A headless peer starts at once. A TUI peer (claude, and pi and codex by default) opens in a terminal with a fixed command; when the hub cannot open a terminal the call fails with the command for the person to run.", { peer: { type: "string", enum:
|
|
83
|
+
tool("hub_peer_start", "Start local, kimi, qwen, jcode, pi, claude or codex in the start mode a person set for it; you cannot choose the mode. A headless peer starts at once. A TUI peer (claude, and pi and codex by default) opens in a terminal with a fixed command; when the hub cannot open a terminal the call fails with the command for the person to run.", { peer: { type: "string", enum: CONDUCTOR_START_PEERS } }, ["peer"]),
|
|
84
84
|
tool("hub_peer_hold", "Hold a peer's deliveries as the conductor. This hold is separate from the person's hold and budget pauses.", { peer: str }, ["peer"]),
|
|
85
85
|
tool("hub_peer_release", "Release only the conductor hold you placed. Never lifts a person's hold or a budget pause.", { peer: str }, ["peer"]),
|
|
86
86
|
];
|
package/src/hub/peer-catalog.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { definitionOf } from "../adapters/registry.ts";
|
|
1
|
+
import { definitionOf, SUPPORTED_PEERS } from "../adapters/registry.ts";
|
|
2
|
+
export { SUPPORTED_PEERS } from "../adapters/registry.ts";
|
|
2
3
|
|
|
3
4
|
// ponytail: older staged coordinators read these literal declarations to probe target support.
|
|
4
5
|
// A2 replaces that source probe with a published capability before deleting the compatibility exports.
|
|
@@ -6,7 +7,6 @@ import { definitionOf } from "../adapters/registry.ts";
|
|
|
6
7
|
export const KNOWN_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
|
|
7
8
|
/** Optional production peers are added explicitly; legacy defaults remain stable. */
|
|
8
9
|
export const OPTIONAL_PEERS = ["qwen", "jcode"] as const;
|
|
9
|
-
export const SUPPORTED_PEERS = [...KNOWN_PEERS, ...OPTIONAL_PEERS] as const;
|
|
10
10
|
export type KnownPeer = typeof SUPPORTED_PEERS[number];
|
|
11
11
|
|
|
12
12
|
export const isKnownPeer = (value: unknown): value is KnownPeer =>
|
|
@@ -15,14 +15,5 @@ export const isKnownPeer = (value: unknown): value is KnownPeer =>
|
|
|
15
15
|
export const isSupportedPeer = (value: unknown): value is KnownPeer =>
|
|
16
16
|
typeof value === "string" && !!definitionOf(value);
|
|
17
17
|
|
|
18
|
-
// Policy
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
/** Each caller owns its role arrays; changing a project's defaults cannot change another's. */
|
|
22
|
-
export function defaultPeerRoles(includeOptional = false): Record<string, string[]> {
|
|
23
|
-
return Object.fromEntries(ROLE_ORDER.filter(peer => includeOptional || !definitionOf(peer)!.optional)
|
|
24
|
-
.map(peer => [peer, [...definitionOf(peer)!.roles]]));
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** #455: who may hold the planner role, in the order automatic assignment tries them; the hub-sandboxed pi and local never plan. */
|
|
28
|
-
export const PLANNER_PEERS = ["claude", "codex", "kimi", "qwen", "jcode"] as const;
|
|
18
|
+
// Policy and historical projection order belong to the registry; public compatibility exports remain intact.
|
|
19
|
+
export { defaultPeerRoles, PLANNER_PEERS } from "../adapters/registry.ts";
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { isSupportedPeer } from "./peer-catalog.ts";
|
|
2
1
|
import { definitionOf } from "../adapters/registry.ts";
|
|
3
2
|
import { lstatSync } from "node:fs";
|
|
4
3
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
@@ -14,8 +13,9 @@ export function permissionDefaults(value: unknown): Record<string, PermissionMod
|
|
|
14
13
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("permission_modes must be an object with ask, ask-when-needed or never-ask values");
|
|
15
14
|
const result: Record<string, PermissionMode> = {};
|
|
16
15
|
for (const [peer, mode] of Object.entries(value)) {
|
|
17
|
-
|
|
18
|
-
if (
|
|
16
|
+
const definition = typeof peer === "string" ? definitionOf(peer) : undefined;
|
|
17
|
+
if (!definition) throw new Error(`permission_modes.${peer}: unsupported peer`);
|
|
18
|
+
if (definition.permission.unmanaged) throw new Error(`${definition.displayName} native policy is unmanaged; no hub permission_modes.${peer} mapping exists`);
|
|
19
19
|
if (!isPermissionMode(mode)) throw new Error(`permission_modes.${peer} must be ask, ask-when-needed or never-ask`);
|
|
20
20
|
result[peer] = mode;
|
|
21
21
|
}
|
|
@@ -23,25 +23,22 @@ export function permissionDefaults(value: unknown): Record<string, PermissionMod
|
|
|
23
23
|
}
|
|
24
24
|
/** Approval frequency does not create a common sandbox across native peers. */
|
|
25
25
|
export function permissionBoundary(peer: string): string {
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
if (
|
|
26
|
+
const definition = definitionOf(peer);
|
|
27
|
+
if (definition?.permission.unmanaged) return `${peer}: native policy unmanaged; NO hub sandbox; approvals belong to its native tools`;
|
|
28
|
+
if (definition?.trust.hubSandboxed) return `${peer}: inside hub sandbox, path guard and denylist`;
|
|
29
|
+
if (definition?.transport === "claude-channel" || definition?.transport === "codex-appserver") return `${peer}: native vendor bounds; no hub sandbox`;
|
|
29
30
|
return `${peer}: runs its own tools; NO hub sandbox`;
|
|
30
31
|
}
|
|
31
32
|
/** What a mode lets a peer do without asking, in one line a person reads before choosing it (#269). */
|
|
32
33
|
export function permissionGrant(peer: string, mode: PermissionMode): string {
|
|
33
34
|
const grant = definitionOf(peer)?.permission.grants?.[mode];
|
|
34
35
|
if (grant !== undefined) return grant;
|
|
35
|
-
if (
|
|
36
|
-
if (mode === "ask") return peer === "codex" ? "the session's own approval policy applies again" : peer === "kimi" ? "Kimi asks as it does on its own" : peer === "claude" ? "Claude's own permission rules decide" : "every write, edit and shell command asks";
|
|
37
|
-
if (peer === "kimi") return mode === "never-ask" ? "Kimi's auto mode: nothing asks" : "Kimi's yolo mode: it asks only where it judges it must";
|
|
38
|
-
if (peer === "codex") return mode === "never-ask" ? "approval policy never on every turn: nothing asks" : "approval policy on-request on every turn";
|
|
39
|
-
if (peer === "claude") return mode === "never-ask" ? "every tool runs without asking" : "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules";
|
|
36
|
+
if (mode === "ask") return "every write, edit and shell command asks";
|
|
40
37
|
return mode === "never-ask" ? "every tool runs without asking, inside the hub sandbox and path guard" : "write and edit inside the project run without asking; shell and git writes still ask";
|
|
41
38
|
}
|
|
42
39
|
export const PI_EDIT_TOOLS = new Set(["edit", "write"]);
|
|
43
40
|
/** Native agent policy files are never included in scoped automatic file grants. */
|
|
44
|
-
export const AGENT_CONFIG_SEGMENTS: ReadonlySet<string> = new Set([".claude", ".codex", ".qwen", ".kimi", ".kimi-code", ".jcode", ".pi", ".mcp.json", ".agents", ".claude.json"]);
|
|
41
|
+
export const AGENT_CONFIG_SEGMENTS: ReadonlySet<string> = new Set([".claude", ".codex", ".qwen", ".kimi", ".kimi-code", ".jcode", ".pi", ".mcp.json", ".agents", ".agent", "_agents", "_agent", ".gemini", ".grok", ".claude.json"]);
|
|
45
42
|
export const INSTRUCTION_FILES: ReadonlySet<string> = new Set(["agents.md", "claude.md", "qwen.md"]);
|
|
46
43
|
export const isInstructionPath = (path: string): boolean => path.split(/[\\/]/).some(segment => INSTRUCTION_FILES.has(foldSegment(segment)));
|
|
47
44
|
export function instructionProtection(value: unknown): boolean {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ADAPTERS } from "../adapters/registry.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
4
|
import { chmodSync, closeSync, constants, fstatSync, lstatSync, openSync, readSync, renameSync, unlinkSync } from "node:fs";
|
|
@@ -37,12 +38,12 @@ function canonicalRoot(value: unknown): string {
|
|
|
37
38
|
return root;
|
|
38
39
|
}
|
|
39
40
|
function checked(value: unknown): RecoveryContinuityBookmark {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
const codecs = ADAPTERS.flatMap(adapter => adapter.session.continuity ? [adapter.session.continuity] : []);
|
|
42
|
+
if (!record(value) || !keys(value, ["schema", "operationId", "projectId", "projectRoot", "at", ...codecs.map(codec => codec.key)]) || value.schema !== 1 || typeof value.operationId !== "string" || !operationIdPattern.test(value.operationId) || !id(value.projectId) || !absolute(value.projectRoot) || !Number.isSafeInteger(value.at) || (value.at as number) < 0) return unavailable();
|
|
43
|
+
const native: Record<string, unknown> = {};
|
|
44
|
+
for (const codec of codecs) { const parsed = codec.parse(value[codec.key]); if (parsed === undefined) return unavailable(); native[codec.key] = parsed; }
|
|
43
45
|
const projectRoot = canonicalRoot(value.projectRoot);
|
|
44
|
-
return { schema: 1, operationId: value.operationId, projectId: value.projectId, projectRoot, at: value.at as number,
|
|
45
|
-
pi: { sessionId: pi.sessionId, ...(pi.sessionFile === undefined ? {} : { sessionFile: pi.sessionFile as string }), ...(pi.backend === undefined ? {} : { backend: pi.backend as "auto" | "dgx" | "mlx" }), ...(pi.model === undefined ? {} : { model: pi.model as string }) } };
|
|
46
|
+
return { schema: 1, operationId: value.operationId, projectId: value.projectId, projectRoot, at: value.at as number, ...native } as RecoveryContinuityBookmark;
|
|
46
47
|
}
|
|
47
48
|
function stateDirectory(stateDir: string): void {
|
|
48
49
|
if (!absolute(stateDir)) return unavailable();
|
package/src/hub/report.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
1
2
|
import type { StampedEvent } from "./events.ts";
|
|
2
3
|
|
|
3
4
|
export interface Report {
|
|
@@ -103,7 +104,7 @@ export function summarize(events: StampedEvent[]): Report {
|
|
|
103
104
|
const r: Report = { peers: {}, conduct: {}, supervision: {}, routes: {}, usage: { peers: {}, totals: { inputTokens: 0, inputRecords: 0, outputTokens: 0, outputRecords: 0, cacheReadTokens: 0, cacheReadRecords: 0, cacheWriteTokens: 0, cacheWriteRecords: 0, totalTokens: 0, totalRecords: 0 }, unknownPeers: [], completeCoverage: true }, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
|
|
104
105
|
const supervisor = (id: string) => (r.supervision[id] ??= { turns: null, tokens: null, measuredTokens: null, knownTokenTurns: 0, unknownTokenTurns: 0 });
|
|
105
106
|
const peer = (id: string) => (r.peers[id] ??= { turns: 0, busyMinutes: 0, tokens: 0 });
|
|
106
|
-
const
|
|
107
|
+
const nativeStops = events.some(event => event.type === "native_turn_end" && definitionOf(event.peer)?.turnEnd === "native-hook" && !!event.id);
|
|
107
108
|
const seenNativeStops = new Set<string>();
|
|
108
109
|
const usagePeer = (id: string) => (r.usage.peers[id] ??= emptyUsage());
|
|
109
110
|
const pairs = new Set<string>();
|
|
@@ -147,11 +148,11 @@ export function summarize(events: StampedEvent[]): Report {
|
|
|
147
148
|
r.messages.dropped.stale = (r.messages.dropped.stale ?? 0) + 1;
|
|
148
149
|
break;
|
|
149
150
|
case "turn_end":
|
|
150
|
-
if (e.peer !== "
|
|
151
|
+
if (definitionOf(e.peer)?.turnEnd !== "native-hook" || !nativeStops) peer(e.peer).turns = (peer(e.peer).turns ?? 0) + 1;
|
|
151
152
|
peer(e.peer).busyMinutes += e.ms / 60_000;
|
|
152
153
|
break;
|
|
153
154
|
case "native_turn_end":
|
|
154
|
-
if (e.peer === "
|
|
155
|
+
if (definitionOf(e.peer)?.turnEnd === "native-hook" && e.id) {
|
|
155
156
|
if (seenNativeStops.has(e.id)) break;
|
|
156
157
|
seenNativeStops.add(e.id);
|
|
157
158
|
peer(e.peer).turns = (peer(e.peer).turns ?? 0) + 1;
|
|
@@ -211,10 +212,10 @@ export function summarize(events: StampedEvent[]): Report {
|
|
|
211
212
|
// Pricing is intentionally absent: a token count is neither an estimated price nor provider-reported spend.
|
|
212
213
|
r.messages.perTask = taskMessages.size ? Number(([...taskMessages.values()].reduce((a, b) => a + b, 0) / taskMessages.size).toFixed(1)) : 0;
|
|
213
214
|
for (const p of Object.values(r.peers)) p.busyMinutes = Number(p.busyMinutes.toFixed(1));
|
|
214
|
-
|
|
215
|
-
const logical = events.some(event => event.type === "turn_end" && event.peer ===
|
|
216
|
-
|
|
217
|
-
if (!
|
|
215
|
+
for (const [id, summary] of Object.entries(r.peers)) if (definitionOf(id)?.turnEnd === "native-hook") {
|
|
216
|
+
const logical = events.some(event => event.type === "turn_end" && event.peer === id);
|
|
217
|
+
summary.turnSource = nativeStops ? "native-stop" : logical ? "logical-state" : "unknown";
|
|
218
|
+
if (!nativeStops && !logical) summary.turns = null;
|
|
218
219
|
}
|
|
219
220
|
return r;
|
|
220
221
|
}
|
|
@@ -223,7 +224,7 @@ export function formatReport(r: Report): string[] {
|
|
|
223
224
|
const lines = [`period: ${r.from ?? "-"} .. ${r.to ?? "-"}`];
|
|
224
225
|
for (const [id, p] of Object.entries(r.peers).sort(([a], [b]) => a.localeCompare(b))) {
|
|
225
226
|
const turns = p.turns === null ? "turns unknown" : `${p.turns} turn${p.turns === 1 ? "" : "s"}${p.turnSource === "native-stop" ? " (native Stop)" : p.turnSource === "logical-state" ? " (logical state, native completion unobserved)" : ""}`;
|
|
226
|
-
lines.push(`peer ${id}: ${turns}, ${p.busyMinutes} busy minutes, ${p.tokens ? `${p.tokens} tokens` : id
|
|
227
|
+
lines.push(`peer ${id}: ${turns}, ${p.busyMinutes} busy minutes, ${p.tokens ? `${p.tokens} tokens` : definitionOf(id)?.reporting?.tokensNotReported ? "tokens not reported" : "- tokens"}`);
|
|
227
228
|
}
|
|
228
229
|
if (Object.keys(r.usage.peers).length) lines.push(...formatUsage(r.usage));
|
|
229
230
|
for (const [id, s] of Object.entries(r.supervision).sort(([a], [b]) => a.localeCompare(b))) {
|
package/src/hub/roster.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
2
|
+
import { defaultRosterEnabled } from "../adapters/registry.ts";
|
|
2
3
|
|
|
3
|
-
export type PeerRoster = Partial<Record<
|
|
4
|
-
// Default participation policy, not another catalog. New catalog members default off.
|
|
5
|
-
const DEFAULT_ON: Partial<Record<KnownPeer, boolean>> = { claude: true, codex: true, kimi: true, local: true };
|
|
4
|
+
export type PeerRoster = Partial<Record<string, boolean>>;
|
|
6
5
|
|
|
7
6
|
export function peerRosterConfig(value: unknown): PeerRoster {
|
|
8
7
|
if (value === undefined) return {};
|
|
9
8
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("roster must be an object of boolean peer values");
|
|
10
9
|
const roster: PeerRoster = {};
|
|
11
10
|
for (const [peer, on] of Object.entries(value)) {
|
|
12
|
-
if (!
|
|
11
|
+
if (!definitionOf(peer)) throw new Error("roster contains an unsupported peer");
|
|
13
12
|
if (typeof on !== "boolean") throw new Error(`roster.${peer} must be a boolean`);
|
|
14
13
|
roster[peer] = on;
|
|
15
14
|
}
|
|
@@ -18,6 +17,6 @@ export function peerRosterConfig(value: unknown): PeerRoster {
|
|
|
18
17
|
|
|
19
18
|
/** External ids keep their existing admission rules; Pi's legacy switch is only its default. */
|
|
20
19
|
export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean): boolean {
|
|
21
|
-
if (!
|
|
22
|
-
return roster[peer] ?? (peer
|
|
20
|
+
if (!definitionOf(peer)) return true;
|
|
21
|
+
return roster[peer] ?? defaultRosterEnabled(peer, piEnabled);
|
|
23
22
|
}
|
package/src/hub/routing.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
|
|
1
2
|
import { readFileSync, statSync } from "node:fs";
|
|
2
3
|
import { join, resolve } from "node:path";
|
|
3
4
|
import { CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
@@ -17,7 +18,7 @@ export interface ClassPolicy {
|
|
|
17
18
|
/** Switchyard route the local worker asks for on a task of this class */
|
|
18
19
|
route?: string;
|
|
19
20
|
fixed_model?: string;
|
|
20
|
-
/** on
|
|
21
|
+
/** on `tasks.escalate_after` consecutive changes_requested (default 3) */
|
|
21
22
|
escalate_to?: PeerId[];
|
|
22
23
|
/** false: `local` is never owner or reviewer for this class */
|
|
23
24
|
local_allowed?: boolean;
|
|
@@ -108,6 +109,16 @@ const TEMPLATE = join(import.meta.dir, "..", "..", "templates", "routing.toml");
|
|
|
108
109
|
export const LOCAL: PeerId = "local";
|
|
109
110
|
export const PI: PeerId = "pi";
|
|
110
111
|
|
|
112
|
+
/** The quota/roster relay keeps its native preference order, then the class's original tail. */
|
|
113
|
+
export function relayCandidates(classPeers: readonly PeerId[], pii: boolean): PeerId[] {
|
|
114
|
+
const preferred = ADAPTERS.filter(adapter => adapter.routing?.handoffPriority !== undefined)
|
|
115
|
+
.sort((a, b) => a.routing!.handoffPriority! - b.routing!.handoffPriority!);
|
|
116
|
+
return [
|
|
117
|
+
...preferred.filter(adapter => !pii || adapter.trust.onPrem).map(adapter => adapter.kind),
|
|
118
|
+
...classPeers.filter(peer => definitionOf(peer)?.routing?.handoffPriority === undefined),
|
|
119
|
+
].filter((peer): peer is PeerId => !!peer);
|
|
120
|
+
}
|
|
121
|
+
|
|
111
122
|
/**
|
|
112
123
|
* `.agenthub/routing.toml`, or the shipped default when the project has none, with `.agenthub/routing.local.toml`
|
|
113
124
|
* merged over it key by key (#269). Throws on a file that does not parse or lacks a fixed model.
|
|
@@ -323,15 +334,15 @@ export function assign(
|
|
|
323
334
|
if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return deny("paused", "paused");
|
|
324
335
|
const failing = peerEntry(opts.failing, peer);
|
|
325
336
|
if (failing) return deny("failing", `failing: ${failing}`);
|
|
326
|
-
if (pii && peer !==
|
|
327
|
-
|
|
328
|
-
if (
|
|
329
|
-
if (
|
|
337
|
+
if (pii && definitionOf(peer)?.trust.onPrem !== true) return deny("pii", "pii: on-prem peers only");
|
|
338
|
+
const nativeRouting = definitionOf(peer)?.routing;
|
|
339
|
+
if (nativeRouting?.localAllowedRefusal && policy?.local_allowed === false) return deny("policy", nativeRouting.localAllowedRefusal);
|
|
340
|
+
if (nativeRouting?.longContext === "backend-limit" && task.signals.includes("long_context")) {
|
|
330
341
|
const backend = policy?.pi_backend ?? "dgx";
|
|
331
342
|
const limit = backend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens;
|
|
332
|
-
if (routing.signals.long_context_tokens > limit) return deny("policy",
|
|
343
|
+
if (routing.signals.long_context_tokens > limit) return deny("policy", `${nativeRouting.backendLabel} ${backend} capability limit ${limit} tokens`);
|
|
333
344
|
}
|
|
334
|
-
if (
|
|
345
|
+
if (nativeRouting?.longContext === "skip-owner" && role === "owner" && task.signals.includes("long_context") && routing.constraints.long_context === "skip_local") return deny("policy", "long_context: skip local");
|
|
335
346
|
return undefined;
|
|
336
347
|
};
|
|
337
348
|
/** Quota that resets soonest gets used first: headroom per hour left in the window, at least 15 min (a task assigned that close to a reset mostly runs after it). */
|
|
@@ -381,7 +392,7 @@ export function assign(
|
|
|
381
392
|
}
|
|
382
393
|
const ranked = rank(ok, role, not);
|
|
383
394
|
// Demotion applies to local and Pi too: a demoted one loses its place ahead of the cloud peers.
|
|
384
|
-
const localTier = ranked.filter((p) => (p ===
|
|
395
|
+
const localTier = ranked.filter((p) => definitionOf(p)?.routing?.preferredTier === true && !(role === "owner" && peerEntry(opts.demoted, p)));
|
|
385
396
|
if (localTier.length) return localTier.find((p) => states[p] === "idle") ?? localTier[0]; // local/Pi stays ahead of an idle cloud peer
|
|
386
397
|
return ranked.find((p) => states[p] === "idle") ?? ranked[0];
|
|
387
398
|
};
|
|
@@ -413,8 +424,8 @@ export function assign(
|
|
|
413
424
|
const route = policy?.route ?? routing.local.route;
|
|
414
425
|
const fixedModel = policy?.fixed_model ?? routing.local.fixed_model;
|
|
415
426
|
const piBackend = policy?.pi_backend;
|
|
416
|
-
if (owner ===
|
|
417
|
-
if (owner ===
|
|
427
|
+
if (owner && definitionOf(owner)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
|
|
428
|
+
if (owner && definitionOf(owner)?.routing?.trace === "backend") trace.push(`${definitionOf(owner)!.routing!.backendLabel} decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
|
|
418
429
|
return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
|
|
419
430
|
}
|
|
420
431
|
|
package/src/hub/settings.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PLANNER_PEERS, SUPPORTED_PEERS } from "
|
|
1
|
+
import { definitionOf, ADAPTERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
@@ -6,7 +6,7 @@ import { basename, dirname, join } from "node:path";
|
|
|
6
6
|
import { CLASSES, type TaskClass } from "./board.ts";
|
|
7
7
|
import { configTracked, configRepositoryPresent } from "./config-trust.ts";
|
|
8
8
|
import { PERMISSION_MODES, permissionBoundary } from "./permission-mode.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { START_MODES } from "./start-mode.ts";
|
|
10
10
|
import { OVERLAY_FILE, overlayToml, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "./routing.ts";
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -49,14 +49,15 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
49
49
|
...["enabled", "auto_reassign"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["task_sweep", key] })),
|
|
50
50
|
...["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"].map((key): SettingDef => ({ key: `task_sweep.${key}`, group: "Switches", label: `Task sweep: ${key}`, type: "number", min: 1, max: 2_147_483_647 / (key === "interval_s" ? 1000 : 60_000), store: "config", applies: "live", risk: "raises", path: ["task_sweep", key] })),
|
|
51
51
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "live", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
|
|
52
|
+
{ key: "tasks.escalate_after", group: "Routing", label: "Escalate after N consecutive changes_requested", type: "integer", min: 1, max: 10, store: "config", applies: "live", risk: "raises", path: ["tasks", "escalate_after"] },
|
|
52
53
|
{ key: "planner", group: "Roles", label: "Planner (the one peer that plans)", type: "enum", values: PLANNER_PEERS, store: "config", applies: "live", risk: "raises", path: ["planner"] },
|
|
53
54
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "next use", risk: "safe", path: ["catalog_roles", peer] })),
|
|
54
55
|
{ key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
|
|
55
56
|
...SETTING_PEERS.map((peer): SettingDef => ({ key: `roster.${peer}`, group: "Roster", label: `${peer}: participates`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", peer] })),
|
|
56
|
-
...SETTING_PEERS.filter(peer => peer
|
|
57
|
-
...SETTING_PEERS.filter(peer => peer
|
|
57
|
+
...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).map((peer): SettingDef => ({ key: `permission.${peer}`, group: "Permissions", label: `${peer}: mode now`, type: "enum", values: PERMISSION_MODES, store: "runtime", applies: "live", risk: "raises", floor: "ask", path: [peer] })),
|
|
58
|
+
...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at next peer start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "next use", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
|
|
58
59
|
...START_MODE_PEERS.map((peer): SettingDef => ({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (tui unless set)`, type: "enum", values: START_MODES, store: "config", applies: "next use", risk: "raises", floor: "tui", path: ["peers", peer, "start_mode"] })),
|
|
59
|
-
|
|
60
|
+
...ADAPTERS.flatMap(adapter => adapter.configuration?.settings ?? []),
|
|
60
61
|
{ key: "routing.stay_switch", group: "Routing", label: "Stay or switch (hub/auto and stage routes)", type: "enum", values: ["off", "shadow", "enforce"], store: "routing", applies: "live", risk: "raises", path: ["stay_switch"] },
|
|
61
62
|
...CLASSES.flatMap(perClass),
|
|
62
63
|
{ key: "research.enabled", group: "Switches", label: "Research records", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["research", "enabled"] },
|
package/src/hub/settled-roles.ts
CHANGED
package/src/hub/start-mode.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SUPPORTED_PEERS,
|
|
1
|
+
import { definitionOf, SUPPORTED_PEERS, START_MODE_PEERS as REGISTRY_START_MODE_PEERS } from "../adapters/registry.ts";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* #269: how a peer comes up when the hub starts it: its own TUI in a terminal, or headless. A person who types
|
|
@@ -8,12 +8,15 @@ import { SUPPORTED_PEERS, isSupportedPeer } from "./peer-catalog.ts";
|
|
|
8
8
|
export const PEER_START_RETRY_MS = 30_000;
|
|
9
9
|
export const START_MODES = ["tui", "headless"] as const;
|
|
10
10
|
export type StartMode = typeof START_MODES[number];
|
|
11
|
+
// ponytail: released coordinators source-probe this exact literal before restoring headless Kimi.
|
|
12
|
+
// Keep it until those readers negotiate the registry capability.
|
|
11
13
|
/** The peers with both forms. Kimi's TUI is pull-only; Claude has only a TUI and local only headless. */
|
|
12
14
|
export const START_MODE_PEERS = ["pi", "codex", "kimi"] as const;
|
|
13
15
|
/** Every peer the hub can start itself. */
|
|
14
16
|
export const STARTABLE_PEERS = SUPPORTED_PEERS;
|
|
15
17
|
export type StartablePeer = typeof STARTABLE_PEERS[number];
|
|
16
|
-
export const isStartablePeer = (value: unknown): value is StartablePeer =>
|
|
18
|
+
export const isStartablePeer = (value: unknown): value is StartablePeer => typeof value === "string" && !!definitionOf(value)?.start.conductorStartable;
|
|
19
|
+
|
|
17
20
|
|
|
18
21
|
export type PeerStartConfig = Record<string, { start_mode?: StartMode }>;
|
|
19
22
|
/** The checked per-peer start modes; only peers with both forms may be configured. */
|
|
@@ -22,7 +25,7 @@ export function peerStartConfig(value: unknown): PeerStartConfig {
|
|
|
22
25
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("peers must be an object of per-peer settings");
|
|
23
26
|
const result: PeerStartConfig = {};
|
|
24
27
|
for (const [peer, block] of Object.entries(value)) {
|
|
25
|
-
if (!(
|
|
28
|
+
if (!(REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer)) throw new Error(`peers.${peer}: only ${REGISTRY_START_MODE_PEERS.join(" and ")} have a start mode`);
|
|
26
29
|
if (!block || typeof block !== "object" || Array.isArray(block)) throw new Error(`peers.${peer} must be an object`);
|
|
27
30
|
const mode = (block as { start_mode?: unknown }).start_mode;
|
|
28
31
|
if (mode !== undefined && !(START_MODES as readonly unknown[]).includes(mode)) throw new Error(`peers.${peer}.start_mode must be tui or headless`);
|
|
@@ -33,9 +36,8 @@ export function peerStartConfig(value: unknown): PeerStartConfig {
|
|
|
33
36
|
|
|
34
37
|
/** The mode a start of `peer` takes when nothing more specific was asked: `tui` wherever the peer has one. */
|
|
35
38
|
export function startModeOf(peers: PeerStartConfig | undefined, peer: string): StartMode {
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
return "headless";
|
|
39
|
+
if ((REGISTRY_START_MODE_PEERS as readonly string[]).includes(peer)) return peers?.[peer]?.start_mode ?? definitionOf(peer)?.start.defaultForm ?? "tui";
|
|
40
|
+
return definitionOf(peer)?.start.defaultForm ?? "headless";
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
/**
|
package/src/hub/tasks.ts
CHANGED
|
@@ -18,12 +18,12 @@ import { CLASSES, OUTCOMES_KEPT_MS, ALL_PLAN_KEYS, TASK_MOVE_REASONS, taskHasSta
|
|
|
18
18
|
import type { Bus } from "./bus.ts";
|
|
19
19
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, sanitize, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
20
20
|
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
21
|
-
import { assign, detectSignals,
|
|
21
|
+
import { assign, detectSignals, peerEntry, predictSplit, relayCandidates, type Assignment, type PeerCapabilities, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
22
22
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
23
23
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
24
24
|
import { realPath } from "./project.ts";
|
|
25
25
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
26
|
-
import {
|
|
26
|
+
import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
|
|
27
27
|
import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
28
28
|
import { readSdlcPr, sdlcConfig, sdlcReceipt, sdlcStatus, stageDefinition, lifecycleStage, type SdlcConfig, sdlcGh, type SdlcGh, type SdlcStatus } from "./sdlc.ts";
|
|
29
29
|
|
|
@@ -125,9 +125,12 @@ export interface TasksDeps {
|
|
|
125
125
|
sweep?: TaskSweepConfig;
|
|
126
126
|
/** Shutdown or coordinator work holds task sweeps too. */
|
|
127
127
|
sweepHeld?: () => boolean;
|
|
128
|
+
/** Consecutive changes_requested verdicts that move a task to another peer (`tasks.escalate_after`, #488). */
|
|
129
|
+
escalateAfter?: () => number;
|
|
128
130
|
}
|
|
129
131
|
|
|
130
|
-
|
|
132
|
+
/** The `tasks.escalate_after` default (#488): the third consecutive changes_requested escalates. */
|
|
133
|
+
export const ESCALATE_AFTER = 3;
|
|
131
134
|
// Demotion (issue #36): a failure counts half after a day. A peer is demoted for a class once its decayed failures
|
|
132
135
|
// reach 1.5 (two within about half a day, three within two days) and outweigh its decayed successes there.
|
|
133
136
|
// ponytail: fixed constants; make them config when someone needs to tune them.
|
|
@@ -509,7 +512,7 @@ export class Tasks {
|
|
|
509
512
|
private health() {
|
|
510
513
|
const failing = this.d.failing?.();
|
|
511
514
|
const held = this.d.held?.();
|
|
512
|
-
return { ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => peer.id
|
|
515
|
+
return { ...(this.d.capabilities ? { capabilities: this.d.capabilities(), repository: this.d.repository } : {}), pullOnly: [...this.d.bus.peers.values()].filter(peer => definitionOf(peer.id)?.attach?.pullOnlyForms?.length && peer.pullOnly).map(peer => peer.id), offRoster: ADAPTERS.map(adapter => adapter.kind).filter(peer => !this.d.bus.isRostered(peer)), ...(failing ? { failing } : {}), ...(held ? { held } : {}), ...(this.d.roles ? { roles: this.d.roles } : {}) };
|
|
513
516
|
}
|
|
514
517
|
|
|
515
518
|
/** Capability admission precedes explicit assignments/reservations, independent of readiness or transient peer health. */
|
|
@@ -799,7 +802,7 @@ export class Tasks {
|
|
|
799
802
|
if (by !== USER && Object.hasOwn(patch, "reserved") && this.hasPersonReservation(task)) throw new PreEffectToolRefusal("a person's reservation is retained");
|
|
800
803
|
if (Object.hasOwn(patch, "deps") || Object.hasOwn(patch, "reserved")) {
|
|
801
804
|
if (task.state !== "backlog" || taskHasStarted(task) || task.owner) throw new PreEffectToolRefusal("dependency and reservation changes require unstarted backlog work");
|
|
802
|
-
if (patch.reserved && !(
|
|
805
|
+
if (patch.reserved && !definitionOf(patch.reserved) && !this.d.bus.knownPeers().includes(patch.reserved) && !this.d.routing().classes[task.class]?.peers.includes(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is not a supported or configured peer");
|
|
803
806
|
if (patch.reserved && !this.d.bus.isRostered(patch.reserved)) throw new PreEffectToolRefusal("reserved owner is off roster");
|
|
804
807
|
if (patch.reserved) this.requireCapabilities(task, patch.reserved);
|
|
805
808
|
const rows = new Map(this.d.board.list().map(row => [row.id, row]));
|
|
@@ -1596,7 +1599,7 @@ export class Tasks {
|
|
|
1596
1599
|
const pii = this.isPii(task), delivery = { ...a, reviewer: task.reviewer ?? undefined };
|
|
1597
1600
|
const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", delivery.owner, h.withheld)}`);
|
|
1598
1601
|
const handoff = context && !pii ? this.screen(task, context, "handoff", delivery.owner, contextWithheld).slice(0, 3000) : "";
|
|
1599
|
-
const facts = [`class ${profileTaskLabel(task)}`, delivery.owner
|
|
1602
|
+
const facts = [`class ${profileTaskLabel(task)}`, delivery.owner && definitionOf(delivery.owner)?.routing?.backendLabel ? `backend ${definitionOf(delivery.owner)!.routing!.backendLabel}/${delivery.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", delivery.reviewer ? `reviewer ${delivery.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(delivery)].filter(Boolean).join("; ");
|
|
1600
1603
|
const plan = planText(task.plan);
|
|
1601
1604
|
const body = [
|
|
1602
1605
|
`Task #${task.id} [${profileTaskLabel(task)}] ${task.title}`,
|
|
@@ -2338,7 +2341,7 @@ export class Tasks {
|
|
|
2338
2341
|
if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
|
|
2339
2342
|
this.contradict(rejected);
|
|
2340
2343
|
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
|
|
2341
|
-
if (rejected.rejections >= ESCALATE_AFTER) {
|
|
2344
|
+
if (rejected.rejections >= (this.d.escalateAfter?.() ?? ESCALATE_AFTER)) {
|
|
2342
2345
|
let moved: Task;
|
|
2343
2346
|
try {
|
|
2344
2347
|
moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
|
|
@@ -2494,7 +2497,7 @@ export class Tasks {
|
|
|
2494
2497
|
const owner = task.owner === peer;
|
|
2495
2498
|
const weights = this.weights(task.class);
|
|
2496
2499
|
const pii = this.isPii(task);
|
|
2497
|
-
const candidates = owner ?
|
|
2500
|
+
const candidates = owner ? relayCandidates(routing.classes[task.class]?.peers ?? [], pii) : [];
|
|
2498
2501
|
const a = assign(task, this.states(), routing, { ...weights, offRoster: [...(weights.offRoster ?? []), peer], exclude: [peer], excludeOwners: owner && this.plannerPullRecord(task)?.pull ? this.plannerFull(task) : [], candidates, ...(owner ? { waitsFor: this.waitsFor(task) } : task.owner ? { notReviewer: task.owner } : {}) });
|
|
2499
2502
|
return { id: task.id, title: this.publicTitle(task), role: owner ? "owner" : "reviewer", to: (owner ? a.owner : a.reviewer) ?? null };
|
|
2500
2503
|
});
|
|
@@ -2515,7 +2518,7 @@ export class Tasks {
|
|
|
2515
2518
|
if (urgentOnly && !task.signals.includes("urgent")) continue;
|
|
2516
2519
|
if (task.owner === peer && (OPEN.includes(task.state) || roster && task.state === "in_review")) {
|
|
2517
2520
|
const pii = this.isPii(task);
|
|
2518
|
-
const candidates =
|
|
2521
|
+
const candidates = relayCandidates(routing.classes[task.class]?.peers ?? [], pii);
|
|
2519
2522
|
if (this.plannerPullRecord(task)?.pull && !assign(task, this.states(), routing, { candidates, exclude: [...this.excluded(task), peer], excludeOwners: this.plannerFull(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).owner) continue;
|
|
2520
2523
|
// Budget keeps its original release/unassign behaviour for ordinary work. Roster changes the state in
|
|
2521
2524
|
// assignOwner's same owner write only after routing has found somebody to take it.
|
|
@@ -2735,4 +2738,4 @@ export class Tasks {
|
|
|
2735
2738
|
}
|
|
2736
2739
|
}
|
|
2737
2740
|
|
|
2738
|
-
export { LOCAL };
|
|
2741
|
+
export { LOCAL } from "./routing.ts";
|
package/src/hub/tooling.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
1
2
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import { accessSync,
|
|
3
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
|
-
import { delimiter,
|
|
5
|
+
import { delimiter, relative, resolve, isAbsolute } from "node:path";
|
|
5
6
|
import { realPath } from "./project.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -15,11 +16,6 @@ export const ADDONS = ["claude-mem", "ponytail", "ripwire", "open-code-review",
|
|
|
15
16
|
export type AddonName = (typeof ADDONS)[number];
|
|
16
17
|
export interface Addon { name: AddonName; version: string | null }
|
|
17
18
|
|
|
18
|
-
/** Plugin names (before the `@marketplace`) that stand for an add-on. */
|
|
19
|
-
// Maps, not objects: a plugin named `constructor` or `toString` must find nothing, not an inherited property.
|
|
20
|
-
const PLUGINS = new Map<string, AddonName>([["claude-mem", "claude-mem"], ["ponytail", "ponytail"], ["open-code-review", "open-code-review"]]);
|
|
21
|
-
/** Claude Code only: the Codex plugin is a Claude plugin named `codex` from the `openai-codex` marketplace. */
|
|
22
|
-
const CLAUDE_ONLY = new Map<string, AddonName>([["codex@openai-codex", "codex-plugin"]]);
|
|
23
19
|
/**
|
|
24
20
|
* Executables any peer with a shell can run, and how each is told apart from another program of the same name: the
|
|
25
21
|
* first one on PATH is asked for its version (bounded, no stdin), so Graphviz's `dot` is never resource admission.
|
|
@@ -32,60 +28,9 @@ const PROBE_MS = 2000, PROBE_BYTES = 4096;
|
|
|
32
28
|
/** `env` supplies CLAUDE_CONFIG_DIR and CODEX_HOME, the runtimes' own config roots (the hub's environment by default). */
|
|
33
29
|
export interface ToolingSources { home?: string; cwd?: string; path?: string; env?: Record<string, string | undefined> }
|
|
34
30
|
|
|
35
|
-
/** A regular file of at most 1 MiB, opened without blocking: a FIFO or device under a project's `.claude/` reads as absent. */
|
|
36
|
-
const text = (file: string): string | undefined => {
|
|
37
|
-
let fd: number | undefined;
|
|
38
|
-
try {
|
|
39
|
-
fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
40
|
-
const st = fstatSync(fd);
|
|
41
|
-
if (!st.isFile() || st.size > 1024 * 1024) return undefined;
|
|
42
|
-
return readFileSync(fd, "utf8");
|
|
43
|
-
} catch { return undefined; } finally { if (fd !== undefined) closeSync(fd); }
|
|
44
|
-
};
|
|
45
|
-
const json = (file: string): Record<string, unknown> | undefined => {
|
|
46
|
-
try { const v = JSON.parse(text(file) ?? ""); return v && typeof v === "object" ? v as Record<string, unknown> : undefined; } catch { return undefined; }
|
|
47
|
-
};
|
|
48
31
|
/** A version string as data, never anything longer or stranger. */
|
|
49
32
|
const version = (v: unknown): string | null => typeof v === "string" && /^[0-9A-Za-z.+_-]{1,40}$/.test(v) ? v : null;
|
|
50
33
|
|
|
51
|
-
function claudePlugins(root: string, cwd: string | undefined): Addon[] {
|
|
52
|
-
const enabled = new Map<string, boolean>();
|
|
53
|
-
for (const file of [join(root, "settings.json"), ...(cwd ? [join(cwd, ".claude", "settings.json"), join(cwd, ".claude", "settings.local.json")] : [])]) {
|
|
54
|
-
const map = json(file)?.enabledPlugins;
|
|
55
|
-
if (map && typeof map === "object") for (const [key, on] of Object.entries(map)) enabled.set(key, on === true);
|
|
56
|
-
}
|
|
57
|
-
const installed = (json(join(root, "plugins", "installed_plugins.json"))?.plugins ?? {}) as Record<string, { scope?: string; projectPath?: string; version?: unknown }[]>;
|
|
58
|
-
const out: Addon[] = [];
|
|
59
|
-
for (const [key, on] of enabled) {
|
|
60
|
-
if (!on) continue;
|
|
61
|
-
const name = CLAUDE_ONLY.get(key) ?? PLUGINS.get(key.split("@")[0]!);
|
|
62
|
-
const entries = Array.isArray(installed[key]) ? installed[key]! : [];
|
|
63
|
-
// An install for another project is not this one's.
|
|
64
|
-
const entry = entries.find((e) => e?.scope === "user") ?? entries.find((e) => e?.projectPath === cwd);
|
|
65
|
-
if (name && entry) out.push({ name, version: version(entry.version) });
|
|
66
|
-
}
|
|
67
|
-
return out;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function codexPlugins(root: string): Addon[] {
|
|
71
|
-
const config = text(join(root, "config.toml"));
|
|
72
|
-
if (config === undefined) return [];
|
|
73
|
-
const found = new Map<string, string>(), disabled = new Set<string>(); // plugin name -> marketplace
|
|
74
|
-
// `[plugins."name@market"]` with no `enabled = false` below it, and plugins whose hooks Codex was told to trust
|
|
75
|
-
// unless their plugin entry disables them: a trust entry outlives the plugin's own switch.
|
|
76
|
-
for (const m of config.matchAll(/^\[plugins\."([^"@]+)@([^"]+)"\]\s*\n((?:(?!\[)[^\n]*\n?)*)/gm)) if (!/^\s*enabled\s*=\s*false\b/m.test(m[3] ?? "")) found.set(m[1]!, m[2]!); else disabled.add(m[1]! + "@" + m[2]!);
|
|
77
|
-
for (const m of config.matchAll(/^\[hooks\.state\."([^"@]+)@([^":]+):/gm)) if (!disabled.has(m[1]! + "@" + m[2]!)) found.set(m[1]!, m[2]!);
|
|
78
|
-
const out: Addon[] = [];
|
|
79
|
-
for (const [plugin, market] of found) {
|
|
80
|
-
const name = PLUGINS.get(plugin);
|
|
81
|
-
if (!name) continue;
|
|
82
|
-
let versions: string[] = [];
|
|
83
|
-
try { versions = readdirSync(join(root, "plugins", "cache", market, plugin)); } catch { /* no cache: version unknown */ }
|
|
84
|
-
out.push({ name, version: versions.length === 1 ? version(versions[0]) : null });
|
|
85
|
-
}
|
|
86
|
-
return out;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
34
|
/** Never throws: a path that cannot be resolved (a project directory that went away) is compared as given. */
|
|
90
35
|
const real = (path: string): string => { try { return realPath(path); } catch { return resolve(path); } };
|
|
91
36
|
const within = (file: string, dir: string): boolean => { const r = relative(dir, file); return r === "" || (!r.startsWith("..") && !isAbsolute(r)); };
|
|
@@ -145,8 +90,7 @@ export async function detectTooling(peer: string, src: ToolingSources = {}): Pro
|
|
|
145
90
|
const found = new Map<AddonName, Addon>();
|
|
146
91
|
const add = (a: Addon) => { if (!found.has(a.name) || (found.get(a.name)!.version === null && a.version !== null)) found.set(a.name, a); };
|
|
147
92
|
const env = src.env ?? process.env;
|
|
148
|
-
|
|
149
|
-
if (peer === "codex") codexPlugins(env.CODEX_HOME?.trim() || join(home, ".codex")).forEach(add);
|
|
93
|
+
definitionOf(peer)?.hooks?.pluginInventory?.({ env, home, cwd: src.cwd }).forEach(add);
|
|
150
94
|
const path = src.path ?? process.env.PATH ?? "";
|
|
151
95
|
for (const [bin, spec] of Object.entries(BINARIES)) {
|
|
152
96
|
const file = onPath(bin, path, src.cwd ?? process.cwd());
|