@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.
@@ -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
+ }
@@ -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-13 source is required");
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 = 13;
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
- checkpointWaits.get(by)?.(summary);
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 [bin, ...rest] = config.kimi_cmd;
1589
- const cmd = args.model ? [bin!, "--model", args.model, ...rest] : config.kimi_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: budget.status(),
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 done of checkpointWaits.values()) done(undefined);
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 };