@staix/agent-hub 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +35 -8
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +18 -3
- package/docs/agent-notes/tasks.md +12 -4
- package/docs/agent-notes/tests.md +3 -1
- package/docs/dogfood.md +16 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +264 -14
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +23 -6
- package/docs/security.md +198 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
- package/docs/specs/2026-09-19-multi-project-design.md +12 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +354 -0
- package/docs/verification/2026-11-01-0.25.0.md +91 -0
- package/docs/verification/2026-11-01-0.26.0.md +54 -0
- package/docs/verified.json +45 -17
- package/package.json +4 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5294 -4797
- package/src/adapters/channel-peer.ts +4 -2
- package/src/adapters/claude-channel.ts +29 -6
- package/src/adapters/definitions/builtin-metadata.ts +6 -6
- package/src/adapters/definitions/native-cli-doctor.ts +30 -0
- package/src/adapters/definitions/qwen.ts +2 -0
- package/src/adapters/jcode.ts +3 -0
- package/src/adapters/registry-types.ts +2 -0
- package/src/adapters/registry.ts +2 -1
- package/src/cli/acon-register.ts +29 -0
- package/src/cli/conductor-recovery.ts +3 -2
- package/src/cli/handoff-args.ts +27 -0
- package/src/cli/handoff-output.ts +25 -0
- package/src/cli/help.ts +11 -8
- package/src/cli/identity.ts +7 -1
- package/src/cli/main.ts +138 -30
- package/src/cli/output.ts +8 -5
- package/src/cli/recovery-package.ts +94 -4
- package/src/cli/reset.ts +14 -4
- package/src/cli/runtime-version-warning.ts +28 -0
- package/src/cli/schedule-output.ts +8 -4
- package/src/cli/start-agents-run.ts +125 -0
- package/src/cli/study-output.ts +71 -0
- package/src/cli/study.ts +58 -0
- package/src/cli/task-output.ts +6 -0
- package/src/cli/upgrade-interactive.ts +4 -4
- package/src/cli/upgrade-runtime.ts +105 -57
- package/src/cli/upgrade.ts +90 -28
- package/src/hub/autonomy-defaults.ts +32 -7
- package/src/hub/board.ts +993 -8
- package/src/hub/budget.ts +3 -4
- package/src/hub/channel-project.ts +61 -0
- package/src/hub/child-process.ts +4 -3
- package/src/hub/conductor.ts +3 -4
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-edit-host.ts +1 -0
- package/src/hub/config-tier.ts +3 -0
- package/src/hub/config-trust.ts +10 -2
- package/src/hub/control-client.ts +39 -10
- package/src/hub/daemon.ts +326 -25
- package/src/hub/db-schema.ts +143 -0
- package/src/hub/delivery-journal.ts +3 -4
- package/src/hub/envelope.ts +2 -0
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +15 -4
- package/src/hub/folder-trigger.ts +3 -4
- package/src/hub/handoff.ts +126 -0
- package/src/hub/hub-tools.ts +12 -2
- package/src/hub/invocations.ts +3 -4
- package/src/hub/lifecycle.ts +87 -29
- package/src/hub/manager.ts +110 -87
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/orca-env.ts +19 -0
- package/src/hub/path-policy.ts +12 -8
- package/src/hub/project-runtime-state.ts +139 -0
- package/src/hub/project-runtime.ts +62 -0
- package/src/hub/project.ts +14 -5
- package/src/hub/protocol-window.ts +19 -0
- package/src/hub/provenance.ts +30 -0
- package/src/hub/recovery-store.ts +102 -12
- package/src/hub/registry.ts +5 -0
- package/src/hub/research-campaign.ts +265 -0
- package/src/hub/research-experiments.ts +474 -0
- package/src/hub/research-runner.ts +172 -0
- package/src/hub/restart.ts +35 -0
- package/src/hub/runtime-capture-prune.ts +415 -0
- package/src/hub/runtime-prune-health.ts +112 -0
- package/src/hub/schedules.ts +213 -13
- package/src/hub/sdlc-merge.ts +7 -0
- package/src/hub/sdlc-queue.ts +13 -1
- package/src/hub/session-identity.ts +49 -0
- package/src/hub/settings.ts +4 -0
- package/src/hub/snapshots.ts +3 -4
- package/src/hub/start-agents.ts +44 -0
- package/src/hub/task-approval.ts +23 -0
- package/src/hub/tasks.ts +1005 -34
- package/src/hub/ui.ts +15 -6
- package/src/hub/usage-rollup.ts +3 -4
- package/src/memory/capture.ts +2 -2
- package/src/pi/tool-receipts.ts +3 -4
- package/src/ui/index.html +5 -3
- package/src/ui/office-space.ts +118 -0
- package/src/ui/plugin.ts +293 -0
- package/templates/AGENTS.block.md +1 -0
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +4 -4
- package/templates/profiles/writing.json +1 -1
- package/templates/roles/research/analyst.md +1 -0
- package/templates/roles/research/fact-checker.md +1 -0
- package/templates/roles/research/research-lead.md +1 -0
- package/templates/roles/research/researcher.md +1 -0
- package/templates/roles/research/statistician.md +1 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
|
|
4
|
+
import type { Project } from "./registry.ts";
|
|
5
|
+
import { configRepositoryPresent } from "./config-trust.ts";
|
|
6
|
+
import { childEnv } from "./child-process.ts";
|
|
7
|
+
import { hubGitSync } from "./git.ts";
|
|
8
|
+
|
|
9
|
+
export type RuntimeProject = Pick<Project, "id" | "root" | "stateDir">;
|
|
10
|
+
export interface ProjectRuntimeSelection { version: string; root: string; digest: string; kind: "restart" | "upgrade" }
|
|
11
|
+
export interface ProjectRuntimeIdentity extends ProjectRuntimeSelection {
|
|
12
|
+
schema: 1; projectId: string; projectRoot: string; stateDir: string; instanceId: string | null;
|
|
13
|
+
}
|
|
14
|
+
export const PROJECT_RUNTIME_FILE = "runtime.json";
|
|
15
|
+
const LIMIT = 16 * 1024;
|
|
16
|
+
const version = (v: unknown): v is string => typeof v === "string" && v.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.exec(v)?.[0] === v;
|
|
17
|
+
const pathText = (v: unknown): v is string => typeof v === "string" && v.length <= 4096 && isAbsolute(v) && normalize(v) === v && !/[\x00-\x1f\x7f]/.test(v);
|
|
18
|
+
export const projectRuntimeFile = (project: RuntimeProject): string => join(project.stateDir, PROJECT_RUNTIME_FILE);
|
|
19
|
+
const invalid = (): Error => new Error("project runtime record is invalid; inspect runtime.json before starting");
|
|
20
|
+
|
|
21
|
+
export function parseProjectRuntime(project: RuntimeProject, input: unknown): ProjectRuntimeIdentity {
|
|
22
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw invalid();
|
|
23
|
+
const row = input as Record<string, unknown>;
|
|
24
|
+
if (Object.keys(row).sort().join(",") !== "digest,instanceId,kind,projectId,projectRoot,root,schema,stateDir,version"
|
|
25
|
+
|| row.schema !== 1 || row.projectId !== project.id || row.projectRoot !== project.root || row.stateDir !== project.stateDir
|
|
26
|
+
|| !version(row.version) || !pathText(row.root) || typeof row.digest !== "string" || row.digest.length !== 64 || !/^[a-f0-9]{64}$/.test(row.digest)
|
|
27
|
+
|| row.kind !== "restart" && row.kind !== "upgrade"
|
|
28
|
+
|| row.instanceId !== null && (typeof row.instanceId !== "string" || /^[a-zA-Z0-9-]{1,128}$/.exec(row.instanceId)?.[0] !== row.instanceId)) throw invalid();
|
|
29
|
+
return { schema: 1, projectId: project.id, projectRoot: project.root, stateDir: project.stateDir,
|
|
30
|
+
version: row.version, root: row.root, digest: row.digest, kind: row.kind, instanceId: row.instanceId as string | null };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ACCESS = Symbol("project runtime access");
|
|
34
|
+
export interface ProjectRuntimeAccess {
|
|
35
|
+
readonly [ACCESS]: true;
|
|
36
|
+
readonly projectId: string; readonly projectRoot: string; readonly stateDir: string;
|
|
37
|
+
read(): ProjectRuntimeIdentity | undefined;
|
|
38
|
+
write(runtime: ProjectRuntimeSelection, instanceId: string | null): ProjectRuntimeIdentity;
|
|
39
|
+
}
|
|
40
|
+
const TRUST_GIT_MS = 2000;
|
|
41
|
+
const exactGlob = (value: string) => value.replace(/([*?[\]\\])/g, "\\$1");
|
|
42
|
+
const trustRefusal = (why: string): never => { throw new Error(`project runtime selection is machine-local: ${why}`); };
|
|
43
|
+
|
|
44
|
+
/** Plain folders remain supported. A repository must affirm this file is untracked using only its parent/ancestor paths. */
|
|
45
|
+
function requireRuntimeTrust(project: RuntimeProject): void {
|
|
46
|
+
const file = projectRuntimeFile(project), checked = new Set<string>(), deadline = performance.now() + TRUST_GIT_MS;
|
|
47
|
+
const git = (args: string[]) => {
|
|
48
|
+
const remaining = deadline - performance.now();
|
|
49
|
+
if (remaining <= 0) trustRefusal("git could not confirm runtime.json is untracked");
|
|
50
|
+
return hubGitSync(args, { encoding: "utf8", env: childEnv(process.env), timeout: Math.max(1, Math.ceil(remaining)), maxBuffer: 256 * 1024 });
|
|
51
|
+
};
|
|
52
|
+
for (const cwd of new Set([project.root, dirname(file)])) {
|
|
53
|
+
if (!configRepositoryPresent(cwd)) continue;
|
|
54
|
+
let at = cwd;
|
|
55
|
+
while (!existsSync(at) && dirname(at) !== at) at = dirname(at);
|
|
56
|
+
const top = git(["-C", at, "rev-parse", "--show-toplevel"]);
|
|
57
|
+
if (top.status !== 0 || !isAbsolute(top.stdout.trim())) trustRefusal("git could not confirm runtime.json is untracked");
|
|
58
|
+
const root = top.stdout.trim(), selected = relative(root, file).split(sep).join("/");
|
|
59
|
+
if (checked.has(root) || selected === ".." || selected.startsWith("../") || isAbsolute(selected)) continue;
|
|
60
|
+
checked.add(root);
|
|
61
|
+
const parts = selected.split("/"), parent = parts.slice(0, -1).join("/");
|
|
62
|
+
// ponytail: explicit custom stateDir ancestor listings can exceed 256 KiB (default unaffected); use an untracked dedicated state directory, then upgrade to exact ancestor index lookup.
|
|
63
|
+
const specs = [`:(icase,literal)${parent || selected}`];
|
|
64
|
+
for (let i = 1; i < parts.length - 1; i++) specs.push(`:(icase,glob)${exactGlob(parts.slice(0, i).join("/"))}`);
|
|
65
|
+
const tracked = git(["-C", root, "ls-files", "-s", "-z", "--", ...specs]);
|
|
66
|
+
if (tracked.status !== 0) trustRefusal("git could not confirm runtime.json is untracked");
|
|
67
|
+
let opened: { dev: number; ino: number } | undefined;
|
|
68
|
+
try { opened = statSync(file); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") trustRefusal("runtime.json identity is unavailable"); }
|
|
69
|
+
for (const row of tracked.stdout.split("\0").filter(Boolean)) {
|
|
70
|
+
const name = row.slice(row.indexOf("\t") + 1);
|
|
71
|
+
if (name === selected || selected.startsWith(`${name}/`)) trustRefusal("runtime.json or its parent is committed to git");
|
|
72
|
+
if (opened) {
|
|
73
|
+
let info; try { info = statSync(join(root, name)); } catch { continue; }
|
|
74
|
+
if (info.dev === opened.dev && info.ino === opened.ino) trustRefusal("runtime.json is committed to git");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** This reserved sibling namespace is exclusively for captures withdrawn for pruning. */
|
|
81
|
+
export function assertExecutableRuntimeRoot(root: string): void {
|
|
82
|
+
for (let at = root; ; at = dirname(at)) {
|
|
83
|
+
if (basename(at).startsWith(".runtime-prune-")) throw new Error("runtime root is quarantined for pruning");
|
|
84
|
+
if (dirname(at) === at) return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Host-only, project-bound admission for one call; callers create it before acquiring the registry lock. */
|
|
89
|
+
export function createProjectRuntimeAccess(project: RuntimeProject): ProjectRuntimeAccess {
|
|
90
|
+
requireRuntimeTrust(project);
|
|
91
|
+
const bound = { id: project.id, root: project.root, stateDir: project.stateDir };
|
|
92
|
+
return Object.freeze({ [ACCESS]: true as const, projectId: bound.id, projectRoot: bound.root, stateDir: bound.stateDir,
|
|
93
|
+
read: () => readProjectRuntimeReference(bound), write: (runtime: ProjectRuntimeSelection, instanceId: string | null) => writeRuntimeState(bound, runtime, instanceId) });
|
|
94
|
+
}
|
|
95
|
+
function accessFor(project: RuntimeProject, access?: ProjectRuntimeAccess): ProjectRuntimeAccess {
|
|
96
|
+
if (!access) return createProjectRuntimeAccess(project);
|
|
97
|
+
if (access[ACCESS] !== true || access.projectId !== project.id || access.projectRoot !== project.root || access.stateDir !== project.stateDir)
|
|
98
|
+
throw new Error("project runtime access belongs to another project");
|
|
99
|
+
return access;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function readProjectRuntime(project: RuntimeProject, access?: ProjectRuntimeAccess): ProjectRuntimeIdentity | undefined {
|
|
103
|
+
return accessFor(project, access).read();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Metadata-only retention reference: bounded binding/JSON checks confer no Git trust or execution authority. */
|
|
107
|
+
export function readProjectRuntimeReference(project: RuntimeProject): ProjectRuntimeIdentity | undefined {
|
|
108
|
+
let fd: number;
|
|
109
|
+
try { fd = openSync(projectRuntimeFile(project), constants.O_RDONLY | constants.O_NOFOLLOW); }
|
|
110
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw invalid(); }
|
|
111
|
+
try {
|
|
112
|
+
const before = fstatSync(fd);
|
|
113
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > LIMIT) throw invalid();
|
|
114
|
+
const bytes = Buffer.alloc(LIMIT + 1);
|
|
115
|
+
const count = readSync(fd, bytes, 0, bytes.length, 0), after = fstatSync(fd);
|
|
116
|
+
if (count > LIMIT || count !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) throw invalid();
|
|
117
|
+
return parseProjectRuntime(project, JSON.parse(bytes.subarray(0, count).toString("utf8")));
|
|
118
|
+
} catch { throw invalid(); }
|
|
119
|
+
finally { closeSync(fd); }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Caller holds the shared runtime-capture lock until publication and its launch/reference effects finish. */
|
|
123
|
+
function writeRuntimeState(project: RuntimeProject, runtime: ProjectRuntimeSelection, instanceId: string | null): ProjectRuntimeIdentity {
|
|
124
|
+
const record = parseProjectRuntime(project, { schema: 1, projectId: project.id, projectRoot: project.root, stateDir: project.stateDir,
|
|
125
|
+
version: runtime.version, root: runtime.root, digest: runtime.digest, kind: runtime.kind, instanceId });
|
|
126
|
+
assertExecutableRuntimeRoot(record.root);
|
|
127
|
+
readProjectRuntimeReference(project); // refuse to overwrite an unknown binding or an unsafe file
|
|
128
|
+
const body = JSON.stringify(record);
|
|
129
|
+
if (Buffer.byteLength(body) > LIMIT) throw invalid();
|
|
130
|
+
mkdirSync(project.stateDir, { recursive: true });
|
|
131
|
+
const file = projectRuntimeFile(project), temporary = `${file}.${randomUUID()}.tmp`;
|
|
132
|
+
try { writeFileSync(temporary, body, { mode: 0o600, flag: "wx" }); renameSync(temporary, file); }
|
|
133
|
+
finally { rmSync(temporary, { force: true }); }
|
|
134
|
+
return record;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function writeProjectRuntimeState(project: RuntimeProject, runtime: ProjectRuntimeSelection, instanceId: string | null, access?: ProjectRuntimeAccess): ProjectRuntimeIdentity {
|
|
138
|
+
return accessFor(project, access).write(runtime, instanceId);
|
|
139
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { stageCurrentRuntime, verifyRecoveryTarget, newerVersion, exactVersion } from "../cli/recovery-package.ts";
|
|
3
|
+
import { hubHome, realPath } from "./project.ts";
|
|
4
|
+
import { Registry, type Project } from "./registry.ts";
|
|
5
|
+
import { processLiveness } from "../pi/process-signature.ts";
|
|
6
|
+
import { assertExecutableRuntimeRoot, createProjectRuntimeAccess, readProjectRuntime, writeProjectRuntimeState, type ProjectRuntimeAccess, type ProjectRuntimeIdentity, type ProjectRuntimeSelection } from "./project-runtime-state.ts";
|
|
7
|
+
export { readProjectRuntime } from "./project-runtime-state.ts";
|
|
8
|
+
export type { ProjectRuntimeIdentity, ProjectRuntimeSelection } from "./project-runtime-state.ts";
|
|
9
|
+
|
|
10
|
+
export const projectRuntimeRefusal = (version: string, detail?: string): Error => new Error(`this project runs ${version}; use ${version} or later / ahub upgrade${detail ? ` (${detail})` : ""}`);
|
|
11
|
+
|
|
12
|
+
export function verifyProjectRuntime(runtime: ProjectRuntimeSelection): ProjectRuntimeSelection {
|
|
13
|
+
assertExecutableRuntimeRoot(runtime.root);
|
|
14
|
+
try {
|
|
15
|
+
if (realPath(runtime.root) !== runtime.root || verifyRecoveryTarget(runtime.kind, runtime.root, runtime.version) !== runtime.digest)
|
|
16
|
+
throw new Error("runtime changed");
|
|
17
|
+
return runtime;
|
|
18
|
+
} catch { throw projectRuntimeRefusal(runtime.version, "recorded runtime is missing or changed"); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Read-only caller admission also covers an already-running daemon; no capture is staged or hashed here. */
|
|
22
|
+
export function readProjectRuntimeForCaller(project: Project, invokingVersion: string, access?: ProjectRuntimeAccess): ProjectRuntimeIdentity | undefined {
|
|
23
|
+
exactVersion(invokingVersion);
|
|
24
|
+
const retained = readProjectRuntime(project, access);
|
|
25
|
+
// ponytail: only updated resolver code honors runtime.json; bootstrap shared CLI to 0.26.0+ before relying on this floor.
|
|
26
|
+
if (retained && newerVersion(retained.version, invokingVersion)) throw projectRuntimeRefusal(retained.version);
|
|
27
|
+
return retained;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Selectors first ship in 0.26.0; unmodified earlier published CLIs cannot honor this record. */
|
|
31
|
+
export function resolveProjectRuntime(project: Project, invokingVersion: string, sourceRoot = resolve(import.meta.dir, "../.."), registry?: Registry, access?: ProjectRuntimeAccess): ProjectRuntimeSelection {
|
|
32
|
+
const retained = readProjectRuntimeForCaller(project, invokingVersion, access);
|
|
33
|
+
if (retained) return verifyProjectRuntime(retained);
|
|
34
|
+
const staged = stageCurrentRuntime(sourceRoot, invokingVersion, hubHome(), registry);
|
|
35
|
+
return { ...staged, kind: "restart" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function recordProjectRuntime(project: Project, runtime: ProjectRuntimeSelection, instanceId: string | null, registry: Registry, access?: ProjectRuntimeAccess): void {
|
|
39
|
+
const admitted = access ?? createProjectRuntimeAccess(project);
|
|
40
|
+
registry.runtimeCaptureLocked(() => {
|
|
41
|
+
const current = registry.get(project.id);
|
|
42
|
+
if (!current || current.root !== project.root || current.stateDir !== project.stateDir
|
|
43
|
+
|| instanceId !== null && current.instanceId !== instanceId
|
|
44
|
+
|| instanceId === null && current.instanceId && processLiveness(current.pid, current.pidSignature) !== "gone")
|
|
45
|
+
throw new Error("project runtime owner changed; refresh before starting");
|
|
46
|
+
verifyProjectRuntime(runtime);
|
|
47
|
+
writeProjectRuntimeState(project, runtime, instanceId, admitted);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A null instance is a pending selection, never consent to replace a later capture choice. */
|
|
52
|
+
export function confirmProjectRuntime(project: Project, runtime: ProjectRuntimeSelection, observed: { version?: unknown; instanceId?: unknown }, registry: Registry, access?: ProjectRuntimeAccess): void {
|
|
53
|
+
const admitted = access ?? createProjectRuntimeAccess(project);
|
|
54
|
+
registry.runtimeCaptureLocked(() => {
|
|
55
|
+
const saved = readProjectRuntime(project, admitted);
|
|
56
|
+
if (!saved || saved.version !== runtime.version || saved.root !== runtime.root || saved.digest !== runtime.digest || saved.kind !== runtime.kind
|
|
57
|
+
|| observed.version !== runtime.version || typeof observed.instanceId !== "string" || !observed.instanceId
|
|
58
|
+
|| saved.instanceId !== null && saved.instanceId !== observed.instanceId)
|
|
59
|
+
throw projectRuntimeRefusal(saved?.version ?? runtime.version, "runtime selection changed before readiness");
|
|
60
|
+
recordProjectRuntime(project, runtime, observed.instanceId, registry, admitted);
|
|
61
|
+
});
|
|
62
|
+
}
|
package/src/hub/project.ts
CHANGED
|
@@ -66,15 +66,24 @@ export function projectContext(cwd: string, env: NodeJS.ProcessEnv = process.env
|
|
|
66
66
|
const root = projectRoot(cwd);
|
|
67
67
|
const override = env.AGENTHUB_STATE_DIR?.trim();
|
|
68
68
|
if (!override) return { root, stateDir: canonical(join(root, ".agenthub", "state")) };
|
|
69
|
+
const inherited = inheritedStateDir(root, env);
|
|
70
|
+
return { root, stateDir: inherited ?? canonical(join(root, ".agenthub", "state")) };
|
|
71
|
+
}
|
|
69
72
|
|
|
73
|
+
/**
|
|
74
|
+
* The ownership proof for an inherited state directory, on its own so the channel plugin applies the same rule as the CLI (#604 PR B, item 10). The directory is accepted only when the root
|
|
75
|
+
* recorded in it equals `root`, or when it records none (a freshly initialized state) and the inherited `AGENTHUB_PROJECT_DIR` names `root`. A marker for another project is authoritative:
|
|
76
|
+
* an inherited pair from the main checkout must not pull a process started in a linked worktree into the main repository's hub.
|
|
77
|
+
*/
|
|
78
|
+
export function inheritedStateDir(root: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
79
|
+
const override = env.AGENTHUB_STATE_DIR?.trim();
|
|
80
|
+
if (!override) return undefined;
|
|
70
81
|
const declared = env.AGENTHUB_PROJECT_DIR?.trim();
|
|
71
82
|
const stateDir = canonical(override);
|
|
72
83
|
const recorded = stateMarker(stateDir);
|
|
73
|
-
if (recorded === root) return
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (!recorded && declared && canonical(declared) === root) return { root, stateDir };
|
|
77
|
-
return { root, stateDir: canonical(join(root, ".agenthub", "state")) };
|
|
84
|
+
if (recorded === root) return stateDir;
|
|
85
|
+
if (!recorded && declared && canonical(declared) === root) return stateDir;
|
|
86
|
+
return undefined;
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
export function hubHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Inclusive supported control-wire interval; v is its highest version. */
|
|
2
|
+
export interface ProtocolWindow { v: number; vMin: number }
|
|
3
|
+
|
|
4
|
+
/** Older peers without a minimum advertise only their one explicit version. */
|
|
5
|
+
export function protocolWindow(v: unknown, vMin?: unknown): ProtocolWindow | undefined {
|
|
6
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v <= 0) return undefined;
|
|
7
|
+
const minimum = vMin === undefined ? v : vMin;
|
|
8
|
+
if (typeof minimum !== "number" || !Number.isSafeInteger(minimum) || minimum <= 0 || minimum > v) return undefined;
|
|
9
|
+
return { v, vMin: minimum };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Choose the highest common version. Invalid intervals and disjoint ranges negotiate nothing. */
|
|
13
|
+
export function negotiateProtocol(local: ProtocolWindow, remote: ProtocolWindow): number | undefined {
|
|
14
|
+
if (!local || typeof local !== "object" || Array.isArray(local) || !remote || typeof remote !== "object" || Array.isArray(remote)) return undefined;
|
|
15
|
+
const a = protocolWindow(local.v, local.vMin), b = protocolWindow(remote.v, remote.vMin);
|
|
16
|
+
if (!a || !b) return undefined;
|
|
17
|
+
const highest = Math.min(a.v, b.v);
|
|
18
|
+
return highest >= Math.max(a.vMin, b.vMin) ? highest : undefined;
|
|
19
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Sender provenance (#604 PR B, agent-hub#1030 item 11). Pure: a connection's identity fields in, a bounded record out. The record names the sending connection by role, bound peer
|
|
2
|
+
// and launch or instance id, and nothing else: never a path, a cwd, a project root, a message body or a token. Two sessions that both claim `claude` stay distinguishable in
|
|
3
|
+
// events.jsonl because their launch ids differ, and an envelope can be traced to the connection that sent it.
|
|
4
|
+
/** `instanceId` is a declared extra instance's id when the connection is one. It is never the hub run's own id, which is the same for every connection and so distinguishes none. */
|
|
5
|
+
export interface ConnectionIdentity { role?: "peer" | "console" | "tools"; peer?: string; launchId?: string; instanceId?: string; session?: boolean }
|
|
6
|
+
/** `session: true` marks a distinct session: its `peer` is the daemon-minted `<peer>~<origin>` id (agent-hub#1036). */
|
|
7
|
+
export type Origin = { role: "peer" | "console" | "tools" | "hub"; peer?: string; launch?: string; instance?: string; session?: true };
|
|
8
|
+
|
|
9
|
+
/** An id the hub itself minted (a UUID or a short token). Anything else, or anything long, is dropped rather than copied: the field is evidence, not a channel for caller text. */
|
|
10
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
11
|
+
const idOf = (value: unknown): string | undefined => typeof value === "string" && ID.test(value) ? value : undefined;
|
|
12
|
+
const PEER = /^[a-z][a-z0-9_~-]{0,63}$/;
|
|
13
|
+
|
|
14
|
+
/** `undefined` identity is the hub's own writer (a task flow, a notice): it is `hub`, not a connection. */
|
|
15
|
+
export function originOf(identity: ConnectionIdentity | undefined): Origin {
|
|
16
|
+
if (!identity?.role) return { role: "hub" };
|
|
17
|
+
const peer = typeof identity.peer === "string" && PEER.test(identity.peer) ? identity.peer : undefined;
|
|
18
|
+
const launch = idOf(identity.launchId), instance = idOf(identity.instanceId);
|
|
19
|
+
return { role: identity.role, ...(peer ? { peer } : {}), ...(launch ? { launch } : {}), ...(instance ? { instance } : {}), ...(identity.session === true && peer ? { session: true as const } : {}) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The one `attach` event a peer or tools hello writes, and the shape an envelope event embeds as `origin`. */
|
|
23
|
+
export function attachEvent(identity: ConnectionIdentity): { type: "attach" } & Origin {
|
|
24
|
+
return { type: "attach", ...originOf(identity) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether two origins name the same connection identity: the same role, peer and launch. A connection with no launch id never equals one that has it. */
|
|
28
|
+
export function sameOrigin(a: Origin, b: Origin): boolean {
|
|
29
|
+
return a.role === b.role && a.peer === b.peer && a.launch === b.launch && a.instance === b.instance && a.session === b.session;
|
|
30
|
+
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { Database } from "bun:sqlite";
|
|
5
|
-
import { hubHome } from "./project.ts";
|
|
5
|
+
import { canonicalPath, hubHome } from "./project.ts";
|
|
6
|
+
import { isDatabaseBusy } from "./recovery-error.ts";
|
|
6
7
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
7
8
|
|
|
8
9
|
const RUNNER_DB_BUSY_TIMEOUT_MS = 3000;
|
|
9
10
|
export const operationIdPattern = /^[a-f0-9-]{36}$/;
|
|
10
11
|
export function operationPath(id: string, home = hubHome()): string {
|
|
11
|
-
if (!operationIdPattern.test(id)) throw new Error("invalid recovery operation ID");
|
|
12
|
+
if (typeof id !== "string" || id.length !== 36 || !operationIdPattern.test(id)) throw new Error("invalid recovery operation ID");
|
|
12
13
|
return join(home, "recovery", `${id}.json`);
|
|
13
14
|
}
|
|
14
15
|
|
|
@@ -31,11 +32,11 @@ export function writeOperation(id: string, value: unknown, home = hubHome()): vo
|
|
|
31
32
|
atomicPrivateJSON(operationPath(id, home), value);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
function legacyRecoveryLock(home: string): string | undefined {
|
|
35
36
|
const file = join(home, "recovery.lock");
|
|
36
37
|
try {
|
|
37
38
|
const data = JSON.parse(readFileSync(file, "utf8"));
|
|
38
|
-
if (!operationIdPattern.test(data.operationId)) throw new Error("invalid recovery lock");
|
|
39
|
+
if (typeof data.operationId !== "string" || data.operationId.length !== 36 || !operationIdPattern.test(data.operationId)) throw new Error("invalid recovery lock");
|
|
39
40
|
return data.operationId;
|
|
40
41
|
} catch (error) {
|
|
41
42
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
@@ -43,23 +44,112 @@ export function recoveryLock(home = hubHome()): string | undefined {
|
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
const projectLocksPath = (home: string) => join(home, "recovery", "project-locks.db");
|
|
48
|
+
const projectLockError = () => new Error("project recovery coordination state is unreadable; inspect it before changing runtimes");
|
|
49
|
+
function recoveryProjectKey(root: string): string {
|
|
50
|
+
if (typeof root !== "string" || !isAbsolute(root) || root.includes(String.fromCharCode(0))) throw new Error("recovery scope needs a canonical project root");
|
|
51
|
+
try { return canonicalPath(root); }
|
|
52
|
+
catch (error) {
|
|
53
|
+
// Receipts retain the already-canonical registration spelling so a removed project can still be disposed.
|
|
54
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return resolve(root);
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function projectLocksExist(file: string): boolean {
|
|
59
|
+
try {
|
|
60
|
+
const info = lstatSync(file);
|
|
61
|
+
if (!info.isFile() || info.isSymbolicLink()) throw projectLockError();
|
|
62
|
+
return true;
|
|
63
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
|
64
|
+
}
|
|
65
|
+
function projectLocks(home: string, create = false): Database | undefined {
|
|
66
|
+
const file = projectLocksPath(home);
|
|
67
|
+
if (!projectLocksExist(file)) {
|
|
68
|
+
if (!create) return undefined;
|
|
69
|
+
mkdirSync(join(home, "recovery"), { recursive: true, mode: 0o700 });
|
|
70
|
+
// Publish a complete DELETE-journal database, never an empty coordination file another process could misread.
|
|
71
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
72
|
+
try {
|
|
73
|
+
const db = new Database(temporary, { create: true });
|
|
74
|
+
try {
|
|
75
|
+
db.run(`PRAGMA busy_timeout = ${RUNNER_DB_BUSY_TIMEOUT_MS}`);
|
|
76
|
+
db.run("CREATE TABLE project_locks (project_root TEXT PRIMARY KEY, operation_id TEXT NOT NULL)");
|
|
77
|
+
} finally { db.close(); }
|
|
78
|
+
chmodSync(temporary, 0o600);
|
|
79
|
+
try { linkSync(temporary, file); }
|
|
80
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
81
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
82
|
+
}
|
|
83
|
+
if (!projectLocksExist(file)) throw projectLockError();
|
|
84
|
+
let db: Database | undefined;
|
|
85
|
+
try {
|
|
86
|
+
db = new Database(file, create ? { readwrite: true, create: false } : { readonly: true, create: false });
|
|
87
|
+
db.run(`PRAGMA busy_timeout = ${RUNNER_DB_BUSY_TIMEOUT_MS}`);
|
|
88
|
+
if (!db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_locks'").get()) throw projectLockError();
|
|
89
|
+
return db;
|
|
90
|
+
} catch (error) { db?.close(); if (isDatabaseBusy(error)) throw error; throw projectLockError(); }
|
|
91
|
+
}
|
|
92
|
+
function projectLockOn(db: Database, key: string): string | undefined {
|
|
93
|
+
const rows = db.query("SELECT CASE WHEN typeof(operation_id)='text' AND length(operation_id)=36 THEN operation_id END AS id FROM project_locks WHERE project_root=? LIMIT 2").all(key) as { id: string | null }[];
|
|
94
|
+
if (!rows.length) return undefined;
|
|
95
|
+
if (rows.length !== 1 || typeof rows[0]!.id !== "string" || !operationIdPattern.test(rows[0]!.id)) throw projectLockError();
|
|
96
|
+
return rows[0]!.id;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Legacy machine holds remain authoritative; new project holds affect only their canonical project. */
|
|
100
|
+
export function recoveryLock(home = hubHome(), projectRoot?: string): string | undefined {
|
|
101
|
+
const legacy = legacyRecoveryLock(home);
|
|
102
|
+
if (legacy || projectRoot === undefined) return legacy;
|
|
103
|
+
const key = recoveryProjectKey(projectRoot), db = projectLocks(home);
|
|
104
|
+
if (!db) return undefined;
|
|
105
|
+
try { return projectLockOn(db, key); }
|
|
106
|
+
finally { db.close(); }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function acquireRecoveryLock(id: string, home = hubHome(), projectRoot?: string): void {
|
|
47
110
|
operationPath(id, home);
|
|
111
|
+
if (projectRoot !== undefined) {
|
|
112
|
+
const key = recoveryProjectKey(projectRoot), legacy = legacyRecoveryLock(home);
|
|
113
|
+
if (legacy && legacy !== id) throw new Error(`another recovery operation owns this machine: ${legacy}`);
|
|
114
|
+
const db = projectLocks(home, true)!;
|
|
115
|
+
try {
|
|
116
|
+
db.transaction(() => {
|
|
117
|
+
const existing = projectLockOn(db, key);
|
|
118
|
+
if (existing && existing !== id) throw new Error(`another recovery operation owns this project: ${existing}`);
|
|
119
|
+
if (!existing) db.query("INSERT INTO project_locks(project_root,operation_id) VALUES(?,?)").run(key, id);
|
|
120
|
+
}).immediate();
|
|
121
|
+
} finally { db.close(); }
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
48
124
|
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
49
125
|
const existing = recoveryLock(home);
|
|
50
126
|
if (existing === id) return;
|
|
51
|
-
|
|
52
|
-
|
|
127
|
+
const temporary = join(home, `.recovery-lock-${randomUUID()}.tmp`);
|
|
128
|
+
try {
|
|
129
|
+
writeFileSync(temporary, JSON.stringify({ operationId: id }), { mode: 0o600, flag: "wx" });
|
|
130
|
+
try { linkSync(temporary, join(home, "recovery.lock")); }
|
|
131
|
+
catch { throw new Error(`another recovery operation owns this machine: ${recoveryLock(home) ?? "unknown"}`); }
|
|
132
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
53
133
|
}
|
|
54
134
|
|
|
55
|
-
export function releaseRecoveryLock(id: string, home = hubHome()): void {
|
|
135
|
+
export function releaseRecoveryLock(id: string, home = hubHome(), projectRoot?: string): void {
|
|
136
|
+
operationPath(id, home);
|
|
137
|
+
if (projectRoot !== undefined) {
|
|
138
|
+
const key = recoveryProjectKey(projectRoot), db = projectLocks(home, true)!;
|
|
139
|
+
try {
|
|
140
|
+
if (db.query("DELETE FROM project_locks WHERE project_root=? AND operation_id=?").run(key, id).changes !== 1)
|
|
141
|
+
throw new Error("recovery lock ownership changed");
|
|
142
|
+
} finally { db.close(); }
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// Legacy release is called under the operation's exclusive runner claim. Project releases compare/delete atomically.
|
|
56
146
|
if (recoveryLock(home) !== id) throw new Error("recovery lock ownership changed");
|
|
57
147
|
rmSync(join(home, "recovery.lock"));
|
|
58
148
|
}
|
|
59
149
|
|
|
60
150
|
/** Used by CLI and manager lifecycle paths. A detached operation carries its own ID. */
|
|
61
|
-
export function assertLifecycleAvailable(): void {
|
|
62
|
-
const owner = recoveryLock();
|
|
151
|
+
export function assertLifecycleAvailable(projectRoot?: string): void {
|
|
152
|
+
const owner = recoveryLock(undefined, projectRoot);
|
|
63
153
|
if (owner && process.env.AGENTHUB_RECOVERY_OPERATION !== owner) throw new Error(activeOperation(owner));
|
|
64
154
|
}
|
|
65
155
|
|
package/src/hub/registry.ts
CHANGED
|
@@ -106,6 +106,11 @@ export class Registry {
|
|
|
106
106
|
return (this.db.query(`SELECT ${PROJECT_COLUMNS}, pid_signature FROM projects ORDER BY root`).all() as Record<string, unknown>[]).map(projectFromRow);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** Serialize capture publication, durable project references and pruning with registry writers. */
|
|
110
|
+
runtimeCaptureLocked<T>(fn: () => T): T {
|
|
111
|
+
return this.db.inTransaction ? fn() : this.db.transaction(fn).immediate();
|
|
112
|
+
}
|
|
113
|
+
|
|
109
114
|
allocate(root: string): number {
|
|
110
115
|
return this.register(root).basePort;
|
|
111
116
|
}
|