@staix/agent-hub 0.12.39 → 0.12.40

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +15 -10
  3. package/docs/agent-notes/adapters.md +4 -1
  4. package/docs/agent-notes/budget.md +2 -0
  5. package/docs/agent-notes/bus.md +2 -2
  6. package/docs/agent-notes/daemon.md +10 -6
  7. package/docs/agent-notes/models.md +4 -4
  8. package/docs/agent-notes/tasks.md +3 -0
  9. package/docs/agent-notes/tests.md +1 -1
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +152 -28
  12. package/docs/quickstart.md +19 -5
  13. package/docs/security.md +19 -7
  14. package/docs/smoke.md +2 -2
  15. package/docs/specs/2026-09-19-agent-hub-design.md +158 -70
  16. package/docs/specs/2026-10-04-switchyard-source-port-design.md +12 -7
  17. package/docs/verification/2026-10-20-0.12.40.md +63 -0
  18. package/docs/verified.json +38 -19
  19. package/package.json +1 -1
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +413 -147
  22. package/src/adapters/acp.ts +34 -1
  23. package/src/adapters/claude-channel.ts +35 -4
  24. package/src/adapters/codex-appserver.ts +12 -3
  25. package/src/adapters/definitions/builtin-metadata.ts +2 -0
  26. package/src/adapters/definitions/native-cli-doctor.ts +8 -5
  27. package/src/adapters/definitions/native-cli-entry-claude.ts +7 -2
  28. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -14
  29. package/src/adapters/definitions/native-cli-entry-jcode.ts +13 -3
  30. package/src/adapters/definitions/native-cli-entry-pi.ts +9 -7
  31. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  32. package/src/adapters/definitions/native-cli-jcode-mcp.ts +55 -1
  33. package/src/adapters/jcode.ts +110 -4
  34. package/src/adapters/local-worker.ts +21 -37
  35. package/src/adapters/pi.ts +7 -0
  36. package/src/cli/console-state.ts +1 -0
  37. package/src/cli/console.ts +11 -1
  38. package/src/cli/help.ts +13 -8
  39. package/src/cli/identity-audit.ts +33 -7
  40. package/src/cli/jcode-mcp.ts +1 -1
  41. package/src/cli/main.ts +134 -24
  42. package/src/cli/output.ts +5 -2
  43. package/src/cli/status-lines.ts +1 -1
  44. package/src/cli/terminal-recovery.ts +54 -4
  45. package/src/cli/upgrade-interactive.ts +5 -0
  46. package/src/cli/upgrade-runtime.ts +13 -1
  47. package/src/cli/upgrade.ts +67 -1
  48. package/src/hub/board.ts +4 -2
  49. package/src/hub/bus.ts +8 -3
  50. package/src/hub/checks.ts +15 -5
  51. package/src/hub/conductor.ts +2 -1
  52. package/src/hub/config-apply.ts +20 -2
  53. package/src/hub/config-reload.ts +2 -8
  54. package/src/hub/control-client.ts +26 -3
  55. package/src/hub/daemon.ts +155 -68
  56. package/src/hub/delivery-superseded.ts +19 -2
  57. package/src/hub/events.ts +4 -1
  58. package/src/hub/hub-tools.ts +4 -4
  59. package/src/hub/inference.ts +4 -8
  60. package/src/hub/lifecycle.ts +3 -3
  61. package/src/hub/main-checkout.ts +36 -0
  62. package/src/hub/memory-profile-check.ts +6 -2
  63. package/src/hub/memory-profile.ts +18 -1
  64. package/src/hub/peers.ts +2 -0
  65. package/src/hub/ports.ts +1 -0
  66. package/src/hub/routing.ts +24 -14
  67. package/src/hub/settings.ts +1 -1
  68. package/src/hub/task-repository.ts +136 -0
  69. package/src/hub/tasks.ts +101 -21
  70. package/src/hub/terminal-launch.ts +130 -0
  71. package/src/hub/usage.ts +44 -18
  72. package/src/models/route/config.ts +1 -1
  73. package/src/omniroute/client.ts +7 -19
  74. package/templates/AGENTS.block.md +1 -0
  75. package/templates/routing.toml +10 -44
  76. package/src/switchyard/config.ts +0 -45
  77. package/src/switchyard/sidecar.ts +0 -136
package/src/hub/events.ts CHANGED
@@ -78,7 +78,7 @@ function cutShort(file: string): boolean {
78
78
  }
79
79
 
80
80
  /** An appender that never throws: the state dir can vanish under a running hub, and telemetry must not take it down. */
81
- export function eventLog(file: string, onFailure?: (e: HubEvent) => void): (e: HubEvent) => void {
81
+ export function eventLog(file: string, onFailure?: (e: HubEvent) => void): (e: HubEvent) => boolean {
82
82
  let first = true;
83
83
  return (e) => {
84
84
  try {
@@ -86,9 +86,12 @@ export function eventLog(file: string, onFailure?: (e: HubEvent) => void): (e: H
86
86
  const lead = first && cutShort(file) ? "\n" : "";
87
87
  first = false;
88
88
  appendFileSync(file, `${lead}${JSON.stringify({ v: EVENTS_SCHEMA, at: new Date().toISOString(), ...e })}\n`);
89
+ return true;
89
90
  } catch {
91
+ first = true; // A failed append may have left a partial row; recheck its newline before the next event.
90
92
  try { onFailure?.(e); } catch { /* telemetry failure reporting is optional too */ }
91
93
  // the state dir is gone; the watchdog is stopping the hub
94
+ return false;
92
95
  }
93
96
  };
94
97
  }
@@ -13,7 +13,7 @@ const id = { type: "integer", description: "task id" } as const;
13
13
  const refs = {
14
14
  type: "object",
15
15
  description: "where the work lives",
16
- properties: { branch: str, commit: str, paths: { type: "array", items: str } },
16
+ properties: { repo: { type: "string", description: "owner/name repository slug or checkout path; set for work in another repository" }, branch: str, commit: str, paths: { type: "array", items: str } },
17
17
  additionalProperties: false,
18
18
  } as const;
19
19
  const list = (description: string) => ({ type: "array", items: str, description }) as const;
@@ -68,10 +68,10 @@ export const TASK_TOOLS: HubTool[] = [
68
68
  tool("hub_release_cut", "Freeze an active release's approved scope after host resource admission and fresh peer quota checks. Unknown evidence defers; no publish, tag, upgrade or authorization.", { id, revision: { type: "integer", minimum: 0 } }, ["id", "revision"]),
69
69
  tool("hub_release_list", "Read public release scope and planned/actual dates for phased planning. Configured planners only.", {}),
70
70
 
71
- tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, role: { type: "string", description: "catalog role id for this task class; guidance only, no routing or authority change" }, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it. With after, the owner is reserved: offered the task first once it is ready" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, backlog: { type: "boolean", description: "stage unoffered work; owner is only a reservation, even when naming yourself" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" }, goal: { type: "string", description: "id of this project's long-term task (ahub goal list) the work is part of" } }, ["title"]),
72
- tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it.", { id, plan: acceptPlan }, ["id"]),
71
+ tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, set refs.repo for work in another repository, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, role: { type: "string", description: "catalog role id for this task class; guidance only, no routing or authority change" }, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it. With after, the owner is reserved: offered the task first once it is ready" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, backlog: { type: "boolean", description: "stage unoffered work; owner is only a reservation, even when naming yourself" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" }, goal: { type: "string", description: "id of this project's long-term task (ahub goal list) the work is part of" } }, ["title"]),
72
+ tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it. Set refs.repo for work in another repository; acceptance may correct only that repository metadata.", { id, plan: acceptPlan, refs: { ...refs, properties: { repo: refs.properties.repo } } }, ["id"]),
73
73
  tool("hub_task_cancel", "Cancel unaccepted proposed/backlog work you proposed as its configured planner; accepted work is person-only. Keeps history and does not stop a native turn or delete files.", { id, reason: str, superseded_by: str, revision: { type: "integer", minimum: 0 } }, ["id", "reason"]),
74
- tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
74
+ tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer. A plain decline is a lasting refusal: you are never offered this task again. Pass capacity: true when you only have no room right now: you are skipped for this move only, and a later reroute or planner pull may offer it to you again.", { id, reason: str, capacity: { type: "boolean" } }, ["id"]),
75
75
  tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. 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"]),
76
76
  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"]),
77
77
  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" } }),
@@ -1,6 +1,5 @@
1
1
  import type { MlxHandle } from "../models/mlx.ts";
2
2
  import type { ChatMessage, OmniRoute } from "../omniroute/client.ts";
3
- import type { Sidecar } from "../switchyard/sidecar.ts";
4
3
  import { CLASSES, type TaskClass } from "./board.ts";
5
4
  import { DIGEST, newEnvelope, type Envelope } from "./envelope.ts";
6
5
  import { buildEscalationJudgeRequest, parseEscalationVerdict, type EscalationVerdict } from "../models/route/escalation.ts";
@@ -25,15 +24,13 @@ const SUMMARY_CAP = 1500;
25
24
 
26
25
  export interface InferenceDeps {
27
26
  omni: OmniRoute;
28
- sidecar: () => Sidecar | undefined;
29
- route: string;
30
27
  fixedModel: () => string;
31
28
  log: (line: string) => void;
32
29
  timeoutMs?: number;
33
30
  }
34
31
 
35
32
  /**
36
- * The hub's own small model calls (route `sy/fast`, the usual fallback to a fixed model). Everything here is optional
33
+ * The hub's own small fixed-model calls. Everything here is optional
37
34
  * and fail-open: no gateway, a slow model or a useless answer means "no result", never a delayed or lost delivery.
38
35
  * The model reads text written by agents, so its input is data and its output is only ever used as capped text or as
39
36
  * a value checked against a closed list: never as a route, a peer id, a tool call or an instruction.
@@ -56,7 +53,7 @@ export class Inference {
56
53
  const limit = opts.timeoutMs ?? this.d.timeoutMs ?? TIMEOUT_MS;
57
54
  const quiet = !!opts.interactive;
58
55
  let timer: ReturnType<typeof setTimeout> | undefined;
59
- // One clock over everything, the gateway probe and the sidecar start included, not only the model call.
56
+ // One clock over everything, the gateway probe included, not only the model call.
60
57
  const timeout = new Promise<"timeout">((resolve) => (timer = setTimeout(() => (abort.abort(), resolve("timeout")), limit)));
61
58
  const result = await Promise.race([this.run(system, user, maxTokens, abort.signal, quiet), timeout]);
62
59
  clearTimeout(timer);
@@ -77,9 +74,8 @@ export class Inference {
77
74
  // probe every time, so both back off.
78
75
  if (!(await this.d.omni.base())) return this.backOff("no gateway configured or reachable", quiet);
79
76
  const messages = [{ role: "system" as const, content: system }, { role: "user" as const, content: user }];
80
- const via = await this.d.sidecar()?.endpoint();
81
- const body = via ? { model: this.d.route, messages, max_tokens: maxTokens } : { model: this.d.fixedModel(), messages, max_tokens: maxTokens };
82
- const res = await this.d.omni.chat(body, { signal, ...(via ? { via } : {}) });
77
+ const body = { model: this.d.fixedModel(), messages, max_tokens: maxTokens };
78
+ const res = await this.d.omni.chat(body, { signal });
83
79
  return res.message.content?.trim() || undefined;
84
80
  } catch (e) {
85
81
  return signal.aborted ? undefined : this.backOff((e as Error).message, quiet);
@@ -8,7 +8,7 @@ import { ControlClient, PROTOCOL, readControl } from "./control-client.ts";
8
8
  import { startDaemon } from "./daemon.ts";
9
9
  import { terminalOpener } from "./terminal-open.ts";
10
10
  import { Registry, type Project } from "./registry.ts";
11
- import { CODEX_APP, CODEX_PROXY, CONTROL, SWITCHYARD } from "./ports.ts";
11
+ import { CODEX_APP, CODEX_PROXY, CONTROL } from "./ports.ts";
12
12
  import { assertLifecycleAvailable, recoveryLock } from "./recovery-store.ts";
13
13
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
14
14
 
@@ -64,7 +64,7 @@ export async function inspectProject(project: Project, timeoutMs = 3000): Promis
64
64
  async function available(base: number): Promise<boolean> {
65
65
  const servers: Server[] = [];
66
66
  try {
67
- for (const offset of [CONTROL, CODEX_APP, CODEX_PROXY, SWITCHYARD]) {
67
+ for (const offset of [CONTROL, CODEX_APP, CODEX_PROXY]) {
68
68
  const server = createServer();
69
69
  servers.push(server);
70
70
  await new Promise<void>((resolve, reject) => {
@@ -116,7 +116,7 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
116
116
  try {
117
117
  daemon = await startDaemon({ cwd: project.root, stateDir: project.stateDir, projectId: project.id, instanceId,
118
118
  controlPort: base + CONTROL, codexAppPort: base + CODEX_APP, codexProxyPort: base + CODEX_PROXY,
119
- switchyardPort: base + SWITCHYARD, unattended, onShutdownStart,
119
+ unattended, onShutdownStart,
120
120
  // #269: the real terminal providers belong to the running product; a daemon built without them opens nothing.
121
121
  terminal: (template) => terminalOpener({ template, cwd: project.root, stateDir: project.stateDir }) });
122
122
  break;
@@ -0,0 +1,36 @@
1
+ import { childEnv } from "./child-process.ts";
2
+ import { hubGitSync } from "./git.ts";
3
+
4
+ /**
5
+ * #572 AC3: the project root is the shared checkout every session reads. A task branch left checked out there changes
6
+ * what each other session sees, so a finished task says so. Advisory only: it is a line of text, never a refusal.
7
+ * Only git is read, bounded: HEAD's branch, and the default branch from `origin/HEAD`, falling back to `main`.
8
+ * A detached head, a missing directory, a directory that is not a repository and any git failure say nothing.
9
+ */
10
+ const git = (cwd: string, args: string[]) => hubGitSync(["-C", cwd, ...args], { encoding: "utf8", env: childEnv(process.env), maxBuffer: 64 * 1024, timeout: 3000 });
11
+
12
+ export function mainCheckoutBranch(cwd: string): { branch: string; defaultBranch: string } | undefined {
13
+ try {
14
+ const head = git(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
15
+ if (head.status !== 0 || typeof head.stdout !== "string" || !head.stdout.trim()) return undefined;
16
+ const branch = head.stdout.trim();
17
+ const origin = git(cwd, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
18
+ const named = origin.status === 0 && typeof origin.stdout === "string" ? origin.stdout.trim().replace(/^origin\//, "") : "";
19
+ const defaultBranch = named || "main";
20
+ return branch === defaultBranch ? undefined : { branch, defaultBranch };
21
+ } catch { return undefined; }
22
+ }
23
+
24
+ /**
25
+ * The one advisory sentence, or undefined while the checkout is on its default branch.
26
+ * A branch name is text a person or an agent chose, so it is not safe because git printed it: `nameable` is the hub's
27
+ * whole public-text screen (`Tasks.publicMetadataText`: the configured PII patterns, secret shapes and private paths). A name it refuses, or any text
28
+ * that is not a plain ref name, is replaced by a generic sentence that carries no part of it (#572 review I2).
29
+ */
30
+ export function mainCheckoutWarning(cwd: string, nameable: (text: string) => boolean = () => false): string | undefined {
31
+ const found = mainCheckoutBranch(cwd);
32
+ if (!found) return undefined;
33
+ const plain = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,99}$/.test(found.branch) && nameable(found.branch);
34
+ return plain ? `the main checkout is on \`${found.branch}\`; move the work to a worktree under .worktrees/`
35
+ : "the main checkout is on a branch other than its default (its name is withheld); move the work to a worktree under .worktrees/";
36
+ }
@@ -5,7 +5,7 @@ import { ControlClient, readControl, type ControlDescriptor } from "./control-cl
5
5
  import { canonicalPath, hubHome, realPath } from "./project.ts";
6
6
  import { processLiveness, type Liveness } from "../pi/process-signature.ts";
7
7
  import type { Project } from "./registry.ts";
8
- import type { UnboundMemoryProfileReceipt } from "./memory-profile.ts";
8
+ import { MEMORY_SPLIT_INCOMPLETE, shardProfileForProject, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
9
9
 
10
10
  export interface ExpectedMemoryProfile {
11
11
  home: string;
@@ -94,7 +94,11 @@ export function evaluateMemoryProfileCheck(expected: ExpectedMemoryProfile, evid
94
94
  result.ready = true; result.quiesced = true; result.reason = `memory handle ${bound.state}; quiesced`; daemons.push(result); continue;
95
95
  }
96
96
  if (bound.state !== "ready") { reject("running memory binding is not ready"); continue; }
97
- if (bound.profile !== expected.profile || bound.generation !== expected.generation || bound.selectorRevision !== expected.selectorRevision) { reject("running memory binding differs from the selected profile"); continue; }
97
+ // #536: on a split home each hub is bound to its own project's shard, so compare like with like.
98
+ let want: ExpectedMemoryProfile = expected;
99
+ try { want = shardProfileForProject(expected.home, project.root) ?? expected; }
100
+ catch (e) { reject((e as Error).message === MEMORY_SPLIT_INCOMPLETE ? MEMORY_SPLIT_INCOMPLETE : "running memory binding differs from the selected profile"); continue; }
101
+ if (bound.profile !== want.profile || bound.generation !== want.generation || bound.selectorRevision !== want.selectorRevision) { reject("running memory binding differs from the selected profile"); continue; }
98
102
  result.ready = true; daemons.push(result);
99
103
  }
100
104
  const relevant = daemons.filter(row => row.scope !== "different-home");
@@ -120,7 +120,24 @@ export function memoryProjectFor(cwd: string, opts: Pick<StoreOptions, "home"> =
120
120
  /** Inspect selection without constructing a store, index, or candidate sidecar. */
121
121
  export function selectedMemoryProfile(opts: StoreOptions = {}): MemoryProfileReceipt {
122
122
  assertLoadedAgentMem();
123
- return receipt(resolveProfile(homeFor(opts)), "ready");
123
+ const home = homeFor(opts);
124
+ if (!splitMarked(home)) return receipt(resolveProfile(home), "ready");
125
+ // #536: a split home selects through shards.json, not the legacy selector. The user shard is sealed by every split,
126
+ // so it names the active generation and selector revision without opening a store; an inactive split throws the resume remedy.
127
+ assertActiveSplit(home);
128
+ return receipt(resolveShardProfile(home, { user: true }), "ready");
129
+ }
130
+
131
+ /**
132
+ * #536: the shard profile a hub bound to `projectRoot` reports on an active split home, without opening a store.
133
+ * Returns undefined on an unsplit home; throws MEMORY_SPLIT_INCOMPLETE on an inactive split, and whatever the
134
+ * shard resolution throws when this project's seal is missing or corrupt.
135
+ */
136
+ export function shardProfileForProject(canonicalMemoryHome: string, projectRoot: string): MemoryProfileReceipt | undefined {
137
+ assertLoadedAgentMem();
138
+ if (!splitMarked(canonicalMemoryHome)) return undefined;
139
+ assertActiveSplit(canonicalMemoryHome);
140
+ return receipt(resolveShardProfile(canonicalMemoryHome, { project: resolveAlias(canonicalMemoryHome, memoryProjectKey(projectRoot)) }), "ready");
124
141
  }
125
142
 
126
143
  export class MemoryProfileBinding {
package/src/hub/peers.ts CHANGED
@@ -31,6 +31,8 @@ export interface PeerAdapter {
31
31
  onDelivery?: (receipt: DeliveryReceipt) => void;
32
32
  /** Safe restart metadata only: ids and launch parameters, never prompt/message text. */
33
33
  recoveryMetadata?(): Record<string, unknown>;
34
+ /** Current native TUI ownership, correlated to one launcher. A start acknowledgement is never evidence. */
35
+ terminalOwner?(launchId: string): { generation: string; nativePid?: number } | undefined;
34
36
  }
35
37
 
36
38
  export const DEFAULT_WATCHDOG_MS = 300_000;
package/src/hub/ports.ts CHANGED
@@ -9,6 +9,7 @@ export const STRIDE = 10;
9
9
  export const CONTROL = 0;
10
10
  export const CODEX_APP = 1;
11
11
  export const CODEX_PROXY = 2;
12
+ /** Reserved after sidecar retirement (#553); never bind this offset or move later allocations. */
12
13
  export const SWITCHYARD = 3;
13
14
 
14
15
  /** SQLite-backed project allocation; an explicit JSON path retains the legacy utility API under a lock. */
@@ -11,12 +11,10 @@ import type { PeerId, PeerState } from "./envelope.ts";
11
11
  import { parseHubRoutes, type HubRoute } from "../models/route/config.ts";
12
12
  import { DEFAULT_STAY_SWITCH, type StaySwitchMode } from "../models/route/stage.ts";
13
13
 
14
- type Table = Record<string, unknown>;
15
-
16
14
  export interface ClassPolicy {
17
15
  /** preference order among attached, unpaused peers */
18
16
  peers: PeerId[];
19
- /** Switchyard route the local worker asks for on a task of this class */
17
+ /** In-process hub route, or a retired non-hub selection that uses fixed_model. */
20
18
  route?: string;
21
19
  fixed_model?: string;
22
20
  /** on `tasks.escalate_after` consecutive changes_requested (default 3) */
@@ -29,8 +27,8 @@ export interface ClassPolicy {
29
27
 
30
28
  export interface Routing {
31
29
  local: { route?: string; fixed_model: string };
32
- targets: Record<string, Table & { id: string }>;
33
- routes: Record<string, Table & { type: string }>;
30
+ /** Loader-owned diagnostics for ignored legacy routing tables. */
31
+ retired?: string[];
34
32
  hub_routes?: Record<string, HubRoute>;
35
33
  /** Session-aware stay/switch for `hub/auto` and hub stage routes (#197). */
36
34
  stay_switch: StaySwitchMode;
@@ -45,9 +43,14 @@ export interface Routing {
45
43
  sources?: Record<string, "routing.local.toml">;
46
44
  }
47
45
 
46
+ /** A class fixed_model without its own route explicitly selects the gateway alias directly. */
47
+ export function effectiveLocalRoute(defaultRoute: string | undefined, classRoute?: string, classFixedModel?: string): string | undefined {
48
+ return classRoute ?? (classFixedModel === undefined ? defaultRoute : undefined);
49
+ }
50
+
48
51
  /**
49
52
  * What `.agenthub/routing.local.toml` may hold (#269): a person's machine-local override of these keys and nothing
50
- * else, so a settings write can never reach targets, routes, signals or the PII constraint.
53
+ * else, so a settings write can never reach hub routes, signals or the PII constraint.
51
54
  */
52
55
  export interface RoutingOverlay {
53
56
  stay_switch?: StaySwitchMode;
@@ -186,25 +189,33 @@ export function routingText(cwd: string): string {
186
189
 
187
190
  /** The policy of a routing.toml text with an overlay merged over it: what `loadRouting` reads, without the disk. */
188
191
  export function parseRouting(text: string, overlay?: RoutingOverlay): Routing {
189
- const raw = Bun.TOML.parse(text) as Partial<Routing>;
192
+ const raw = Bun.TOML.parse(text) as Partial<Routing> & { targets?: unknown; routes?: unknown };
193
+ const retired: string[] = [];
194
+ if (Object.hasOwn(raw, "targets")) retired.push('[targets] is retired and ignored: name gateway aliases directly in hub_routes efficient/capable/judge or fixed_model');
195
+ if (Object.hasOwn(raw, "routes")) retired.push('[routes] is retired and ignored: replace stage_router with hub_routes type="stage" (efficient_target -> efficient, capable_target -> capable, same confidence_threshold), passthrough with fixed_model and no route, and llm_classifier escalation with hub_routes type="escalation"');
196
+ delete raw.targets;
197
+ delete raw.routes;
190
198
  const sources: NonNullable<Routing["sources"]> = {};
191
199
  if (overlay) {
192
200
  const checked = checkOverlay(overlay);
193
201
  if (checked.stay_switch) { (raw as Record<string, unknown>).stay_switch = checked.stay_switch; sources.stay_switch = OVERLAY_FILE; }
194
- const known = new Set([...Object.keys(raw.routes ?? {}), ...Object.keys(raw.hub_routes ?? {})]);
202
+ const known = new Set(Object.keys(raw.hub_routes ?? {}));
195
203
  for (const [name, entry] of Object.entries(checked.classes ?? {})) {
196
- if (entry.route && !known.has(entry.route)) throw new Error(`${OVERLAY_FILE}: [classes.${name}] route ${entry.route} names no route in routing.toml`);
204
+ if (entry.route && entry.route.startsWith("hub/") && !known.has(entry.route)) throw new Error(`${OVERLAY_FILE}: [classes.${name}] route ${entry.route} names no route in routing.toml`);
197
205
  const classes = (raw.classes ??= {}) as Record<string, ClassPolicy>;
198
206
  // A class the shared file does not have starts from an empty peer list: the overlay may add its own.
199
207
  classes[name] = { ...(classes[name] ?? { peers: [] }), ...entry };
200
208
  for (const field of Object.keys(entry)) sources[`classes.${name}.${field}`] = OVERLAY_FILE;
201
209
  }
202
210
  }
203
- if (!raw.local?.fixed_model) throw new Error("routing.toml: [local] fixed_model is required (the path that works without Switchyard)");
211
+ if (!raw.local?.fixed_model) throw new Error("routing.toml: [local] fixed_model is required (the direct gateway fallback)");
204
212
  const signals = { pii_patterns: [], long_context_tokens: 120_000, pii_screen: "off" as const, ...raw.signals };
205
213
  for (const p of signals.pii_patterns) new RegExp(p); // a bad pattern fails here, at load, not in the middle of an assignment
206
214
  if (signals.pii_screen !== "off" && signals.pii_screen !== "local") throw new Error('routing.toml: [signals] pii_screen must be "off" or "local"');
207
215
  const classes = raw.classes ?? {};
216
+ if ([raw.local.route, ...Object.values(classes).map(policy => policy?.route)].some(route => route && !route.startsWith("hub/"))) {
217
+ retired.push('non-hub route selections are retired and ignored; using fixed_model (replace the selection with a hub/ route to enable in-process routing)');
218
+ }
208
219
  for (const [name, policy] of Object.entries(classes)) {
209
220
  if (policy?.pi_backend !== undefined && policy.pi_backend !== "dgx" && policy.pi_backend !== "mlx") throw new Error(`routing.toml: [classes.${name}] pi_backend must be "dgx" or "mlx"`);
210
221
  }
@@ -222,8 +233,7 @@ export function parseRouting(text: string, overlay?: RoutingOverlay): Routing {
222
233
  if (!(Number.isSafeInteger(max_switch_prefill_tokens) && max_switch_prefill_tokens > 0)) throw new Error("routing.toml: max_switch_prefill_tokens must be a positive integer");
223
234
  return {
224
235
  local: raw.local,
225
- targets: raw.targets ?? {},
226
- routes: raw.routes ?? {},
236
+ ...(retired.length ? { retired } : {}),
227
237
  hub_routes: parseHubRoutes(raw.hub_routes),
228
238
  stay_switch,
229
239
  max_switch_prefill_tokens,
@@ -298,7 +308,7 @@ export interface Assignment {
298
308
  refusals?: RoutingRefusal[];
299
309
  owner?: PeerId;
300
310
  reviewer?: PeerId;
301
- /** what `local` should ask for on this task: a Switchyard route id and the model to fall back to */
311
+ /** What local should ask for on this task: a hub route or retired non-hub selection, with its fixed fallback. */
302
312
  route?: string;
303
313
  fixedModel?: string;
304
314
  piBackend?: "dgx" | "mlx";
@@ -542,7 +552,7 @@ export function assign(
542
552
  if (reviewerHold) trace.push(` hold: ${reviewer}'s queue is held: ${reviewerHold} (it receives the review once the hold is resolved)`);
543
553
  }
544
554
  }
545
- const route = policy?.route ?? routing.local.route;
555
+ const route = effectiveLocalRoute(routing.local.route, policy?.route, policy?.fixed_model);
546
556
  const fixedModel = policy?.fixed_model ?? routing.local.fixed_model;
547
557
  const piBackend = policy?.pi_backend;
548
558
  if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
@@ -430,7 +430,7 @@ export function settingRows(input: {
430
430
  const raw = (name: string): unknown => { try { return JSON.parse(readFileSync(join(input.cwd, ".agenthub", name), "utf8")); } catch { return undefined; } };
431
431
  const local = raw(CONFIG_LOCAL), shared = raw("config.json");
432
432
  const projectRouting = existsSync(join(input.cwd, ".agenthub", "routing.toml"));
433
- const routes = [...Object.keys(input.routing.routes), ...Object.keys(input.routing.hub_routes ?? {})];
433
+ const routes = Object.keys(input.routing.hub_routes ?? {});
434
434
  const scalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
435
435
  return projectSettings(input.instances).map((def): SettingRow => {
436
436
  const base = { key: def.key, group: def.group, label: def.label, type: def.type, ...(["integer", "number"].includes(def.type) ? { min: def.min, max: def.max } : {}), applies: def.applies, ...(def.restartReason ? { restartReason: def.restartReason } : {}), risk: def.risk, ...(def.floor === undefined ? {} : { floor: def.floor }),
@@ -0,0 +1,136 @@
1
+ import { statSync } from "node:fs";
2
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
3
+ import type { TaskPlan, TaskRefs } from "./board.ts";
4
+ import { hubGitSync } from "./git.ts";
5
+ import { canonicalPath, realPath } from "./project.ts";
6
+ import { isGitHubRepository } from "./spec-read.ts";
7
+
8
+ export interface TaskRepository { key: string; root?: string; aliases: string[]; }
9
+ const GIT_TIMEOUT_MS = 1000, GIT_MAX_BYTES = 16 * 1024, MAX_ANCESTORS = 64;
10
+ const beneath = (root: string, path: string) => { const rel = relative(root, path); return !rel || rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel); };
11
+ const canonical = (path: string) => { try { return realPath(path); } catch { try { return canonicalPath(path); } catch { return resolve(path); } } };
12
+ function slug(value: string): string | undefined {
13
+ const raw = value.trim().replace(/\/+$/, "").replace(/\.git$/i, "");
14
+ if (isGitHubRepository(raw)) return raw.toLowerCase();
15
+ const scp = /^(?:[^/@:]+@)?github\.com:(.+)$/i.exec(raw);
16
+ if (scp && isGitHubRepository(scp[1])) return scp[1].toLowerCase();
17
+ try {
18
+ const url = new URL(raw);
19
+ if (url.hostname.toLowerCase() !== "github.com" || !["https:", "http:", "ssh:"].includes(url.protocol) || url.port && !(url.protocol === "ssh:" && url.port === "22")) return undefined;
20
+ const name = url.pathname.replace(/^\//, "");
21
+ return isGitHubRepository(name) ? name.toLowerCase() : undefined;
22
+ } catch { return undefined; }
23
+ }
24
+
25
+ /** Repository metadata is advisory, never an execution root or a grant. Git reads are bounded, local and cached. */
26
+ export class TaskRepositories {
27
+ readonly project: TaskRepository;
28
+ private readonly scopes = new Map<string, TaskRepository>();
29
+ private readonly directories = new Map<string, TaskRepository>();
30
+ private readonly roots = new Map<string, TaskRepository>();
31
+ private readonly paths = new Map<string, string>();
32
+ private projectNamed: boolean;
33
+ constructor(private readonly cwd: string, repository?: string) {
34
+ const root = canonical(cwd), named = repository && slug(repository);
35
+ this.projectNamed = !!named;
36
+ this.project = { key: named ? `repo:${named}` : `path:${root}`, root, aliases: [...new Set([resolve(cwd), root])] };
37
+ this.roots.set(root, this.project);
38
+ }
39
+ private git(dir: string, args: string[]): string | undefined {
40
+ try {
41
+ const found = hubGitSync(["-C", dir, ...args], { encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BYTES });
42
+ return found.status === 0 && typeof found.stdout === "string" && found.stdout.trim() ? found.stdout.trim() : undefined;
43
+ } catch { return undefined; } // Unknown identity keeps a path key; never log remote URLs or stderr.
44
+ }
45
+ private directory(path: string): string {
46
+ let cursor = resolve(path);
47
+ for (let count = 0; count < MAX_ANCESTORS; count++) {
48
+ try { if (statSync(cursor).isDirectory()) return canonical(cursor); } catch { /* Try the next ancestor, never scan a directory. */ }
49
+ const parent = dirname(cursor); if (parent === cursor) break; cursor = parent;
50
+ }
51
+ return canonical(dirname(path));
52
+ }
53
+ private at(path: string): TaskRepository {
54
+ const directory = this.directory(path), cached = this.directories.get(directory);
55
+ if (cached) return cached;
56
+ const top = this.git(directory, ["rev-parse", "--show-toplevel"]);
57
+ const root = top ? canonical(top) : directory;
58
+ let scope = this.roots.get(root);
59
+ if (!scope) {
60
+ const remote = top && this.git(root, ["config", "--get", "remote.origin.url"]);
61
+ const named = remote && slug(remote);
62
+ scope = { key: named ? `repo:${named}` : `path:${root}`, root, aliases: [root] };
63
+ this.roots.set(root, scope);
64
+ }
65
+ this.directories.set(directory, scope);
66
+ return scope;
67
+ }
68
+ private common(paths: string[]): TaskRepository {
69
+ let root = this.directory(paths[0]!);
70
+ for (const path of paths.slice(1)) {
71
+ const dir = this.directory(path);
72
+ while (!beneath(root, dir)) { const parent = dirname(root); if (parent === root) break; root = parent; }
73
+ }
74
+ return this.roots.get(root) ?? { key: `path:${root}`, root, aliases: [root] };
75
+ }
76
+ /** Resolve only once per refs identity. Default project-only tasks never invoke Git. Negative probes are cached too. */
77
+ scope(refs: TaskRefs): TaskRepository {
78
+ const absolute = (refs.paths ?? []).filter(isAbsolute);
79
+ const fingerprint = JSON.stringify([refs.repo ?? null, absolute]);
80
+ const prior = this.scopes.get(fingerprint); if (prior) return prior;
81
+ const outside = absolute.length > 0 && absolute.every(path => !beneath(this.project.root!, canonical(path)));
82
+ let scope = this.project;
83
+ if (refs.repo) {
84
+ const named = slug(refs.repo);
85
+ if (named) {
86
+ // A retained project may not have a host-supplied slug. Probe only when an explicit slug needs that alias.
87
+ if (!this.projectNamed) {
88
+ const remote = this.git(this.project.root!, ["config", "--get", "remote.origin.url"]);
89
+ const projectSlug = remote && slug(remote);
90
+ if (projectSlug) this.project.key = `repo:${projectSlug}`;
91
+ this.projectNamed = true;
92
+ }
93
+ const key = `repo:${named}`;
94
+ for (const path of absolute) this.at(path);
95
+ const matching = [...this.roots.values()].filter(repo => repo.key === key);
96
+ scope = matching.length ? { key, root: matching[0]!.root, aliases: [...new Set(matching.flatMap(repo => repo.aliases))] } : { key, aliases: [] };
97
+ } else scope = this.at(resolve(this.cwd, refs.repo));
98
+ } else if (outside) {
99
+ const found = absolute.map(path => this.at(path));
100
+ scope = found.every(repo => repo.key === found[0]!.key)
101
+ ? { key: found[0]!.key, root: found[0]!.root, aliases: [...new Set(found.flatMap(repo => repo.aliases))] }
102
+ : this.common(absolute);
103
+ }
104
+ this.scopes.set(fingerprint, scope);
105
+ return scope;
106
+ }
107
+ /** External absolute spellings are normalized once, not by probing files on every overlap read. */
108
+ relativePath(scope: TaskRepository, path: string): string {
109
+ if (!isAbsolute(path)) return path;
110
+ const initialKey = JSON.stringify([scope.root, scope.aliases, path]);
111
+ const prior = this.paths.get(initialKey); if (prior !== undefined) return prior;
112
+ // Absolute plan evidence may be the first local checkout known for an explicit slug.
113
+ if (!scope.root || !scope.aliases.some(alias => beneath(alias, path))) {
114
+ const actual = this.at(path);
115
+ if (actual.key === scope.key) {
116
+ scope.root ??= actual.root;
117
+ scope.aliases = [...new Set([...scope.aliases, ...actual.aliases])];
118
+ }
119
+ }
120
+ if (!scope.root) { this.paths.set(initialKey, path); return path; }
121
+ const key = JSON.stringify([scope.root, scope.aliases, path]), cached = this.paths.get(key); if (cached !== undefined) return cached;
122
+ const physical = canonical(path);
123
+ const root = scope.aliases.find(alias => beneath(alias, path)) ?? (beneath(scope.root, physical) ? scope.root : undefined);
124
+ const value = root ? relative(root, beneath(root, path) ? path : physical) || "." : path;
125
+ this.paths.set(key, value);
126
+ this.paths.set(initialKey, value);
127
+ return value;
128
+ }
129
+ mismatch(refs: TaskRefs, plan?: TaskPlan): boolean {
130
+ const scope = this.scope(refs);
131
+ return [...(refs.paths ?? []), ...(plan?.paths ?? [])].filter(isAbsolute).some(path => {
132
+ const actual = beneath(this.project.root!, canonical(path)) ? this.project : this.at(path);
133
+ return actual.key !== scope.key;
134
+ });
135
+ }
136
+ }