@staix/agent-hub 0.19.0 → 0.20.0

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 (41) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/adapters.md +3 -2
  4. package/docs/agent-notes/bus.md +1 -1
  5. package/docs/agent-notes/daemon.md +6 -2
  6. package/docs/agent-notes/local-worker.md +1 -0
  7. package/docs/agent-notes/tasks.md +4 -2
  8. package/docs/agent-notes/tests.md +4 -3
  9. package/docs/dogfood.md +4 -1
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +40 -8
  12. package/docs/quickstart.md +7 -3
  13. package/docs/sdlc.md +12 -0
  14. package/docs/security.md +4 -4
  15. package/docs/specs/2026-09-19-agent-hub-design.md +85 -3
  16. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  17. package/docs/verified.json +24 -16
  18. package/package.json +2 -2
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +103 -7
  21. package/src/adapters/acp.ts +8 -0
  22. package/src/adapters/channel-peer.ts +7 -5
  23. package/src/adapters/claude-implementer.ts +1 -0
  24. package/src/adapters/codex-appserver.ts +11 -1
  25. package/src/adapters/conformance/enrollment.ts +4 -4
  26. package/src/adapters/conformance/factories.ts +2 -1
  27. package/src/adapters/conformance/kit.ts +10 -0
  28. package/src/adapters/local-worker.ts +2 -0
  29. package/src/adapters/pi.ts +7 -0
  30. package/src/adapters/reviewer-print.ts +1 -0
  31. package/src/hub/ci-watch.ts +140 -0
  32. package/src/hub/claude-launch-proof.ts +67 -2
  33. package/src/hub/config-apply.ts +2 -2
  34. package/src/hub/config-trust.ts +1 -0
  35. package/src/hub/daemon.ts +86 -79
  36. package/src/hub/peers.ts +34 -0
  37. package/src/hub/settings.ts +1 -0
  38. package/src/hub/task-shell-default.ts +3 -2
  39. package/src/hub/tasks.ts +139 -7
  40. package/src/local/deny.ts +21 -0
  41. package/src/local/sandbox.ts +3 -1
@@ -0,0 +1,140 @@
1
+ import { parsePrEvidence, type SdlcGh } from "./sdlc.ts";
2
+
3
+ /**
4
+ * #785 P1b: one bounded watcher per exact PR head. It starts when `hub_task_done` names a head whose CI was not green,
5
+ * ends on the first CI result or on this deadline, and is cancelled by a newer head or any task move. It is a timer in the
6
+ * hub, never a peer turn, and its gh reads (one `gh pr view` per poll) are the only periodic gh calls the hub makes.
7
+ * The longest hosted run is about 30 minutes (the job limits in `.github/workflows/check.yml` are 35 and 40 minutes).
8
+ */
9
+ export const CI_WATCH_DEADLINE_MS = 45 * 60_000;
10
+ export const CI_WATCH_POLL_MS = 60_000;
11
+ /** Same default set as `readReviewReadiness`, which is the authoritative read the review admission makes. */
12
+ const DEFAULT_CHECKS = ["check (ubuntu-latest)", "check (macos-latest)", "seeded guards"];
13
+ const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
14
+ /** How many finished heads are remembered, so one head never notifies twice. */
15
+ const SETTLED_CAP = 256;
16
+
17
+ /** What one poll found. `incomplete` is every required check finished with none failed and not all passed (skipped, neutral, duplicated). */
18
+ export type CiPoll = "pending" | "passed" | "failed" | "incomplete" | "superseded" | "closed";
19
+ export type CiOutcome = "passed" | "failed" | "incomplete" | "deadline";
20
+
21
+ export interface CiWatch { id: number; head: string; repo: string; pr: number; deadlineAt: number }
22
+
23
+ /** One read of the PR: the head, its state and the required checks of that head. A failed or unreadable read is `pending`; the deadline bounds the retries. */
24
+ export async function pollCi(target: { repo: string; pr: number }, head: string, required: readonly string[], run: SdlcGh): Promise<CiPoll> {
25
+ if (!REPO.test(target.repo) || !Number.isSafeInteger(target.pr) || target.pr < 1) return "closed";
26
+ try {
27
+ const view = await run(["pr", "view", String(target.pr), "--repo", target.repo, "--json", "headRefOid,state,statusCheckRollup"], 10_000, undefined, 512 * 1024);
28
+ if (view.code !== 0) return "pending";
29
+ const body = JSON.parse(view.stdout);
30
+ if (body?.headRefOid !== head) return "superseded";
31
+ if (body.state !== "OPEN") return "closed";
32
+ // The same check-state mapping as every other PR read; files are not requested here.
33
+ const evidence = parsePrEvidence({ ...body, files: [], changedFiles: 0 });
34
+ const rows = (required.length ? required : DEFAULT_CHECKS).map(name => evidence.checks.filter(check => check.name === name));
35
+ if (rows.some(row => row.some(check => check.state === "failed"))) return "failed";
36
+ if (rows.some(row => row.length === 0 || row.some(check => check.state === "pending"))) return "pending";
37
+ return rows.every(row => row.length === 1 && row[0]!.state === "passed") ? "passed" : "incomplete";
38
+ } catch { return "pending"; }
39
+ }
40
+
41
+ export interface CiWatchersOptions {
42
+ deadlineMs?: number;
43
+ pollMs?: number;
44
+ now?: () => number;
45
+ poll: (watch: CiWatch) => Promise<CiPoll>;
46
+ /** False once the task moved or its head changed: the watcher ends without a notice. */
47
+ current: (watch: CiWatch) => boolean;
48
+ finish: (watch: CiWatch, outcome: CiOutcome) => void | Promise<void>;
49
+ }
50
+
51
+ /**
52
+ * At most one watcher per task, and one per exact head. Timers are chained `setTimeout`s (never an interval), unref'd, and cleared
53
+ * on every end, so nothing outlives its deadline, a newer head or `stopAll`. Watchers are not restored after a hub restart:
54
+ * the next `hub_task_done` starts one (ponytail: persist them only if restarts during CI turn out to be common).
55
+ */
56
+ export class CiWatchers {
57
+ private readonly active = new Map<number, { watch: CiWatch; timer?: ReturnType<typeof setTimeout> }>();
58
+ private readonly settled = new Map<number, { head: string; outcome: CiOutcome }>();
59
+ private readonly deadlineMs: number;
60
+ private readonly pollMs: number;
61
+ private readonly now: () => number;
62
+
63
+ constructor(private readonly o: CiWatchersOptions) {
64
+ this.deadlineMs = o.deadlineMs ?? CI_WATCH_DEADLINE_MS;
65
+ this.pollMs = o.pollMs ?? CI_WATCH_POLL_MS;
66
+ this.now = o.now ?? Date.now;
67
+ }
68
+
69
+ get size(): number { return this.active.size; }
70
+
71
+ /** Starts a watcher for this exact head; false when this head is already watched or already reported. A different head replaces the old watcher. */
72
+ start(target: { id: number; head: string; repo: string; pr: number }): boolean {
73
+ const have = this.active.get(target.id);
74
+ if (have?.watch.head === target.head || this.settled.get(target.id)?.head === target.head) return false;
75
+ if (have) this.cancel(target.id);
76
+ const entry: { watch: CiWatch; timer?: ReturnType<typeof setTimeout> } = { watch: { ...target, deadlineAt: this.now() + this.deadlineMs } };
77
+ this.active.set(target.id, entry);
78
+ this.schedule(entry);
79
+ return true;
80
+ }
81
+
82
+ /** The result already reported for this exact head, if any. */
83
+ outcomeOf(id: number, head: string): CiOutcome | undefined {
84
+ const done = this.settled.get(id);
85
+ return done?.head === head ? done.outcome : undefined;
86
+ }
87
+
88
+ /** Ends the task's watcher (a given head only when `head` is named). No notice. */
89
+ cancel(id: number, head?: string): void {
90
+ const entry = this.active.get(id);
91
+ if (!entry || head !== undefined && entry.watch.head !== head) return;
92
+ clearTimeout(entry.timer);
93
+ this.active.delete(id);
94
+ }
95
+
96
+ stopAll(): void {
97
+ for (const entry of this.active.values()) clearTimeout(entry.timer);
98
+ this.active.clear();
99
+ }
100
+
101
+ private schedule(entry: { watch: CiWatch; timer?: ReturnType<typeof setTimeout> }): void {
102
+ const delay = Math.max(0, Math.min(this.pollMs, entry.watch.deadlineAt - this.now()));
103
+ entry.timer = setTimeout(() => { void this.tick(entry); }, delay);
104
+ entry.timer.unref?.();
105
+ }
106
+
107
+ private end(entry: { watch: CiWatch; timer?: ReturnType<typeof setTimeout> }): boolean {
108
+ if (this.active.get(entry.watch.id) !== entry) return false;
109
+ clearTimeout(entry.timer);
110
+ this.active.delete(entry.watch.id);
111
+ return true;
112
+ }
113
+
114
+ private report(entry: { watch: CiWatch }, outcome: CiOutcome): void {
115
+ if (outcome !== "deadline") {
116
+ this.settled.delete(entry.watch.id);
117
+ this.settled.set(entry.watch.id, { head: entry.watch.head, outcome });
118
+ while (this.settled.size > SETTLED_CAP) this.settled.delete(this.settled.keys().next().value as number);
119
+ }
120
+ void Promise.resolve().then(() => this.o.finish(entry.watch, outcome)).catch(() => { /* the finisher guards its own notices */ });
121
+ }
122
+
123
+ /** A watcher never throws out of its timer: a `current` or `finish` that fails (a closed board at shutdown) ends it. */
124
+ private async tick(entry: { watch: CiWatch; timer?: ReturnType<typeof setTimeout> }): Promise<void> {
125
+ try { await this.step(entry); } catch { this.end(entry); }
126
+ }
127
+
128
+ private async step(entry: { watch: CiWatch; timer?: ReturnType<typeof setTimeout> }): Promise<void> {
129
+ entry.timer = undefined;
130
+ if (this.active.get(entry.watch.id) !== entry) return;
131
+ if (!this.o.current(entry.watch)) { this.end(entry); return; }
132
+ if (this.now() >= entry.watch.deadlineAt) { if (this.end(entry)) this.report(entry, "deadline"); return; }
133
+ let result: CiPoll;
134
+ try { result = await this.o.poll(entry.watch); } catch { result = "pending"; }
135
+ if (this.active.get(entry.watch.id) !== entry) return; // cancelled or replaced during the read
136
+ if (!this.o.current(entry.watch) || result === "superseded" || result === "closed") { this.end(entry); return; }
137
+ if (result === "pending") { this.schedule(entry); return; }
138
+ if (this.end(entry)) this.report(entry, result);
139
+ }
140
+ }
@@ -1,8 +1,12 @@
1
1
  import { closeSync, fstatSync, openSync, readSync, constants } from "node:fs";
2
2
  import { isAbsolute, join } from "node:path";
3
3
  import { claudeStateFile } from "./claude-state.ts";
4
+ import { readFileSync } from "node:fs";
5
+ import { isWsPeer, type WsPeer } from "../adapters/channel-peer.ts";
6
+ import { isFixedBuiltinDefinition, type AdapterDefinition } from "../adapters/registry.ts";
7
+ import type { PeerAdapter } from "./peers.ts";
4
8
 
5
- export interface ClaudeLaunchProof {
9
+ export interface ClaudeLaunchRecordProof {
6
10
  launchId: string;
7
11
  executionCwd?: string;
8
12
  privateRecord: boolean;
@@ -12,7 +16,7 @@ export interface ClaudeLaunchProof {
12
16
  }
13
17
 
14
18
  /** One bounded reader for current managed launch metadata; no native identity or payload escapes. */
15
- export function readClaudeLaunchProof(stateDir: string, peer: string, instanceId: string): ClaudeLaunchProof | undefined {
19
+ export function readClaudeLaunchProof(stateDir: string, peer: string, instanceId: string): ClaudeLaunchRecordProof | undefined {
16
20
  let fd: number | undefined;
17
21
  try {
18
22
  fd = openSync(join(stateDir, claudeStateFile("claude-launch", peer)), constants.O_RDONLY | constants.O_NOFOLLOW);
@@ -31,3 +35,64 @@ export function readClaudeLaunchProof(stateDir: string, peer: string, instanceId
31
35
  } catch { return; }
32
36
  finally { if (fd !== undefined) closeSync(fd); }
33
37
  }
38
+
39
+ /** Host-only launch and socket-generation evidence; adapter-reported generations cannot establish it. */
40
+ export interface ClaudeLaunchProof {
41
+ permissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined;
42
+ helloLaunch(peer: string, launchId: unknown): "admit" | "refuse";
43
+ expectedLaunch(peer: string): string | undefined;
44
+ owner(peer: string): WsPeer | undefined;
45
+ bindGeneration(peer: string, generation: string): void;
46
+ verified(peer: string): boolean;
47
+ }
48
+
49
+ export function createClaudeLaunchProof(opts: {
50
+ stateDir: string;
51
+ cwd: string;
52
+ instanceId: string;
53
+ definitionOf(peer: string): AdapterDefinition | undefined;
54
+ peer(peer: string): PeerAdapter | undefined;
55
+ }): ClaudeLaunchProof {
56
+ const generations = new Map<string, string>();
57
+ function channel(peer: string): boolean {
58
+ const definition = opts.definitionOf(peer);
59
+ return isFixedBuiltinDefinition(definition) && definition?.transport === "claude-channel";
60
+ }
61
+ function owner(peer: string): WsPeer | undefined {
62
+ if (!channel(peer)) return;
63
+ const current = opts.peer(peer);
64
+ return isWsPeer(current) ? current : undefined;
65
+ }
66
+ function permissionLaunch(peer: string): ReturnType<ClaudeLaunchProof["permissionLaunch"]> {
67
+ if (!channel(peer)) return;
68
+ const row = readClaudeLaunchProof(opts.stateDir, peer, opts.instanceId);
69
+ return row?.permissionHook ? row : undefined;
70
+ }
71
+ function helloLaunch(peer: string, launchId: unknown): "admit" | "refuse" {
72
+ if (!channel(peer) || launchId === undefined) return "admit";
73
+ let currentLaunch: unknown;
74
+ try {
75
+ const launch = readClaudeLaunchProof(opts.stateDir, peer, opts.instanceId);
76
+ if (launch) currentLaunch = launch.launchId;
77
+ } catch { /* No current launch evidence: retain plain/pre-daemon attachment compatibility. */ }
78
+ return currentLaunch !== undefined && launchId !== currentLaunch ? "refuse" : "admit";
79
+ }
80
+ function expectedLaunch(peer: string): string | undefined {
81
+ if (!channel(peer)) return;
82
+ let expectedLaunch: string | undefined;
83
+ try {
84
+ const rows = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
85
+ expectedLaunch = Array.isArray(rows) ? rows.find(row => row?.peer === peer && opts.definitionOf(row.peer)?.transport === "claude-channel" && row?.projectRoot === opts.cwd && row?.instanceId === opts.instanceId)?.launchId : undefined;
86
+ } catch { /* an ordinary terminal has no Orca recovery identity */ }
87
+ try {
88
+ const launch = readClaudeLaunchProof(opts.stateDir, peer, opts.instanceId);
89
+ if (launch) expectedLaunch = launch.launchId;
90
+ } catch { /* older/manual launches do not have a launcher marker */ }
91
+ return expectedLaunch;
92
+ }
93
+ return {
94
+ permissionLaunch, helloLaunch, expectedLaunch, owner,
95
+ bindGeneration(peer, generation) { if (owner(peer)) generations.set(peer, generation); },
96
+ verified(peer) { const current = owner(peer); return !!current && generations.get(peer) === current.sessionGeneration; },
97
+ };
98
+ }
@@ -175,7 +175,7 @@ const schema: Record<string, Check> = {
175
175
  omniroute: block({ urls: strings, api_key_file: text, access_hosts: strings, cf_client_id_file: text, cf_client_secret_file: text }),
176
176
  mlx: block({ enabled: bool, provider: words(["legacy", "ollama"]), host: text, runtimeDir: text, modelPath: text, bin: text, port: number(1, 65535, true), model: text, sourceModel: text,
177
177
  contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
178
- approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)), escalate_after: number(1, 10, true) }),
178
+ approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)), escalate_after: number(1, 10, true), auto_pull: bool }),
179
179
  task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, no_writes_turns: number(0, Number.MAX_SAFE_INTEGER, true), interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
180
180
  checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
181
181
  if (value === undefined) continue;
@@ -230,7 +230,7 @@ const restart: Record<string, string> = {
230
230
  profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
231
231
  text_projection: "Converter and cache ownership are bound at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
232
232
  };
233
- const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify", "pet"]);
233
+ const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "approvals.notify", "pet"]);
234
234
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
235
235
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
236
236
  export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
@@ -27,6 +27,7 @@ export const MACHINE_LOCAL = [
27
27
  "local.bash_network",
28
28
  "local.network_allow", // the hosts the egress proxy opens (#65)
29
29
  "terminal.open", // the command a hub-made TUI start runs to open a terminal (#269)
30
+ "tasks.auto_pull", // #785 P1c: lets the hub start work on its own; a committed config may not turn it on for someone else's machine
30
31
  ] as const;
31
32
  /** Legacy exported field list stays compatible; new policies use the complete list below. */
32
33
  export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "action_policy", "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "adapter_modules", "text_projection", "instances"] as const;