@staix/agent-hub 0.12.14 → 0.12.16
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 +17 -0
- package/README.md +16 -3
- package/docs/agent-notes/adapters.md +2 -0
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +5 -1
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +2 -1
- package/docs/cooperbench.md +17 -1
- package/docs/operations.md +178 -9
- package/docs/quickstart.md +2 -2
- package/docs/security.md +2 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +104 -0
- package/docs/verified.json +159 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4 -4
- package/src/adapters/codex-appserver.ts +7 -0
- package/src/adapters/pi.ts +28 -19
- package/src/cli/init.ts +40 -30
- package/src/cli/launch.ts +18 -1
- package/src/cli/main.ts +34 -11
- package/src/cli/preview.ts +80 -0
- package/src/cli/status-lines.ts +8 -2
- package/src/cli/statusline-tee.ts +14 -0
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/board.ts +5 -2
- package/src/hub/context-window.ts +72 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +126 -32
- package/src/hub/events.ts +1 -0
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/task-sweep.ts +59 -0
- package/src/hub/tasks.ts +57 -1
- package/src/models/relay.ts +79 -4
- package/src/pi/ceiling.ts +54 -0
- package/src/pi/extension.ts +20 -2
- package/src/pi/launch.ts +20 -0
- package/src/ui/index.html +6 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { loadConfig } from "../hub/daemon.ts";
|
|
4
|
+
import { childEnv } from "../hub/child-process.ts";
|
|
5
|
+
import { relayModelIds } from "../models/relay.ts";
|
|
6
|
+
import { buildPiLaunch } from "../pi/launch.ts";
|
|
7
|
+
import { buildLaunch, buildKimiLaunch, claudeObservationHooks, type Launch } from "./launch.ts";
|
|
8
|
+
|
|
9
|
+
const REDACTED = "[redacted]";
|
|
10
|
+
const UNRESOLVED = "[unresolved]";
|
|
11
|
+
/** Arbitrary native arguments can contain secrets even without a credential-looking key. */
|
|
12
|
+
function publicArg(arg: string): string {
|
|
13
|
+
const flags = new Set(["--settings", "--model", "--mcp-config", "--session-id", "--session-file", "--resume", "--continue", "--print", "--safe", "--new"]);
|
|
14
|
+
const key = arg.split("=", 1)[0]!;
|
|
15
|
+
return flags.has(key) ? (arg.includes("=") ? key + "=" + REDACTED : key) : REDACTED;
|
|
16
|
+
}
|
|
17
|
+
function nativeCommand(command: string[], trusted: string[]): string[] {
|
|
18
|
+
return command.map((value, i) => value === trusted[i] ? value : REDACTED);
|
|
19
|
+
}
|
|
20
|
+
/** Read-only preview. Inputs passed to shared native builders are redacted before serialization. */
|
|
21
|
+
export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: string[], cwd: string, stateDir: string, unattended: boolean) {
|
|
22
|
+
const args = raw.filter(arg => !["--print-command", "--dry-run", "--json"].includes(arg));
|
|
23
|
+
const config = loadConfig(cwd);
|
|
24
|
+
const unresolved: { field: string; reason: string }[] = [];
|
|
25
|
+
let launch: Launch;
|
|
26
|
+
let envNames = Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]);
|
|
27
|
+
const value = (flag: string) => {
|
|
28
|
+
const at = args.indexOf(flag);
|
|
29
|
+
if (at < 0) return undefined;
|
|
30
|
+
const next = args[at + 1];
|
|
31
|
+
if (!next || next.startsWith("--")) throw new Error("missing option value");
|
|
32
|
+
return next;
|
|
33
|
+
};
|
|
34
|
+
if (tool === "claude") {
|
|
35
|
+
let original: { command?: string; refreshInterval?: number; padding?: number } | undefined;
|
|
36
|
+
for (const file of [join(cwd, ".claude/settings.local.json"), join(cwd, ".claude/settings.json"), join(process.env.HOME ?? "", ".claude/settings.json")]) {
|
|
37
|
+
try { original ??= JSON.parse(readFileSync(file, "utf8")).statusLine; } catch { /* optional */ }
|
|
38
|
+
}
|
|
39
|
+
// Build with original inputs so channel selection and owned-flag validation match normal launch.
|
|
40
|
+
const facts = claudeObservationHooks(config, { script: join(import.meta.dir, "facts-hook.ts"), stateDir });
|
|
41
|
+
launch = buildLaunch(tool, args, { unattended, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original: { command: original.command ? REDACTED : "", ...(typeof original.refreshInterval === "number" && Number.isFinite(original.refreshInterval) ? { refreshInterval: original.refreshInterval } : {}), ...(typeof original.padding === "number" && Number.isFinite(original.padding) ? { padding: original.padding } : {}) } } : {}) }, ...(facts ? { facts } : {}) });
|
|
42
|
+
const passthrough = args.filter(arg => !["--unattended", "--safe", "--new"].includes(arg));
|
|
43
|
+
launch.args = launch.args.slice(0, launch.args.length - passthrough.length).concat(passthrough.map(publicArg));
|
|
44
|
+
} else if (tool === "codex") {
|
|
45
|
+
// A fresh native proxy is assigned by start, never guessed from an old status file.
|
|
46
|
+
launch = buildLaunch(tool, args, { unattended, proxyUrl: UNRESOLVED, codexBin: config.codex_bin === "codex" ? "codex" : REDACTED });
|
|
47
|
+
const passthrough = args.filter(arg => !["--unattended", "--safe", "--new"].includes(arg));
|
|
48
|
+
launch.args = launch.args.slice(0, launch.args.length - passthrough.length).concat(passthrough.map(publicArg));
|
|
49
|
+
unresolved.push({ field: "proxyUrl", reason: "assigned when the native adapter starts; preview does not start it" });
|
|
50
|
+
} else if (tool === "kimi") {
|
|
51
|
+
const model = value("--model");
|
|
52
|
+
launch = buildKimiLaunch(nativeCommand(config.kimi_cmd, ["kimi", "acp"]), model ? REDACTED : undefined);
|
|
53
|
+
unresolved.push({ field: "sessionId", reason: "ACP session identity is available only after native initialization" });
|
|
54
|
+
} else {
|
|
55
|
+
const mode = value("--mode") ?? "headless";
|
|
56
|
+
const backend = value("--backend") ?? config.pi.backend;
|
|
57
|
+
if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend) || (value("--session-id") && value("--session-file"))) throw new Error("invalid Pi options");
|
|
58
|
+
for (let i = 0; i < args.length; i += 2) if (!["--mode", "--backend", "--model", "--session-id", "--session-file"].includes(args[i]!)) throw new Error("unknown Pi option");
|
|
59
|
+
const models = relayModelIds({ enableHubAuto: true, mlx: config.mlx.enabled === false ? undefined : config.mlx, mlxAlias: "mlx/fast", allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast } });
|
|
60
|
+
const pi = buildPiLaunch({
|
|
61
|
+
stateDir, cmd: nativeCommand(config.pi.cmd, ["pi"]), mode: mode as "headless" | "tui", backend: backend as "auto" | "dgx" | "mlx",
|
|
62
|
+
...(value("--model") ? { model: REDACTED } : {}),
|
|
63
|
+
...(value("--session-id") ? { sessionId: REDACTED } : {}),
|
|
64
|
+
...(value("--session-file") ? { sessionFile: REDACTED } : {}),
|
|
65
|
+
relay: { url: UNRESOLVED, token: UNRESOLVED, models: models.map(id => ({ id })) },
|
|
66
|
+
tools: [], preamble: REDACTED, maxSteps: config.pi.max_steps,
|
|
67
|
+
}, {}, resolve(join(import.meta.dir, "../pi/extension.ts")), UNRESOLVED, UNRESOLVED);
|
|
68
|
+
launch = pi;
|
|
69
|
+
envNames = Object.keys(pi.env).concat(["PATH", "HOME", "USER", "SHELL", "TMPDIR", "TERM", "TERM_PROGRAM", "LANG", "LC_ALL", "LC_CTYPE", "NO_COLOR", "CODEX_HOME"].filter(key => process.env[key] !== undefined));
|
|
70
|
+
if (mode === "tui") envNames.push("AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR");
|
|
71
|
+
unresolved.push({ field: "bridge/relay endpoints and owner credentials", reason: "assigned at runtime; preview does not bind a server or start a model" });
|
|
72
|
+
if (!value("--session-id") && !value("--session-file")) unresolved.push({ field: "sessionId", reason: "new native session identity is not allocated by preview" });
|
|
73
|
+
}
|
|
74
|
+
if (tool === "claude" || tool === "codex") {
|
|
75
|
+
envNames.push("AGENTHUB_INSTANCE_ID", "AGENTHUB_LAUNCH_ID");
|
|
76
|
+
unresolved.push({ field: "env.AGENTHUB_INSTANCE_ID", reason: tool === "claude" ? "injected when an existing daemon instance is read at launch; preview does not read runtime state" : "injected when a verified Orca terminal launch is recorded against the daemon; preview does not record it" });
|
|
77
|
+
unresolved.push({ field: "env.AGENTHUB_LAUNCH_ID", reason: "allocated only for a verified Orca terminal launch; preview does not query Orca, allocate identity or write launch records" });
|
|
78
|
+
}
|
|
79
|
+
return { tool, cmd: launch.cmd, args: launch.args, settings: tool === "claude" && launch.args.includes("--settings") ? launch.args[launch.args.indexOf("--settings") + 1] : undefined, envNames: [...new Set(envNames)].sort(), ...(launch.warning ? { warning: launch.warning } : {}), unresolved, redaction: "Arbitrary user arguments, settings, commands and configured executable overrides are redacted. Environment values are never displayed.", needs: ["Native executable, accounts and runtime readiness are not checked."] };
|
|
80
|
+
}
|
package/src/cli/status-lines.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import type { ContextView } from "../hub/context-window.ts";
|
|
1
2
|
// What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
|
|
2
3
|
|
|
3
|
-
export interface PeerRow { state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; paused?: string; servedBy?: string; requestedModel?: string }
|
|
4
|
+
export interface PeerRow { context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; paused?: string; servedBy?: string; requestedModel?: string }
|
|
4
5
|
export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string }
|
|
5
6
|
|
|
6
7
|
/** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
|
|
@@ -10,6 +11,10 @@ export function backendLabel(backend: BackendRow): string {
|
|
|
10
11
|
return alias.startsWith(`${kind}/`) ? alias : `${kind}/${alias}`;
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
export function contextLine(reading: ContextView): string {
|
|
15
|
+
return `context ${reading.used === null ? "unknown" : `${Math.round(reading.used * 100)}%`} (${reading.freshness}${reading.source ? `, ${reading.source}` : ""}${reading.measuredAt !== null ? `, measured ${new Date(reading.measuredAt).toISOString()}` : ""})`;
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
export function peerLine(id: string, p: PeerRow): string {
|
|
14
19
|
return ` ${id.padEnd(8)} ${(p.state ?? "unknown").padEnd(8)} queued ${p.queued ?? 0}` +
|
|
15
20
|
// Which queued messages the peer will not wait out the batch window for (issue #41).
|
|
@@ -23,7 +28,8 @@ export function peerLine(id: string, p: PeerRow): string {
|
|
|
23
28
|
(p.servedBy ? ` last call: ${p.servedBy}` : "") +
|
|
24
29
|
// Which backend the peer asked for on its last turn: without it, telling a Pi DGX turn from an MLX one
|
|
25
30
|
// meant reading status.json by hand.
|
|
26
|
-
(p.requestedModel ? ` model: ${p.requestedModel}` : "")
|
|
31
|
+
(p.requestedModel ? ` model: ${p.requestedModel}` : "") +
|
|
32
|
+
(p.context ? ` ${contextLine(p.context)}` : "");
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
export function backendLine(backend: BackendRow): string {
|
|
@@ -22,6 +22,20 @@ try {
|
|
|
22
22
|
renameSync(`${file}.tmp`, file);
|
|
23
23
|
}
|
|
24
24
|
const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : typeof parsed.sessionId === "string" ? parsed.sessionId : "";
|
|
25
|
+
if (sessionId && dir) {
|
|
26
|
+
// Bind context to the launcher, daemon and native session. Persist no prompts or transcript text.
|
|
27
|
+
mkdirSync(dir, { recursive: true });
|
|
28
|
+
const file = join(dir, "claude-context.json");
|
|
29
|
+
const c = parsed.context_window;
|
|
30
|
+
const current = c?.current_usage;
|
|
31
|
+
const numeric = (v: unknown) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
32
|
+
const record = current !== null && typeof current === "object" && !Array.isArray(current);
|
|
33
|
+
const context = { context_window_size: numeric(c?.context_window_size), used_percentage: numeric(c?.used_percentage),
|
|
34
|
+
current_usage: !record ? null : { input_tokens: numeric(current.input_tokens), cache_creation_input_tokens: numeric(current.cache_creation_input_tokens), cache_read_input_tokens: numeric(current.cache_read_input_tokens) } };
|
|
35
|
+
writeFileSync(`${file}.tmp`, JSON.stringify({ at: Date.now(), sessionId, instanceId: process.env.AGENTHUB_INSTANCE_ID, launchId: process.env.AGENTHUB_LAUNCH_ID, context }), { mode: 0o600 });
|
|
36
|
+
chmodSync(`${file}.tmp`, 0o600);
|
|
37
|
+
renameSync(`${file}.tmp`, file);
|
|
38
|
+
}
|
|
25
39
|
if (sessionId && dir) {
|
|
26
40
|
mkdirSync(dir, { recursive: true });
|
|
27
41
|
const file = join(dir, "claude-session.json");
|
|
@@ -114,7 +114,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
114
114
|
catch { source = { state: "unavailable", peers: [], blockers: ["source recovery metadata could not be authenticated"] }; }
|
|
115
115
|
if (source.state === "stopped" || source.state === "missing") continue;
|
|
116
116
|
const blockers = [...source.blockers];
|
|
117
|
-
if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated protocol-9, protocol-10, protocol-11, protocol-12 or protocol-
|
|
117
|
+
if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated protocol-9, protocol-10, protocol-11, protocol-12, protocol-13 or protocol-14 source is required");
|
|
118
118
|
if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
|
|
119
119
|
const sessions: { codex?: string; claude?: string; pi?: SessionRef } = {};
|
|
120
120
|
for (const peer of source.peers) {
|
package/src/hub/board.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import type { PeerId } from "./envelope.ts";
|
|
3
|
+
import type { SweepRecord } from "./task-sweep.ts";
|
|
3
4
|
|
|
4
5
|
export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
|
|
5
6
|
export type TaskClass = (typeof CLASSES)[number];
|
|
@@ -26,6 +27,8 @@ export interface HistoryEntry {
|
|
|
26
27
|
owner?: PeerId | null;
|
|
27
28
|
/** On a hand-over: the new owner's split profile then (#109); absent when it was unknown. */
|
|
28
29
|
profile?: string;
|
|
30
|
+
/** Persisted task-idle ladder; never counted as fresh task activity (#186). */
|
|
31
|
+
sweep?: SweepRecord;
|
|
29
32
|
}
|
|
30
33
|
export interface Task {
|
|
31
34
|
id: number;
|
|
@@ -119,14 +122,14 @@ export class Board {
|
|
|
119
122
|
|
|
120
123
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
121
124
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
122
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "refs" | "plan" | "rejections">>, note?: string, extra: Pick<HistoryEntry, "profile"> = {}): Task {
|
|
125
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "refs" | "plan" | "rejections">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep"> = {}): Task {
|
|
123
126
|
const task = this.get(id);
|
|
124
127
|
if (!task) throw new Error(`no task #${id}`);
|
|
125
128
|
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state)) {
|
|
126
129
|
throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
|
|
127
130
|
}
|
|
128
131
|
const next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
|
|
129
|
-
const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}) }];
|
|
132
|
+
const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}) }];
|
|
130
133
|
this.db
|
|
131
134
|
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, refs = ?, plan = ?, rejections = ?, history = ?, updated = ? WHERE id = ?")
|
|
132
135
|
.run(next.state, next.owner, next.reviewer, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(history), Date.now(), id);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Native active-context occupancy, separate from accumulated billable usage (#185). */
|
|
2
|
+
export interface ContextReading {
|
|
3
|
+
source: "claude_statusline" | "codex_token_usage";
|
|
4
|
+
sessionId: string;
|
|
5
|
+
measuredAt: number;
|
|
6
|
+
tokens: number | null;
|
|
7
|
+
window: number | null;
|
|
8
|
+
used: number | null;
|
|
9
|
+
}
|
|
10
|
+
export interface ContextView {
|
|
11
|
+
source: string | null;
|
|
12
|
+
measuredAt: number | null;
|
|
13
|
+
tokens: number | null;
|
|
14
|
+
window: number | null;
|
|
15
|
+
used: number | null;
|
|
16
|
+
freshness: "fresh" | "stale" | "unknown";
|
|
17
|
+
}
|
|
18
|
+
export interface ContextConfig { gate: number; stale_min: number }
|
|
19
|
+
export const DEFAULT_CONTEXT: ContextConfig = { gate: 0, stale_min: 30 };
|
|
20
|
+
const nonnegative = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0;
|
|
21
|
+
const positive = (n: unknown): n is number => nonnegative(n) && n > 0;
|
|
22
|
+
const record = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
23
|
+
const object = (v: unknown): Record<string, unknown> => record(v) ? v : {};
|
|
24
|
+
export function claudeContext(value: unknown, sessionId: string, measuredAt: number): ContextReading {
|
|
25
|
+
const c = object(value);
|
|
26
|
+
const current = object(c.current_usage);
|
|
27
|
+
const parts = [current.input_tokens, current.cache_creation_input_tokens, current.cache_read_input_tokens];
|
|
28
|
+
const window = positive(c.context_window_size) ? c.context_window_size : null;
|
|
29
|
+
const sum = c.current_usage != null && parts.every(nonnegative) ? parts.reduce<number>((sum, n) => sum + (n as number), 0) : null;
|
|
30
|
+
const tokens = nonnegative(sum) ? sum : null;
|
|
31
|
+
const used = record(c.current_usage) && tokens !== null && nonnegative(c.used_percentage) && c.used_percentage <= 100 && window !== null ? c.used_percentage / 100 : null;
|
|
32
|
+
return { source: "claude_statusline", sessionId, measuredAt, tokens, window, used };
|
|
33
|
+
}
|
|
34
|
+
export function codexContext(value: unknown, sessionId: string, measuredAt: number): ContextReading {
|
|
35
|
+
const c = object(value), last = object(c.last);
|
|
36
|
+
const window = positive(c.modelContextWindow) ? c.modelContextWindow : null;
|
|
37
|
+
const tokens = nonnegative(last.totalTokens) ? last.totalTokens : null;
|
|
38
|
+
return { source: "codex_token_usage", sessionId, measuredAt, tokens, window, used: tokens !== null && window !== null ? Math.min(1, tokens / window) : null };
|
|
39
|
+
}
|
|
40
|
+
export const unknownContext = (): ContextView => ({ source: null, measuredAt: null, tokens: null, window: null, used: null, freshness: "unknown" });
|
|
41
|
+
/** Invalid/stale readings hide the value but never rearm a threshold already crossed. */
|
|
42
|
+
export class ContextWindows {
|
|
43
|
+
private readings = new Map<string, ContextReading>();
|
|
44
|
+
private high = new Map<string, { sessionId: string; crossed: boolean }>();
|
|
45
|
+
constructor(private cfg: ContextConfig, private crossing: (peer: string, reading: ContextReading) => unknown, private now: () => number = Date.now) {
|
|
46
|
+
if (!Number.isFinite(cfg.gate) || cfg.gate < 0 || cfg.gate > 1 || !Number.isFinite(cfg.stale_min) || cfg.stale_min <= 0) throw new Error("context: gate must be 0..1 and stale_min positive");
|
|
47
|
+
}
|
|
48
|
+
report(peer: string, reading: ContextReading, currentSession: string | undefined): void {
|
|
49
|
+
if (!currentSession || reading.sessionId !== currentSession) return;
|
|
50
|
+
const previous = this.readings.get(peer);
|
|
51
|
+
if (previous?.sessionId === reading.sessionId && positive(previous.measuredAt) && previous.measuredAt <= this.now() && positive(reading.measuredAt) && reading.measuredAt <= this.now() && previous.measuredAt > reading.measuredAt) return;
|
|
52
|
+
this.readings.set(peer, reading);
|
|
53
|
+
if (!positive(reading.measuredAt) || reading.measuredAt > this.now() || reading.used === null || !nonnegative(reading.used) || reading.used > 1 || this.now() - reading.measuredAt > this.cfg.stale_min * 60_000 || !this.cfg.gate) return;
|
|
54
|
+
const state = this.high.get(peer);
|
|
55
|
+
const crossed = state?.sessionId === reading.sessionId && state.crossed;
|
|
56
|
+
const over = reading.used >= this.cfg.gate;
|
|
57
|
+
if (over && !crossed && this.crossing(peer, reading) === false) return;
|
|
58
|
+
this.high.set(peer, { sessionId: reading.sessionId, crossed: over });
|
|
59
|
+
}
|
|
60
|
+
/** Reconsider a held crossing after release, using only the still-fresh reading and current native session. */
|
|
61
|
+
retry(peer: string, currentSession: string | undefined): void {
|
|
62
|
+
const reading = this.readings.get(peer);
|
|
63
|
+
if (reading) this.report(peer, reading, currentSession);
|
|
64
|
+
}
|
|
65
|
+
view(peer: string, currentSession: string | undefined, attached: boolean): ContextView {
|
|
66
|
+
const r = this.readings.get(peer);
|
|
67
|
+
if (!r) return unknownContext();
|
|
68
|
+
const stale = this.now() - r.measuredAt > this.cfg.stale_min * 60_000;
|
|
69
|
+
const valid = positive(r.measuredAt) && r.measuredAt <= this.now() && attached && !!currentSession && r.sessionId === currentSession && !stale;
|
|
70
|
+
return { source: r.source, measuredAt: positive(r.measuredAt) ? r.measuredAt : null, tokens: valid ? r.tokens : null, window: r.window, used: valid ? r.used : null, freshness: !valid ? stale ? "stale" : "unknown" : r.used === null ? "unknown" : "fresh" };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -6,10 +6,10 @@ export function stateDirFor(cwd: string): string {
|
|
|
6
6
|
return projectContext(cwd).stateDir;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). The plugin is installed apart from the daemon, so they can drift. */
|
|
10
|
-
export const PROTOCOL =
|
|
9
|
+
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). The plugin is installed apart from the daemon, so they can drift. */
|
|
10
|
+
export const PROTOCOL = 14;
|
|
11
11
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
12
|
-
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, PROTOCOL] as const;
|
|
12
|
+
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, PROTOCOL] as const;
|
|
13
13
|
|
|
14
14
|
export interface Hello {
|
|
15
15
|
/** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
|
package/src/hub/daemon.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildKimiLaunch } from "../cli/launch.ts";
|
|
1
2
|
import { ProgressObserver, normalizeCodexObservation, normalizeClaudeObservation } from "./progress.ts";
|
|
2
3
|
import type { ToolObservation } from "../models/route/signals.ts";
|
|
3
4
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
@@ -7,6 +8,7 @@ import { homedir } from "node:os";
|
|
|
7
8
|
import { spawn } from "node:child_process";
|
|
8
9
|
import { childEnv } from "./child-process.ts";
|
|
9
10
|
import { runCheck } from "./checks.ts";
|
|
11
|
+
import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
|
|
10
12
|
import { stripUntrusted } from "./config-trust.ts";
|
|
11
13
|
import { eventLog, readEvents, tokenDeltas } from "./events.ts";
|
|
12
14
|
import { ExecutionBudget } from "./execution-budget.ts";
|
|
@@ -56,6 +58,8 @@ import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./li
|
|
|
56
58
|
import { changedPaths, repoOf, snapshot, Turns, type TurnRecord } from "./snapshots.ts";
|
|
57
59
|
import { archiveRestartSnapshot, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
|
|
58
60
|
|
|
61
|
+
import { ContextWindows, DEFAULT_CONTEXT, claudeContext, type ContextConfig } from "./context-window.ts";
|
|
62
|
+
|
|
59
63
|
export interface HubConfig {
|
|
60
64
|
watchdog_ms: number;
|
|
61
65
|
kimi_cmd: string[];
|
|
@@ -66,6 +70,7 @@ export interface HubConfig {
|
|
|
66
70
|
memory: { enabled: boolean; worker_url?: string; inject_tokens: number; brief_items: number };
|
|
67
71
|
roles: Record<string, string[]>;
|
|
68
72
|
budget: BudgetConfig;
|
|
73
|
+
context: ContextConfig;
|
|
69
74
|
inference: InferenceConfig;
|
|
70
75
|
omniroute: OmniRouteConfig;
|
|
71
76
|
pi: { enabled: boolean; auto_start: boolean; cmd: string[]; backend: "auto" | "dgx" | "mlx"; dgx_coding: string; dgx_fast: string; max_steps: number };
|
|
@@ -76,6 +81,8 @@ export interface HubConfig {
|
|
|
76
81
|
approvals: { timeout_s: number; notify: boolean };
|
|
77
82
|
/** An owner offline this long loses its open tasks back to routing; 0 turns it off (issue #6). */
|
|
78
83
|
tasks: { release_after_min: number };
|
|
84
|
+
/** Between-turn task escalation, disabled unless explicitly enabled (#186). */
|
|
85
|
+
task_sweep: TaskSweepConfig;
|
|
79
86
|
/** A command per task class run when the owner marks the task done, and its timeout (issue #7). */
|
|
80
87
|
checks: { timeout_s: number; [cls: string]: string | number };
|
|
81
88
|
/** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
|
|
@@ -110,6 +117,7 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
110
117
|
memory: { enabled: true, inject_tokens: 2000, brief_items: 8 },
|
|
111
118
|
roles: DEFAULT_ROLES,
|
|
112
119
|
budget: DEFAULT_BUDGET,
|
|
120
|
+
context: DEFAULT_CONTEXT,
|
|
113
121
|
inference: DEFAULT_INFERENCE,
|
|
114
122
|
omniroute: DEFAULT_OMNIROUTE,
|
|
115
123
|
pi: { enabled: false, auto_start: false, cmd: ["pi"], backend: "auto", dgx_coding: "coding", dgx_fast: "fast", max_steps: 30 },
|
|
@@ -118,6 +126,7 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
118
126
|
// Off here, so tests and a hub without a config file stay silent; a project's config defaults it on for macOS.
|
|
119
127
|
approvals: { timeout_s: 120, notify: false },
|
|
120
128
|
tasks: { release_after_min: 30 },
|
|
129
|
+
task_sweep: DEFAULT_TASK_SWEEP,
|
|
121
130
|
checks: { timeout_s: 600 },
|
|
122
131
|
// Off here like approvals.notify, so tests (whose cwd is this repository) write no objects; a project's config turns it on.
|
|
123
132
|
snapshots: { enabled: false, keep: 20 },
|
|
@@ -136,7 +145,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
136
145
|
|
|
137
146
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
138
147
|
const CONFIG_FILES = ["config.json", "config.local.json"] as const;
|
|
139
|
-
const CONFIG_BLOCKS = ["memory", "roles", "budget", "inference", "omniroute", "local", "pi", "approvals", "tasks", "checks", "snapshots", "limits", "review", "recovery", "capabilities", "mlx"];
|
|
148
|
+
const CONFIG_BLOCKS = ["memory", "roles", "budget", "context", "inference", "omniroute", "local", "pi", "approvals", "tasks", "task_sweep", "checks", "snapshots", "limits", "review", "recovery", "capabilities", "mlx"];
|
|
140
149
|
|
|
141
150
|
export function loadConfig(cwd: string): HubConfig {
|
|
142
151
|
const ignored: string[] = [];
|
|
@@ -181,6 +190,7 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
181
190
|
memory: { ...DEFAULT_CONFIG.memory, ...file.memory },
|
|
182
191
|
roles: { ...DEFAULT_CONFIG.roles, ...file.roles },
|
|
183
192
|
budget: { ...DEFAULT_CONFIG.budget, wait_max_min: 30, ...file.budget }, // on with any project config (issue #36)
|
|
193
|
+
context: { ...DEFAULT_CONTEXT, ...file.context },
|
|
184
194
|
inference: { ...DEFAULT_CONFIG.inference, ...file.inference },
|
|
185
195
|
omniroute: { ...DEFAULT_CONFIG.omniroute, ...file.omniroute },
|
|
186
196
|
local: { ...DEFAULT_CONFIG.local, ...file.local },
|
|
@@ -190,6 +200,7 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
190
200
|
notify: typeof file.approvals?.notify === "boolean" ? file.approvals.notify : process.platform === "darwin",
|
|
191
201
|
},
|
|
192
202
|
tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks },
|
|
203
|
+
task_sweep: taskSweepConfig(file.task_sweep),
|
|
193
204
|
checks: { ...DEFAULT_CONFIG.checks, ...file.checks },
|
|
194
205
|
snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
|
|
195
206
|
limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
|
|
@@ -237,6 +248,8 @@ export interface DaemonOptions {
|
|
|
237
248
|
onShutdownStart?: () => void;
|
|
238
249
|
/** How often the daemon checks that its project root and state dir still exist. Tests shrink this. */
|
|
239
250
|
orphanWatchMs?: number;
|
|
251
|
+
/** Deterministic task-sweep time; the production default is Date.now. */
|
|
252
|
+
taskSweepNow?: () => number;
|
|
240
253
|
}
|
|
241
254
|
|
|
242
255
|
interface Client {
|
|
@@ -250,11 +263,15 @@ type Sock = ServerWebSocket<Client>;
|
|
|
250
263
|
/** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
|
|
251
264
|
class WsPeer extends BasePeer {
|
|
252
265
|
private generation = crypto.randomUUID();
|
|
266
|
+
// Checkpoints become stale at hello/claim, before asynchronous recall finishes and attach changes delivery generation.
|
|
267
|
+
private claimGeneration = crypto.randomUUID();
|
|
268
|
+
get sessionGeneration(): string { return this.claimGeneration; }
|
|
253
269
|
private delivered = new Set<string>();
|
|
254
270
|
private sock: Sock | undefined;
|
|
255
271
|
private claimed: Sock | undefined;
|
|
256
272
|
/** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
|
|
257
273
|
claim(sock: Sock): void {
|
|
274
|
+
this.claimGeneration = crypto.randomUUID();
|
|
258
275
|
this.claimed = sock;
|
|
259
276
|
}
|
|
260
277
|
/** A hello that has not finished its preface yet. The peer reads as offline until then, and a session standing by
|
|
@@ -557,6 +574,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
557
574
|
triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
|
|
558
575
|
quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
|
|
559
576
|
review: config.review,
|
|
577
|
+
sweep: taskSweepConfig(config.task_sweep),
|
|
578
|
+
sweepHeld: () => stopping || recoveryActive(),
|
|
560
579
|
roles: config.roles,
|
|
561
580
|
turnFree,
|
|
562
581
|
capable: (peer) => capable.has(peer),
|
|
@@ -744,36 +763,61 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
744
763
|
if (turn) turnTasks.set(turn.id, [...new Set([...(turnTasks.get(turn.id) ?? []), t.id])]);
|
|
745
764
|
};
|
|
746
765
|
// ---- budget relay -------------------------------------------------------------------------------------------
|
|
747
|
-
const checkpointWaits = new Map<PeerId, (summary: string | undefined) => void>();
|
|
766
|
+
const checkpointWaits = new Map<PeerId, { reason: "quota" | "context"; requestId: string; session: string | undefined; valid: () => boolean; done: (summary: string | undefined) => void }>();
|
|
767
|
+
const contextSession = (peer: PeerId): string | undefined => {
|
|
768
|
+
const p = bus.peers.get(peer);
|
|
769
|
+
if (p instanceof WsPeer && p.claiming) return undefined;
|
|
770
|
+
if (peer === "claude") { const c = claudeSession(); return c.sessionId ? `${c.sessionId}:${c.launchId ?? ""}` : undefined; }
|
|
771
|
+
if (p instanceof CodexPeer) return p.thread || undefined;
|
|
772
|
+
if (p instanceof AcpPeer || p instanceof PiPeer) { const id = p.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
|
|
773
|
+
return undefined;
|
|
774
|
+
};
|
|
775
|
+
const contextPii = (peer: PeerId) => board.list().some(t => t.state !== "approved" && (t.owner === peer || t.reviewer === peer) && tasks.isPii(t));
|
|
776
|
+
const requestCheckpoint = (peer: PeerId, reason: "quota" | "context"): Promise<string | undefined> => {
|
|
777
|
+
// One outstanding request per peer. A quota transition may proceed without a summary rather than steal a context waiter.
|
|
778
|
+
if (checkpointWaits.has(peer) || recoveryActive() || stopping) return Promise.resolve(undefined);
|
|
779
|
+
const owner = bus.peers.get(peer);
|
|
780
|
+
const state = bus.stateOf(peer);
|
|
781
|
+
const session = contextSession(peer);
|
|
782
|
+
const generation = owner instanceof WsPeer || owner instanceof CodexPeer ? owner.sessionGeneration : undefined;
|
|
783
|
+
if (!owner || (owner instanceof WsPeer && owner.claiming) || (state !== "idle" && state !== "busy") || (reason === "context" && (!session || turns.get(peer)?.private || contextPii(peer)))) return Promise.resolve(undefined);
|
|
784
|
+
const openWork = board.list().some((t) => (["proposed", "in_progress", "changes_requested", "in_review"].includes(t.state) && t.owner === peer) || (t.state === "in_review" && t.reviewer === peer));
|
|
785
|
+
if (!openWork && !bus.queued(peer) && !bus.hasInFlight(peer) && state !== "busy") {
|
|
786
|
+
if (reason === "quota") log(`budget ${peer}: no open work, no checkpoint`);
|
|
787
|
+
return Promise.resolve(undefined);
|
|
788
|
+
}
|
|
789
|
+
const requestId = randomUUID();
|
|
790
|
+
const body = reason === "quota"
|
|
791
|
+
? "Checkpoint request: your quota window is almost used up and the hub is about to pause you. Finish the step you are on, write what you were doing, what is half done and what whoever continues must know to .agenthub/checkpoint.md, then call hub_checkpoint {summary} with the same text. Your open tasks will be handed to another peer; you will be resumed when the window resets."
|
|
792
|
+
: `Checkpoint request: your native context window is near the configured threshold. Finish the step you are on and call hub_checkpoint {summary, request_id: "${requestId}"} with what you were doing, what is half done and what a fresh session would need. This only saves a checkpoint; keep your tasks and session. The operator decides whether to continue or restart. Do not include private task text.`;
|
|
793
|
+
const ask = newEnvelope(HUB, body, { to: [peer], kind: "budget", priority: "important" });
|
|
794
|
+
return new Promise((resolve) => {
|
|
795
|
+
const valid = () => !stopping && !recoveryActive() && bus.peers.get(peer) === owner && (!(owner instanceof WsPeer || owner instanceof CodexPeer) || owner.sessionGeneration === generation) && ["idle", "busy"].includes(bus.stateOf(peer)) && contextSession(peer) === session;
|
|
796
|
+
const done = (summary: string | undefined) => {
|
|
797
|
+
clearTimeout(timer);
|
|
798
|
+
checkpointWaits.delete(peer);
|
|
799
|
+
if (summary === undefined) bus.withdraw(ask.id);
|
|
800
|
+
resolve(summary);
|
|
801
|
+
};
|
|
802
|
+
const timer = setTimeout(() => done(undefined), config.budget.checkpoint_timeout_s * 1000);
|
|
803
|
+
checkpointWaits.set(peer, { reason, requestId, session, valid, done });
|
|
804
|
+
bus.publish(ask);
|
|
805
|
+
});
|
|
806
|
+
};
|
|
807
|
+
const contexts = new ContextWindows(config.context, (peer, reading) => {
|
|
808
|
+
if (!["idle", "busy"].includes(bus.stateOf(peer)) || recoveryActive() || stopping) return false;
|
|
809
|
+
event({ type: "context_pressure", peer, source: reading.source, measuredAt: reading.measuredAt, used: reading.used!, window: reading.window });
|
|
810
|
+
notify(`context: ${peer} crossed ${Math.round(config.context.gate * 100)}% (${reading.source}, measured ${new Date(reading.measuredAt).toISOString()}); the operator controls session continuation`);
|
|
811
|
+
if (peer === "claude" || peer === "codex") void requestCheckpoint(peer, "context").catch(() => {});
|
|
812
|
+
return true;
|
|
813
|
+
});
|
|
748
814
|
const PLATFORM: Record<PeerId, string> = { claude: "claude", codex: "codex", kimi: "kimi" };
|
|
749
815
|
const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, {
|
|
750
816
|
pause: (peer) => bus.pause(peer),
|
|
751
817
|
resume: (peer) => {
|
|
752
818
|
if (!manualPaused.has(peer)) bus.resume(peer);
|
|
753
819
|
},
|
|
754
|
-
requestCheckpoint: (peer) =>
|
|
755
|
-
const state = bus.stateOf(peer);
|
|
756
|
-
if (state !== "idle" && state !== "busy") return Promise.resolve(undefined); // nobody there to answer
|
|
757
|
-
const openWork = board.list().some((t) => (["proposed", "in_progress", "changes_requested", "in_review"].includes(t.state) && t.owner === peer) || (t.state === "in_review" && t.reviewer === peer));
|
|
758
|
-
if (!openWork && !bus.queued(peer) && !bus.hasInFlight(peer) && state !== "busy") {
|
|
759
|
-
log(`budget ${peer}: no open work, no checkpoint`);
|
|
760
|
-
return Promise.resolve(undefined);
|
|
761
|
-
}
|
|
762
|
-
const ask =
|
|
763
|
-
newEnvelope(HUB, "Checkpoint request: your quota window is almost used up and the hub is about to pause you. Finish the step you are on, write what you were doing, what is half done and what whoever continues must know to .agenthub/checkpoint.md, then call hub_checkpoint {summary} with the same text. Your open tasks will be handed to another peer; you will be resumed when the window resets.", { to: [peer], kind: "budget", priority: "important" });
|
|
764
|
-
bus.publish(ask);
|
|
765
|
-
return new Promise((resolve) => {
|
|
766
|
-
const timer = setTimeout(() => done(undefined), config.budget.checkpoint_timeout_s * 1000);
|
|
767
|
-
const done = (summary: string | undefined) => {
|
|
768
|
-
clearTimeout(timer);
|
|
769
|
-
checkpointWaits.delete(peer);
|
|
770
|
-
// A busy peer may never have seen the request: left in its queue it would arrive after the resume, asking for a checkpoint of nothing.
|
|
771
|
-
if (summary === undefined) bus.withdraw(ask.id);
|
|
772
|
-
resolve(summary);
|
|
773
|
-
};
|
|
774
|
-
checkpointWaits.set(peer, done);
|
|
775
|
-
});
|
|
776
|
-
},
|
|
820
|
+
requestCheckpoint: (peer) => requestCheckpoint(peer, "quota"),
|
|
777
821
|
platformContext: async (peer) => {
|
|
778
822
|
const platform = PLATFORM[peer];
|
|
779
823
|
if (!config.memory.enabled || !platform) return undefined;
|
|
@@ -844,7 +888,32 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
844
888
|
};
|
|
845
889
|
// Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
|
|
846
890
|
let claudeUsageSeen = 0;
|
|
891
|
+
let claudeContextSeen = "";
|
|
892
|
+
const contextTailSeen = new Map<PeerId, string>();
|
|
847
893
|
const intervals = [
|
|
894
|
+
setInterval(() => {
|
|
895
|
+
for (const pending of checkpointWaits.values()) if (!pending.valid()) pending.done(undefined);
|
|
896
|
+
for (const peer of bus.knownPeers()) {
|
|
897
|
+
contexts.retry(peer, contextSession(peer));
|
|
898
|
+
const reading = contexts.view(peer, contextSession(peer), bus.peers.has(peer) && ["idle", "busy", "paused"].includes(bus.stateOf(peer)));
|
|
899
|
+
const stamp = JSON.stringify(reading);
|
|
900
|
+
if (contextTailSeen.get(peer) !== stamp) {
|
|
901
|
+
contextTailSeen.set(peer, stamp);
|
|
902
|
+
for (const c of consoles) if (c.data.tail) c.send(JSON.stringify({ t: "context", peer, reading }));
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
try {
|
|
906
|
+
const file = join(opts.stateDir, "claude-context.json");
|
|
907
|
+
const payload = readFileSync(file, "utf8");
|
|
908
|
+
if (payload === claudeContextSeen) return;
|
|
909
|
+
const c = JSON.parse(payload);
|
|
910
|
+
const session = claudeSession();
|
|
911
|
+
if (c.instanceId !== instanceId || !session.sessionId || c.sessionId !== session.sessionId || c.launchId !== session.launchId || !["idle", "busy", "paused"].includes(bus.stateOf("claude"))) return;
|
|
912
|
+
claudeContextSeen = payload;
|
|
913
|
+
const bound = contextSession("claude");
|
|
914
|
+
if (bound) contexts.report("claude", claudeContext(c.context, bound, c.at), bound);
|
|
915
|
+
} catch { /* absent or unreadable native telemetry is unknown */ }
|
|
916
|
+
}, 1000),
|
|
848
917
|
setInterval(() => budget.tick(), 30_000),
|
|
849
918
|
// A verified context path whose hooks stopped altogether has no boundary left to notice it at (issue #108).
|
|
850
919
|
setInterval(() => { for (const peer of [...capable]) checkCapability(peer); }, 30_000),
|
|
@@ -862,6 +931,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
862
931
|
}
|
|
863
932
|
}, 5_000),
|
|
864
933
|
];
|
|
934
|
+
const taskSweep = taskSweepConfig(config.task_sweep);
|
|
935
|
+
if (taskSweep.enabled) intervals.push(setInterval(() => {
|
|
936
|
+
void tasks.sweep(opts.taskSweepNow?.() ?? Date.now()).catch(() => notify("task idle sweep failed; inspect hub task history before manual action"));
|
|
937
|
+
}, taskSweep.interval_s * 1000));
|
|
865
938
|
for (const i of intervals) i.unref?.();
|
|
866
939
|
startupCleanup.push(() => { for (const i of intervals) clearInterval(i); });
|
|
867
940
|
|
|
@@ -960,8 +1033,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
960
1033
|
case "hub_checkpoint": {
|
|
961
1034
|
const summary = String(a.summary ?? "").trim();
|
|
962
1035
|
if (!summary) throw new Error("summary is required");
|
|
1036
|
+
const pending = checkpointWaits.get(by);
|
|
1037
|
+
if (!pending || !pending.valid()) { pending?.done(undefined); throw new Error("checkpoint request expired or its peer/session changed"); }
|
|
1038
|
+
if (pending.reason === "context") {
|
|
1039
|
+
if (a.request_id !== pending.requestId) throw new Error("context checkpoint request_id does not match");
|
|
1040
|
+
if (piiTurn || turns.get(by)?.private || contextPii(by) || !tasks.nameable(summary)) { pending.done(undefined); throw new Error("private context checkpoints are not saved or shared"); }
|
|
1041
|
+
// No broadcast, body-bearing notice or quota record. Memory's cloud observer is allowed only after the PII/session fences.
|
|
1042
|
+
const file = join(opts.stateDir, `context-checkpoint-${by}.json`);
|
|
1043
|
+
writeFileSync(`${file}.tmp`, JSON.stringify({ at: Date.now(), instanceId, session: pending.session, summary }), { mode: 0o600 });
|
|
1044
|
+
chmodSync(`${file}.tmp`, 0o600);
|
|
1045
|
+
renameSync(`${file}.tmp`, file);
|
|
1046
|
+
pending.done(summary);
|
|
1047
|
+
if (config.memory.enabled) await memory.save({ text: summary, title: `agent-hub ${by} context checkpoint`, project: opts.cwd, metadata: { peer: by, kind: "handover" } }).catch(() => undefined);
|
|
1048
|
+
notify(`context: ${by} checkpoint saved locally; no tasks or session changed`);
|
|
1049
|
+
return "context checkpoint saved locally; keep your tasks and session, the operator decides the next step";
|
|
1050
|
+
}
|
|
1051
|
+
if (a.request_id && a.request_id !== pending.requestId) throw new Error("checkpoint request_id does not match");
|
|
963
1052
|
budget.checkpointed(by, summary);
|
|
964
|
-
|
|
1053
|
+
pending.done(summary);
|
|
965
1054
|
return "checkpoint received; you will be paused now and resumed when your window resets";
|
|
966
1055
|
}
|
|
967
1056
|
case "hub_task_list":
|
|
@@ -1046,7 +1135,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1046
1135
|
return true;
|
|
1047
1136
|
});
|
|
1048
1137
|
};
|
|
1049
|
-
const claudeSession = (): { sessionId?: string; transcriptPath?: string } => {
|
|
1138
|
+
const claudeSession = (): { sessionId?: string; transcriptPath?: string; launchId?: string } => {
|
|
1050
1139
|
try {
|
|
1051
1140
|
const value = JSON.parse(readFileSync(join(opts.stateDir, "claude-session.json"), "utf8"));
|
|
1052
1141
|
if (value.instanceId !== instanceId) return {};
|
|
@@ -1056,6 +1145,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1056
1145
|
if (current?.launchId && value.launchId !== current.launchId) return {};
|
|
1057
1146
|
} catch { /* no managed terminal record: the instance fence is still enforced */ }
|
|
1058
1147
|
return {
|
|
1148
|
+
...(typeof value.launchId === "string" ? { launchId: value.launchId } : {}),
|
|
1059
1149
|
...(typeof value.sessionId === "string" && value.sessionId ? { sessionId: value.sessionId } : {}),
|
|
1060
1150
|
...(typeof value.transcriptPath === "string" && value.transcriptPath ? { transcriptPath: value.transcriptPath } : {}),
|
|
1061
1151
|
};
|
|
@@ -1176,7 +1266,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1176
1266
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
1177
1267
|
codexProxyPort: opts.codexProxyPort,
|
|
1178
1268
|
peers: Object.fromEntries(
|
|
1179
|
-
bus.knownPeers().map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); return [id, { state: bus.stateOf(id), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]; }),
|
|
1269
|
+
bus.knownPeers().map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...pausedNote(id), ...(p instanceof LocalPeer && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(p instanceof WsPeer && p.claiming ? { claiming: true } : {}), ...(p instanceof PiPeer ? { requestedModel: p.getRequestedModel(), backends: modelRelay?.status().backends ?? [] } : {}) }]; }),
|
|
1180
1270
|
),
|
|
1181
1271
|
...(sidecar ? { switchyard: sidecar.status } : {}),
|
|
1182
1272
|
...(modelRelay ? { models: modelRelay.status() } : {}),
|
|
@@ -1585,8 +1675,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1585
1675
|
}
|
|
1586
1676
|
if (peer !== "local") await existing?.stop();
|
|
1587
1677
|
if (peer === "kimi") {
|
|
1588
|
-
const
|
|
1589
|
-
const cmd =
|
|
1678
|
+
const launch = buildKimiLaunch(config.kimi_cmd, args.model);
|
|
1679
|
+
const cmd = [launch.cmd, ...launch.args];
|
|
1590
1680
|
const kimi = new AcpPeer("kimi", {
|
|
1591
1681
|
cmd,
|
|
1592
1682
|
...(args.model ? { launchModel: args.model } : {}),
|
|
@@ -1614,6 +1704,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1614
1704
|
let codexSteering = false;
|
|
1615
1705
|
const codex = new CodexPeer("codex", {
|
|
1616
1706
|
onTokens: (added) => void addTokens("codex", added),
|
|
1707
|
+
onContext: (reading) => contexts.report("codex", reading, contextSession("codex")),
|
|
1617
1708
|
onTurn: (native) => {
|
|
1618
1709
|
const open = turns.get("codex");
|
|
1619
1710
|
if (open) turnLog?.native(open.id, native);
|
|
@@ -1912,6 +2003,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1912
2003
|
}
|
|
1913
2004
|
|
|
1914
2005
|
function uiSnapshot(after: number) {
|
|
2006
|
+
const quota = budget.status();
|
|
2007
|
+
const peers = [...new Set([...Object.keys(quota), ...bus.knownPeers()])];
|
|
1915
2008
|
return {
|
|
1916
2009
|
ok: true,
|
|
1917
2010
|
projectId,
|
|
@@ -1922,7 +2015,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1922
2015
|
// Refs and history can themselves quote private text. The dashboard needs neither.
|
|
1923
2016
|
return { id: task.id, title: view.title, detail: view.detail, class: task.class, state: task.state, owner: task.owner, reviewer: task.reviewer };
|
|
1924
2017
|
}),
|
|
1925
|
-
budget:
|
|
2018
|
+
budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[id] ?? { windows: [] }), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
|
|
1926
2019
|
permissions: [...permissions.values()].map(({ push }) => {
|
|
1927
2020
|
const req = JSON.parse(push);
|
|
1928
2021
|
// Local tool titles quote file contents and commands, including those of PII turns.
|
|
@@ -2282,6 +2375,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2282
2375
|
if (c.role !== "console" || c.tail) return;
|
|
2283
2376
|
c.tail = bus.tap((e) => sock.send(JSON.stringify({ t: "event", e: redact(e) })));
|
|
2284
2377
|
for (const p of permissions.values()) sock.send(p.push);
|
|
2378
|
+
for (const peer of bus.knownPeers()) sock.send(JSON.stringify({ t: "context", peer, reading: contexts.view(peer, contextSession(peer), bus.peers.has(peer) && ["idle", "busy", "paused"].includes(bus.stateOf(peer))) }));
|
|
2285
2379
|
return;
|
|
2286
2380
|
case "ui":
|
|
2287
2381
|
if (c.role !== "console") return void reply({ t: "ui", ok: false, error: "ui is a console command" });
|
|
@@ -2410,7 +2504,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2410
2504
|
writeStatus();
|
|
2411
2505
|
dashboard?.stop();
|
|
2412
2506
|
for (const i of intervals) clearInterval(i);
|
|
2413
|
-
for (const
|
|
2507
|
+
for (const pending of checkpointWaits.values()) pending.done(undefined);
|
|
2414
2508
|
for (const p of permissions.values()) p.done(undefined);
|
|
2415
2509
|
try {
|
|
2416
2510
|
// A peer can still be inside memory recall or its native handshake when kill arrives.
|
package/src/hub/events.ts
CHANGED
|
@@ -32,6 +32,7 @@ export type HubEvent =
|
|
|
32
32
|
| { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean }
|
|
33
33
|
| { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
|
|
34
34
|
| { type: "conflict"; peer: string; task?: number; other: number; owner: string; paths: string[]; concurrent: boolean; turns?: [string, string] }
|
|
35
|
+
| { type: "context_pressure"; peer: string; source: string; measuredAt: number; used: number; window: number | null }
|
|
35
36
|
| { type: "quota"; peer: string; windows: { id: string; used: number; resetsAt?: number }[]; hard: boolean; measuredAt?: string };
|
|
36
37
|
|
|
37
38
|
export type StampedEvent = HubEvent & { v: number; at: string };
|