@borgee/agents-host 0.2.31 → 0.2.32
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/README.md +64 -0
- package/dist/cli-args.d.ts +3 -1
- package/dist/cli-args.js +16 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +12 -1
- package/dist/context/prompt.js +1 -1
- package/dist/gateway/localhost-gateway.js +29 -1
- package/dist/index.js +4 -1
- package/dist/policy/authorization-audit.d.ts +1 -0
- package/dist/policy/gateway-authorization.d.ts +1 -1
- package/dist/policy/gateway-authorization.js +27 -4
- package/dist/state-paths.d.ts +5 -0
- package/dist/state-paths.js +12 -0
- package/dist/update/package-installation.d.ts +52 -0
- package/dist/update/package-installation.js +90 -0
- package/dist/update/package-manager.d.ts +33 -0
- package/dist/update/package-manager.js +137 -0
- package/dist/update/semantic-version.d.ts +7 -0
- package/dist/update/semantic-version.js +76 -0
- package/dist/update/update-command.d.ts +9 -0
- package/dist/update/update-command.js +55 -0
- package/dist/update/update-notice.d.ts +39 -0
- package/dist/update/update-notice.js +161 -0
- package/package.json +3 -1
- package/skills/borgee-agent/SKILL.md +5 -3
- package/skills/borgee-agent/borgee-agent.mjs +54 -21
- package/skills/borgee-agent/borgee-agent.py +53 -26
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import spawn from 'cross-spawn';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { isSemanticVersion } from './semantic-version.js';
|
|
4
|
+
export const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'];
|
|
5
|
+
const MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024;
|
|
6
|
+
export class PackageManagerAbsentError extends Error {
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Runs a package-manager command and returns its stdout. stderr is dropped:
|
|
10
|
+
* every manager writes advisory noise there (pnpm's npmrc warnings, npm's
|
|
11
|
+
* update banner), and none of it is part of the answer.
|
|
12
|
+
*
|
|
13
|
+
* The deadline kills the child rather than merely rejecting. That is the whole
|
|
14
|
+
* reason the registry is reached through a subprocess: a resolver that swallows
|
|
15
|
+
* DNS queries hangs inside `getaddrinfo`, which has no cancellation, so the
|
|
16
|
+
* same lookup done in-process would keep this process alive long past its
|
|
17
|
+
* budget. A killed child takes its hung syscall with it.
|
|
18
|
+
*/
|
|
19
|
+
function runCommandProcess(command, args, timeoutMs) {
|
|
20
|
+
return new Promise((resolveOutput, rejectCommand) => {
|
|
21
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
22
|
+
let stdout = '';
|
|
23
|
+
let bytes = 0;
|
|
24
|
+
let timedOut = false;
|
|
25
|
+
const deadline = setTimeout(() => {
|
|
26
|
+
timedOut = true;
|
|
27
|
+
child.kill('SIGKILL');
|
|
28
|
+
}, timeoutMs);
|
|
29
|
+
child.stdout?.setEncoding('utf8');
|
|
30
|
+
child.stdout?.on('data', (chunk) => {
|
|
31
|
+
bytes += Buffer.byteLength(chunk, 'utf8');
|
|
32
|
+
if (bytes > MAX_COMMAND_OUTPUT_BYTES) {
|
|
33
|
+
child.kill('SIGKILL');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
stdout += chunk;
|
|
37
|
+
});
|
|
38
|
+
child.on('error', (error) => {
|
|
39
|
+
clearTimeout(deadline);
|
|
40
|
+
if (error.code === 'ENOENT') {
|
|
41
|
+
rejectCommand(new PackageManagerAbsentError(`${command} is not on PATH`));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
rejectCommand(error);
|
|
45
|
+
});
|
|
46
|
+
child.on('close', (code) => {
|
|
47
|
+
clearTimeout(deadline);
|
|
48
|
+
if (timedOut) {
|
|
49
|
+
rejectCommand(new Error(`${command} ${args.join(' ')} exceeded its ${timeoutMs}ms deadline`));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (code !== 0) {
|
|
53
|
+
rejectCommand(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
resolveOutput(stdout.trim());
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* How each manager reports the directory its global installs land in. Verified
|
|
62
|
+
* against the real CLIs rather than inferred from paths, because every
|
|
63
|
+
* path-shaped rule we tried missed at least one layout: pnpm links a dependency
|
|
64
|
+
* out of a virtual store, yarn v1 capitalises `%LOCALAPPDATA%\Yarn\Data` on
|
|
65
|
+
* Windows, and bun relocates everything under `BUN_INSTALL`.
|
|
66
|
+
*
|
|
67
|
+
* `npm root -g` and `pnpm root -g` already name the `node_modules` directory.
|
|
68
|
+
* `yarn global dir` names its parent. `bun pm bin -g` names `<BUN_INSTALL>/bin`,
|
|
69
|
+
* whose sibling `install/global` is the root bun installs into.
|
|
70
|
+
*/
|
|
71
|
+
const GLOBAL_ROOT_QUERIES = {
|
|
72
|
+
npm: { args: ['root', '-g'], toRootDir: (output) => output },
|
|
73
|
+
pnpm: { args: ['root', '-g'], toRootDir: (output) => output },
|
|
74
|
+
yarn: { args: ['global', 'dir'], toRootDir: (output) => join(output, 'node_modules') },
|
|
75
|
+
bun: {
|
|
76
|
+
args: ['pm', 'bin', '-g'],
|
|
77
|
+
toRootDir: (output) => join(dirname(output), 'install', 'global', 'node_modules'),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
const GLOBAL_INSTALL_ARGV = {
|
|
81
|
+
npm: ['install', '-g'],
|
|
82
|
+
pnpm: ['add', '-g'],
|
|
83
|
+
yarn: ['global', 'add'],
|
|
84
|
+
bun: ['add', '-g'],
|
|
85
|
+
};
|
|
86
|
+
export function buildGlobalInstallArgv(packageManager, packageSpec) {
|
|
87
|
+
return { command: packageManager, args: [...GLOBAL_INSTALL_ARGV[packageManager], packageSpec] };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Where `packageManager` puts its global installs, or `null` when that manager
|
|
91
|
+
* is not installed on this machine.
|
|
92
|
+
*/
|
|
93
|
+
export async function readGlobalRootDir(packageManager, timeoutMs, deps = {}) {
|
|
94
|
+
const runCommand = deps.runCommand ?? runCommandProcess;
|
|
95
|
+
const query = GLOBAL_ROOT_QUERIES[packageManager];
|
|
96
|
+
try {
|
|
97
|
+
const output = await runCommand(packageManager, [...query.args], timeoutMs);
|
|
98
|
+
return output.length > 0 ? query.toRootDir(output) : null;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error instanceof PackageManagerAbsentError) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The registry npm resolves for this working directory. Asking npm rather than
|
|
109
|
+
* reading `npm_config_registry` ourselves is what makes project-level and user
|
|
110
|
+
* `.npmrc` files count, which is also where a private registry's credentials
|
|
111
|
+
* live.
|
|
112
|
+
*/
|
|
113
|
+
export async function readConfiguredRegistry(timeoutMs, deps = {}) {
|
|
114
|
+
const runCommand = deps.runCommand ?? runCommandProcess;
|
|
115
|
+
return runCommand('npm', ['config', 'get', 'registry'], timeoutMs);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Newest published release of `packageName`, as npm resolves it.
|
|
119
|
+
*
|
|
120
|
+
* `npm view` rather than a hand-rolled request to the registry: npm is the
|
|
121
|
+
* reference implementation of everything around that request — `.npmrc`
|
|
122
|
+
* discovery, scoped auth tokens, proxies, custom CAs, retries, mirrors — and
|
|
123
|
+
* all four supported managers publish to and read from the same registry, so
|
|
124
|
+
* the answer does not depend on which one owns the install.
|
|
125
|
+
*/
|
|
126
|
+
export async function readPublishedVersion(packageName, timeoutMs, deps = {}) {
|
|
127
|
+
const runCommand = deps.runCommand ?? runCommandProcess;
|
|
128
|
+
const output = await runCommand('npm', ['view', packageName, 'version', '--json'], timeoutMs);
|
|
129
|
+
const parsed = JSON.parse(output);
|
|
130
|
+
// Whatever comes back here ends up as an npm install spec, so anything that
|
|
131
|
+
// is not a plain version — a git URL, a tarball URL, a dist-tag — would let a
|
|
132
|
+
// hostile registry point the install at a source npm never vetted.
|
|
133
|
+
if (typeof parsed !== 'string' || !isSemanticVersion(parsed)) {
|
|
134
|
+
throw new Error(`npm view reported no semantic version for ${packageName}`);
|
|
135
|
+
}
|
|
136
|
+
return parsed.trim();
|
|
137
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic-version precedence for update decisions. Implemented here rather
|
|
3
|
+
* than pulled in as a dependency because the published CLI needs exactly one
|
|
4
|
+
* comparison, and precedence is fully specified by https://semver.org/#spec-item-11.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isSemanticVersion(value: string): boolean;
|
|
7
|
+
export declare function isNewerSemanticVersion(candidate: string, baseline: string): boolean;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic-version precedence for update decisions. Implemented here rather
|
|
3
|
+
* than pulled in as a dependency because the published CLI needs exactly one
|
|
4
|
+
* comparison, and precedence is fully specified by https://semver.org/#spec-item-11.
|
|
5
|
+
*/
|
|
6
|
+
const SEMANTIC_VERSION_PATTERN = /^(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:-(?<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/u;
|
|
7
|
+
const NUMERIC_IDENTIFIER_PATTERN = /^(?:0|[1-9]\d*)$/u;
|
|
8
|
+
function parseSemanticVersion(value) {
|
|
9
|
+
const match = SEMANTIC_VERSION_PATTERN.exec(value.trim());
|
|
10
|
+
if (!match?.groups) {
|
|
11
|
+
throw new Error(`Not a semantic version: ${value}`);
|
|
12
|
+
}
|
|
13
|
+
const { major, minor, patch, prerelease } = match.groups;
|
|
14
|
+
return {
|
|
15
|
+
release: [major, minor, patch],
|
|
16
|
+
prerelease: prerelease === undefined ? [] : prerelease.split('.'),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function isSemanticVersion(value) {
|
|
20
|
+
return SEMANTIC_VERSION_PATTERN.test(value.trim());
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Semver puts no upper bound on numeric identifiers, so they are compared as
|
|
24
|
+
* digit strings: without leading zeroes the longer string is always the larger
|
|
25
|
+
* number. `parseInt` would silently collapse anything past 2^53.
|
|
26
|
+
*/
|
|
27
|
+
function compareNumericIdentifiers(left, right) {
|
|
28
|
+
if (left.length !== right.length) {
|
|
29
|
+
return left.length < right.length ? -1 : 1;
|
|
30
|
+
}
|
|
31
|
+
return compareStrings(left, right);
|
|
32
|
+
}
|
|
33
|
+
function compareStrings(left, right) {
|
|
34
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
35
|
+
}
|
|
36
|
+
function comparePrereleaseIdentifier(left, right) {
|
|
37
|
+
const leftIsNumeric = NUMERIC_IDENTIFIER_PATTERN.test(left);
|
|
38
|
+
const rightIsNumeric = NUMERIC_IDENTIFIER_PATTERN.test(right);
|
|
39
|
+
if (leftIsNumeric && rightIsNumeric) {
|
|
40
|
+
return compareNumericIdentifiers(left, right);
|
|
41
|
+
}
|
|
42
|
+
if (leftIsNumeric) {
|
|
43
|
+
return -1;
|
|
44
|
+
}
|
|
45
|
+
if (rightIsNumeric) {
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
return compareStrings(left, right);
|
|
49
|
+
}
|
|
50
|
+
function comparePrerelease(left, right) {
|
|
51
|
+
if (left.length === 0 || right.length === 0) {
|
|
52
|
+
// A prerelease ranks below the release it leads up to.
|
|
53
|
+
return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
|
|
54
|
+
}
|
|
55
|
+
for (let index = 0; index < Math.min(left.length, right.length); index++) {
|
|
56
|
+
const identifierOrder = comparePrereleaseIdentifier(left[index], right[index]);
|
|
57
|
+
if (identifierOrder !== 0) {
|
|
58
|
+
return identifierOrder;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return Math.sign(left.length - right.length);
|
|
62
|
+
}
|
|
63
|
+
function compareSemanticVersions(left, right) {
|
|
64
|
+
const parsedLeft = parseSemanticVersion(left);
|
|
65
|
+
const parsedRight = parseSemanticVersion(right);
|
|
66
|
+
for (let index = 0; index < parsedLeft.release.length; index++) {
|
|
67
|
+
const releaseOrder = compareNumericIdentifiers(parsedLeft.release[index], parsedRight.release[index]);
|
|
68
|
+
if (releaseOrder !== 0) {
|
|
69
|
+
return releaseOrder;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return comparePrerelease(parsedLeft.prerelease, parsedRight.prerelease);
|
|
73
|
+
}
|
|
74
|
+
export function isNewerSemanticVersion(candidate, baseline) {
|
|
75
|
+
return compareSemanticVersions(candidate, baseline) > 0;
|
|
76
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type PackageInstallation, type PackageLocation } from './package-installation.js';
|
|
2
|
+
export interface RunUpdateCommandDeps {
|
|
3
|
+
resolveLocation?: () => Promise<PackageLocation>;
|
|
4
|
+
classifyInstallation?: (location: PackageLocation) => Promise<PackageInstallation>;
|
|
5
|
+
resolveLatestVersion?: (packageName: string, timeoutMs: number) => Promise<string>;
|
|
6
|
+
runInstall?: (command: string, args: string[]) => Promise<number>;
|
|
7
|
+
logger?: Pick<Console, 'log'>;
|
|
8
|
+
}
|
|
9
|
+
export declare function runUpdateCommand(deps?: RunUpdateCommandDeps): Promise<void>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import spawn from 'cross-spawn';
|
|
2
|
+
import { classifyPackageInstallation, resolvePackageLocation, } from './package-installation.js';
|
|
3
|
+
import { buildGlobalInstallArgv, readPublishedVersion } from './package-manager.js';
|
|
4
|
+
import { isNewerSemanticVersion } from './semantic-version.js';
|
|
5
|
+
const UPDATE_LOOKUP_TIMEOUT_MS = 30000;
|
|
6
|
+
function runInstallProcess(command, args) {
|
|
7
|
+
return new Promise((resolveExitCode, rejectInstall) => {
|
|
8
|
+
const child = spawn(command, args, { stdio: 'inherit' });
|
|
9
|
+
child.on('error', rejectInstall);
|
|
10
|
+
child.on('close', (code, signal) => {
|
|
11
|
+
if (signal !== null) {
|
|
12
|
+
rejectInstall(new Error(`${command} ${args.join(' ')} was terminated by ${signal}`));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
resolveExitCode(code ?? 0);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Only a globally installed CLI owns its own version. A source checkout is
|
|
21
|
+
* updated with git, and rewriting a project's dependency behind its manifest is
|
|
22
|
+
* that project's decision, not this command's.
|
|
23
|
+
*/
|
|
24
|
+
function assertSelfUpdatableInstallation(installation) {
|
|
25
|
+
if (installation.kind === 'source-checkout') {
|
|
26
|
+
throw new Error(`${installation.packageName} is running from a source checkout at ${installation.packageRootDir}; update that checkout with git instead of the npm registry`);
|
|
27
|
+
}
|
|
28
|
+
if (installation.kind === 'project') {
|
|
29
|
+
throw new Error(`${installation.packageName} is installed as a dependency of ${installation.projectRootDir}; update it through that project's manifest instead of self-updating`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function runUpdateCommand(deps = {}) {
|
|
33
|
+
const logger = deps.logger ?? console;
|
|
34
|
+
const resolveLocation = deps.resolveLocation ?? (() => resolvePackageLocation());
|
|
35
|
+
const classifyInstallation = deps.classifyInstallation ?? ((location) => classifyPackageInstallation(location));
|
|
36
|
+
const resolveLatestVersion = deps.resolveLatestVersion ?? readPublishedVersion;
|
|
37
|
+
const runInstall = deps.runInstall ?? runInstallProcess;
|
|
38
|
+
const installation = await classifyInstallation(await resolveLocation());
|
|
39
|
+
assertSelfUpdatableInstallation(installation);
|
|
40
|
+
const latestVersion = await resolveLatestVersion(installation.packageName, UPDATE_LOOKUP_TIMEOUT_MS);
|
|
41
|
+
// Ordering rather than inequality: a machine running ahead of `latest` — a
|
|
42
|
+
// prerelease, or a release that was unpublished — must not be walked backwards.
|
|
43
|
+
if (!isNewerSemanticVersion(latestVersion, installation.version)) {
|
|
44
|
+
logger.log(`[agents-host] already on ${installation.packageName}@${installation.version}`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const { command, args } = buildGlobalInstallArgv(installation.packageManager, `${installation.packageName}@${latestVersion}`);
|
|
48
|
+
logger.log(`[agents-host] updating ${installation.packageName} ${installation.version} -> ${latestVersion}`);
|
|
49
|
+
logger.log(`[agents-host] running: ${command} ${args.join(' ')}`);
|
|
50
|
+
const exitCode = await runInstall(command, args);
|
|
51
|
+
if (exitCode !== 0) {
|
|
52
|
+
throw new Error(`${command} ${args.join(' ')} exited with code ${exitCode}`);
|
|
53
|
+
}
|
|
54
|
+
logger.log(`[agents-host] updated to ${installation.packageName}@${latestVersion}`);
|
|
55
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type LoggerLike } from '../debug.js';
|
|
2
|
+
import { type PackageInstallation, type PackageLocation } from './package-installation.js';
|
|
3
|
+
export declare const DISABLE_UPDATE_CHECK_ENV = "AGENTS_HOST_DISABLE_UPDATE_CHECK";
|
|
4
|
+
export interface AvailableUpdate {
|
|
5
|
+
installation: PackageInstallation;
|
|
6
|
+
latestVersion: string;
|
|
7
|
+
}
|
|
8
|
+
export interface UpdateCheckOptions {
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
}
|
|
11
|
+
export interface UpdateCheckDeps {
|
|
12
|
+
resolveLocation?: () => Promise<PackageLocation>;
|
|
13
|
+
classifyInstallation?: (location: PackageLocation) => Promise<PackageInstallation>;
|
|
14
|
+
readRegistry?: (timeoutMs: number) => Promise<string>;
|
|
15
|
+
readLatestVersion?: (packageName: string, timeoutMs: number) => Promise<string>;
|
|
16
|
+
readCache?: (path: string) => Promise<string>;
|
|
17
|
+
writeCache?: (path: string, contents: string) => Promise<void>;
|
|
18
|
+
now?: () => number;
|
|
19
|
+
logger?: LoggerLike;
|
|
20
|
+
}
|
|
21
|
+
export declare function isUpdateCheckDisabled(env: NodeJS.ProcessEnv): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Resolves the newest published release for the running installation, or `null`
|
|
24
|
+
* when there is nothing worth telling the operator.
|
|
25
|
+
*
|
|
26
|
+
* Source checkouts never check: their version is whatever the working tree
|
|
27
|
+
* says, and `agents-host update` is the wrong advice for a git tree. That is
|
|
28
|
+
* settled from the path alone, so `pnpm dev` never spawns a package manager.
|
|
29
|
+
* Which manager owns a real install is only asked once there is a notice to
|
|
30
|
+
* word, so a start with nothing to report never pays for that either.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveAvailableUpdate(options?: UpdateCheckOptions, deps?: UpdateCheckDeps): Promise<AvailableUpdate | null>;
|
|
33
|
+
/**
|
|
34
|
+
* Startup-path entry point. The notice is advisory, so every failure below it —
|
|
35
|
+
* offline machine, unreachable registry, unreadable cache — is logged in debug
|
|
36
|
+
* mode and then dropped: a registry outage must never keep an agent from
|
|
37
|
+
* starting. It writes to stderr so machine-readable stdout stays clean.
|
|
38
|
+
*/
|
|
39
|
+
export declare function emitUpdateNotice(options?: UpdateCheckOptions, deps?: UpdateCheckDeps): Promise<void>;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { HostLogger, resolveAgentsHostDebugMode } from '../debug.js';
|
|
4
|
+
import { resolveUpdateCheckCachePath } from '../state-paths.js';
|
|
5
|
+
import { classifyPackageInstallation, resolvePackageLocation, } from './package-installation.js';
|
|
6
|
+
import { readConfiguredRegistry, readPublishedVersion } from './package-manager.js';
|
|
7
|
+
import { isNewerSemanticVersion, isSemanticVersion } from './semantic-version.js';
|
|
8
|
+
export const DISABLE_UPDATE_CHECK_ENV = 'AGENTS_HOST_DISABLE_UPDATE_CHECK';
|
|
9
|
+
const UPDATE_CHECK_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
10
|
+
const UPDATE_CHECK_TIMEOUT_MS = 5000;
|
|
11
|
+
/**
|
|
12
|
+
* Every failure under the update check is swallowed, so `AGENTS_HOST_DEBUG=1`
|
|
13
|
+
* is the only way an operator can find out why a notice never appeared.
|
|
14
|
+
*/
|
|
15
|
+
function createUpdateCheckLogger(env, deps) {
|
|
16
|
+
return new HostLogger({ debug: resolveAgentsHostDebugMode(false, env), logger: deps.logger });
|
|
17
|
+
}
|
|
18
|
+
async function readCacheFile(path) {
|
|
19
|
+
return fs.readFile(path, 'utf8');
|
|
20
|
+
}
|
|
21
|
+
async function writeCacheFile(path, contents) {
|
|
22
|
+
await fs.mkdir(dirname(path), { recursive: true });
|
|
23
|
+
// Write-then-rename: a concurrent start never observes a half-written entry,
|
|
24
|
+
// and a symlink planted at the cache path is replaced rather than followed.
|
|
25
|
+
const tempPath = `${path}.${process.pid}.tmp`;
|
|
26
|
+
try {
|
|
27
|
+
await fs.writeFile(tempPath, contents, { encoding: 'utf8', mode: 0o600 });
|
|
28
|
+
await fs.rename(tempPath, path);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
await fs.rm(tempPath, { force: true });
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function parseCacheEntry(contents) {
|
|
36
|
+
const parsed = JSON.parse(contents);
|
|
37
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
38
|
+
throw new Error('Update check cache is not an object');
|
|
39
|
+
}
|
|
40
|
+
const { checkedAt, registry, latestVersion } = parsed;
|
|
41
|
+
if (typeof checkedAt !== 'number' || !Number.isFinite(checkedAt)) {
|
|
42
|
+
throw new Error('Update check cache carries no checkedAt timestamp');
|
|
43
|
+
}
|
|
44
|
+
if (typeof registry !== 'string' || registry.length === 0) {
|
|
45
|
+
throw new Error('Update check cache carries no registry');
|
|
46
|
+
}
|
|
47
|
+
if (latestVersion === null) {
|
|
48
|
+
return { checkedAt, registry, latestVersion: null };
|
|
49
|
+
}
|
|
50
|
+
if (typeof latestVersion !== 'string' || !isSemanticVersion(latestVersion)) {
|
|
51
|
+
throw new Error('Update check cache carries no semantic latestVersion');
|
|
52
|
+
}
|
|
53
|
+
return { checkedAt, registry, latestVersion };
|
|
54
|
+
}
|
|
55
|
+
export function isUpdateCheckDisabled(env) {
|
|
56
|
+
return env[DISABLE_UPDATE_CHECK_ENV]?.trim() === '1';
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolves the newest published release for the running installation, or `null`
|
|
60
|
+
* when there is nothing worth telling the operator.
|
|
61
|
+
*
|
|
62
|
+
* Source checkouts never check: their version is whatever the working tree
|
|
63
|
+
* says, and `agents-host update` is the wrong advice for a git tree. That is
|
|
64
|
+
* settled from the path alone, so `pnpm dev` never spawns a package manager.
|
|
65
|
+
* Which manager owns a real install is only asked once there is a notice to
|
|
66
|
+
* word, so a start with nothing to report never pays for that either.
|
|
67
|
+
*/
|
|
68
|
+
export async function resolveAvailableUpdate(options = {}, deps = {}) {
|
|
69
|
+
const env = options.env ?? process.env;
|
|
70
|
+
if (isUpdateCheckDisabled(env)) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const resolveLocation = deps.resolveLocation ?? (() => resolvePackageLocation());
|
|
74
|
+
const location = await resolveLocation();
|
|
75
|
+
if (location.nodeModulesOwnerDir === null) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const latestVersion = await resolveLatestPublishedVersion(env, location, deps);
|
|
79
|
+
if (latestVersion === null || !isNewerSemanticVersion(latestVersion, location.version)) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const classifyInstallation = deps.classifyInstallation ??
|
|
83
|
+
((candidate) => classifyPackageInstallation(candidate));
|
|
84
|
+
return { installation: await classifyInstallation(location), latestVersion };
|
|
85
|
+
}
|
|
86
|
+
async function resolveLatestPublishedVersion(env, location, deps) {
|
|
87
|
+
const now = deps.now ?? Date.now;
|
|
88
|
+
const readCache = deps.readCache ?? readCacheFile;
|
|
89
|
+
const writeCache = deps.writeCache ?? writeCacheFile;
|
|
90
|
+
const readRegistry = deps.readRegistry ?? readConfiguredRegistry;
|
|
91
|
+
const readLatestVersion = deps.readLatestVersion ?? readPublishedVersion;
|
|
92
|
+
const logger = createUpdateCheckLogger(env, deps);
|
|
93
|
+
const cachePath = resolveUpdateCheckCachePath(env);
|
|
94
|
+
const [cached, registry] = await Promise.all([
|
|
95
|
+
readCache(cachePath)
|
|
96
|
+
.then(parseCacheEntry)
|
|
97
|
+
.catch((error) => {
|
|
98
|
+
// A missing or damaged advisory cache only costs one registry request.
|
|
99
|
+
logger.debugError('update check cache unreadable', error);
|
|
100
|
+
return null;
|
|
101
|
+
}),
|
|
102
|
+
readRegistry(UPDATE_CHECK_TIMEOUT_MS),
|
|
103
|
+
]);
|
|
104
|
+
const checkedAt = now();
|
|
105
|
+
if (cached !== null && cached.registry === registry) {
|
|
106
|
+
// An entry stamped in the future — a clock that has since been corrected,
|
|
107
|
+
// or a poisoned cache — would otherwise stay "fresh" forever and pin the
|
|
108
|
+
// operator on a stale release, so only an age inside the window counts.
|
|
109
|
+
const age = checkedAt - cached.checkedAt;
|
|
110
|
+
if (age >= 0 && age < UPDATE_CHECK_CACHE_TTL_MS) {
|
|
111
|
+
return cached.latestVersion;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const persist = (latestVersion) => writeCache(cachePath, `${JSON.stringify({ checkedAt, registry, latestVersion })}\n`).catch((error) => {
|
|
115
|
+
logger.debugError('update check cache not persisted', error);
|
|
116
|
+
});
|
|
117
|
+
let latestVersion;
|
|
118
|
+
try {
|
|
119
|
+
latestVersion = await readLatestVersion(location.packageName, UPDATE_CHECK_TIMEOUT_MS);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
// Record the failed attempt: an unreachable registry must cost the deadline
|
|
123
|
+
// once per TTL, not on every single start.
|
|
124
|
+
await persist(null);
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
await persist(latestVersion);
|
|
128
|
+
return latestVersion;
|
|
129
|
+
}
|
|
130
|
+
function renderUpdateNotice(update) {
|
|
131
|
+
const { installation, latestVersion } = update;
|
|
132
|
+
const headline = `update available: ${installation.version} -> ${latestVersion}`;
|
|
133
|
+
if (installation.kind === 'project') {
|
|
134
|
+
return [
|
|
135
|
+
headline,
|
|
136
|
+
`update ${installation.packageName} in ${installation.projectRootDir} to pick it up`,
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
return [headline, 'run `agents-host update` to install it'];
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Startup-path entry point. The notice is advisory, so every failure below it —
|
|
143
|
+
* offline machine, unreachable registry, unreadable cache — is logged in debug
|
|
144
|
+
* mode and then dropped: a registry outage must never keep an agent from
|
|
145
|
+
* starting. It writes to stderr so machine-readable stdout stays clean.
|
|
146
|
+
*/
|
|
147
|
+
export async function emitUpdateNotice(options = {}, deps = {}) {
|
|
148
|
+
const logger = createUpdateCheckLogger(options.env ?? process.env, deps);
|
|
149
|
+
try {
|
|
150
|
+
const update = await resolveAvailableUpdate(options, deps);
|
|
151
|
+
if (update === null) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const line of renderUpdateNotice(update)) {
|
|
155
|
+
logger.error(line);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
logger.debugError('update check failed', error);
|
|
160
|
+
}
|
|
161
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@borgee/agents-host",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.32",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
"typecheck": "tsc --noEmit",
|
|
49
49
|
"pretest": "pnpm --filter @borgee/plugin-sdk build",
|
|
50
50
|
"test": "vitest run --testTimeout=10000",
|
|
51
|
+
"pretest:e2e": "pnpm --filter @borgee/plugin-sdk build",
|
|
52
|
+
"test:e2e": "vitest run --config vitest.e2e.config.ts --testTimeout=120000",
|
|
51
53
|
"pretypecheck": "pnpm --filter @borgee/plugin-sdk build"
|
|
52
54
|
}
|
|
53
55
|
}
|
|
@@ -23,10 +23,12 @@ Use one of the packaged local CLIs to inspect the current channel bootstrap payl
|
|
|
23
23
|
- Python auxiliary mention: `python3 ./borgee-agent.py --context /absolute/path/to/context.json --auth-path /absolute/path/to/.localhost-gateway-auth.json --turn-execution-id turn-id --send-mention user-id --body "Need review from <@user-id>"`
|
|
24
24
|
- Python auxiliary reply: `python3 ./borgee-agent.py --context /absolute/path/to/context.json --auth-path /absolute/path/to/.localhost-gateway-auth.json --turn-execution-id turn-id --send-message --body "Following up here" --reply-to message-id`
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
The same CLIs also expose the task commands. Which ones apply depends on where the turn runs, which the injected `context.json` reports through `taskAssignmentContext`:
|
|
27
27
|
|
|
28
|
-
-
|
|
29
|
-
-
|
|
28
|
+
- Parent channel (no `taskAssignmentContext.active`): `--create-task --title ...`, `--list-tasks`, `--get-task --task-id ...`, `--update-task --task-id ...`, `--read-task-history --task-id ...`
|
|
29
|
+
- Task-assignment thread (`taskAssignmentContext.active: true`): `--get-task` and `--update-task` may omit `--task-id` and resolve the current thread task through the persisted `currentTaskId` or an agents-host local fallback; `--read-task-history --task-id ...` still works for that thread's own task, while `--create-task` and `--list-tasks` stay disabled and must be run from the parent channel
|
|
30
|
+
|
|
31
|
+
`--read-task-history` reads the messages inside a task's thread and always requires an explicit `--task-id`; inside that task's own thread `--read-history` already reads the same messages, so the turn prompt offers the command in the parent channel only. It accepts the same `--limit` / `--before` / `--after` window as `--read-history` and answers the same not-found error for a task outside the current channel as for a task that does not exist.
|
|
30
32
|
|
|
31
33
|
The private draft snapshot is read-only, collaboration-scoped, and separate from ordinary public channel messages.
|
|
32
34
|
Auxiliary sends are only for short targeted escalation, reply-thread nudges, or mentions. They must not be used for the main final answer body, which still belongs to AgentsHost.
|
|
@@ -6,6 +6,11 @@ const TASK_THREAD_COLLECTION_COMMAND_ERROR
|
|
|
6
6
|
= 'Task assignment threads only support --get-task and --update-task. Create/list tasks belong to the parent channel.';
|
|
7
7
|
const TASK_THREAD_MISSING_TASK_ID_ERROR
|
|
8
8
|
= 'Task assignment thread could not resolve the current task from persisted context or local fallback. Pass --task-id explicitly.';
|
|
9
|
+
// A window value outside the IEEE-754 safe integer range does not survive this CLI: it parses
|
|
10
|
+
// the value to a number and re-stringifies it into the query string, so `1000000000000000000000`
|
|
11
|
+
// leaves as `1e+21` and the gateway's `Number.parseInt(value, 10)` reads it back as `1` — a
|
|
12
|
+
// different page, silently. Both packaged CLIs accept exactly this grammar.
|
|
13
|
+
const INTEGER_ARGUMENT_PATTERN = /^[+-]?[0-9]+$/;
|
|
9
14
|
|
|
10
15
|
function parseArgs(argv) {
|
|
11
16
|
let contextPath;
|
|
@@ -36,10 +41,13 @@ function parseArgs(argv) {
|
|
|
36
41
|
if (value == null) {
|
|
37
42
|
throw new Error(`Missing value after ${flag}`);
|
|
38
43
|
}
|
|
39
|
-
|
|
40
|
-
if (!Number.isFinite(parsed)) {
|
|
44
|
+
if (!INTEGER_ARGUMENT_PATTERN.test(value)) {
|
|
41
45
|
throw new Error(`Invalid integer for ${flag}: ${value}`);
|
|
42
46
|
}
|
|
47
|
+
const parsed = Number(value);
|
|
48
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
49
|
+
throw new Error(`Integer out of range for ${flag}: ${value}`);
|
|
50
|
+
}
|
|
43
51
|
return parsed;
|
|
44
52
|
}
|
|
45
53
|
|
|
@@ -80,6 +88,10 @@ function parseArgs(argv) {
|
|
|
80
88
|
setAction('read-history');
|
|
81
89
|
continue;
|
|
82
90
|
}
|
|
91
|
+
if (arg === '--read-task-history') {
|
|
92
|
+
setAction('read-task-history');
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
83
95
|
if (arg === '--read-draft') {
|
|
84
96
|
setAction('read-draft');
|
|
85
97
|
continue;
|
|
@@ -215,16 +227,39 @@ function ensureVisibleMentions(body, participantIds) {
|
|
|
215
227
|
);
|
|
216
228
|
}
|
|
217
229
|
|
|
218
|
-
function
|
|
219
|
-
|
|
230
|
+
function explicitTaskId(options) {
|
|
231
|
+
const taskId = typeof options.taskId === 'string' ? options.taskId.trim() : '';
|
|
232
|
+
return taskId.length > 0 ? taskId : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function missingTaskIdError(action) {
|
|
236
|
+
return new Error(`Missing required --task-id <value> for --${action}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function requireExplicitTaskId(action, options) {
|
|
240
|
+
const taskId = explicitTaskId(options);
|
|
241
|
+
if (taskId === null) {
|
|
242
|
+
throw missingTaskIdError(action);
|
|
243
|
+
}
|
|
244
|
+
return taskId;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function applyHistoryWindow(url, options) {
|
|
248
|
+
if (options.limit != null) {
|
|
249
|
+
url.searchParams.set('limit', String(options.limit));
|
|
250
|
+
}
|
|
251
|
+
if (options.before != null) {
|
|
252
|
+
url.searchParams.set('before', String(options.before));
|
|
253
|
+
}
|
|
254
|
+
if (options.after != null) {
|
|
255
|
+
url.searchParams.set('after', String(options.after));
|
|
256
|
+
}
|
|
220
257
|
}
|
|
221
258
|
|
|
222
259
|
function resolveTaskId(payload, action, options) {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (explicitTaskId) {
|
|
227
|
-
return explicitTaskId;
|
|
260
|
+
const taskId = explicitTaskId(options);
|
|
261
|
+
if (taskId !== null) {
|
|
262
|
+
return taskId;
|
|
228
263
|
}
|
|
229
264
|
if (payload.taskAssignmentContext?.active === true) {
|
|
230
265
|
const persistedTaskId = typeof payload.taskAssignmentContext.currentTaskId === 'string'
|
|
@@ -235,7 +270,7 @@ function resolveTaskId(payload, action, options) {
|
|
|
235
270
|
}
|
|
236
271
|
return null;
|
|
237
272
|
}
|
|
238
|
-
throw
|
|
273
|
+
throw missingTaskIdError(action);
|
|
239
274
|
}
|
|
240
275
|
|
|
241
276
|
function resolveGatewayRequest(payload, action, options) {
|
|
@@ -256,15 +291,13 @@ function resolveGatewayRequest(payload, action, options) {
|
|
|
256
291
|
return { url: new URL(`/v1/channels/${encodedChannelId}/me`, gateway.baseUrl), method: 'GET' };
|
|
257
292
|
case 'read-history': {
|
|
258
293
|
const url = new URL(`/v1/channels/${encodedChannelId}/history`, gateway.baseUrl);
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
url.searchParams.set('after', String(options.after));
|
|
267
|
-
}
|
|
294
|
+
applyHistoryWindow(url, options);
|
|
295
|
+
return { url, method: 'GET' };
|
|
296
|
+
}
|
|
297
|
+
case 'read-task-history': {
|
|
298
|
+
const taskId = requireExplicitTaskId(action, options);
|
|
299
|
+
const url = new URL(`/v1/tasks/${encodeURIComponent(taskId)}/history`, gateway.baseUrl);
|
|
300
|
+
applyHistoryWindow(url, options);
|
|
268
301
|
return { url, method: 'GET' };
|
|
269
302
|
}
|
|
270
303
|
case 'read-draft': {
|
|
@@ -298,7 +331,7 @@ function resolveGatewayRequest(payload, action, options) {
|
|
|
298
331
|
return { url: new URL(`/v1/channels/${encodedChannelId}/tasks`, gateway.baseUrl), method: 'GET' };
|
|
299
332
|
case 'get-task': {
|
|
300
333
|
const taskId = resolveTaskId(payload, action, options);
|
|
301
|
-
if (payload.taskAssignmentContext?.active === true &&
|
|
334
|
+
if (payload.taskAssignmentContext?.active === true && explicitTaskId(options) === null) {
|
|
302
335
|
return {
|
|
303
336
|
url: new URL(`/v1/channels/${encodedChannelId}/current-task`, gateway.baseUrl),
|
|
304
337
|
method: 'GET',
|
|
@@ -322,7 +355,7 @@ function resolveGatewayRequest(payload, action, options) {
|
|
|
322
355
|
if (Object.keys(requestBody).length === 0) {
|
|
323
356
|
throw new Error('At least one of --status, --assignee-id, --title, or --description is required for --update-task');
|
|
324
357
|
}
|
|
325
|
-
if (payload.taskAssignmentContext?.active === true &&
|
|
358
|
+
if (payload.taskAssignmentContext?.active === true && explicitTaskId(options) === null) {
|
|
326
359
|
return {
|
|
327
360
|
url: new URL(`/v1/channels/${encodedChannelId}/current-task`, gateway.baseUrl),
|
|
328
361
|
method: 'PATCH',
|