@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.
@@ -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.31",
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
- When the injected `context.json` includes `taskAssignmentContext.active: true`, the same CLIs also expose the task-thread compatibility surface:
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
- - In ordinary parent channels: `--create-task`, `--list-tasks`, `--get-task --task-id ...`, `--update-task --task-id ...`
29
- - In task-assignment threads: `--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; `--create-task` and `--list-tasks` stay disabled there and must be run from the parent channel
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
- const parsed = Number.parseInt(value, 10);
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 explicitTaskIdProvided(options) {
219
- return typeof options.taskId === 'string' && options.taskId.trim().length > 0;
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 explicitTaskId = typeof options.taskId === 'string'
224
- ? options.taskId.trim()
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 new Error(`Missing required --task-id <value> for --${action}`);
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
- if (options.limit != null) {
260
- url.searchParams.set('limit', String(options.limit));
261
- }
262
- if (options.before != null) {
263
- url.searchParams.set('before', String(options.before));
264
- }
265
- if (options.after != null) {
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 && !explicitTaskIdProvided(options)) {
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 && !explicitTaskIdProvided(options)) {
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',