@agentconnect.md/daemon 1.0.0-rc.45 → 1.0.0-rc.47

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/dist/index.js CHANGED
@@ -4,12 +4,12 @@ import { EventEmitter } from "node:events";
4
4
  import childProcess, { execFile, spawn } from "node:child_process";
5
5
  import * as sp from "node:path";
6
6
  import path, { delimiter, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
7
- import fs, { accessSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, stat, unlinkSync, unwatchFile, watch, watchFile, writeFileSync } from "node:fs";
7
+ import fs, { accessSync, chmodSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, stat, unlinkSync, unwatchFile, watch, watchFile, writeFileSync } from "node:fs";
8
8
  import process$1 from "node:process";
9
9
  import { stripVTControlCharacters } from "node:util";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { createInterface } from "node:readline";
12
- import { cpus, freemem, homedir, hostname, totalmem, type } from "node:os";
12
+ import { cpus, freemem, homedir, hostname, tmpdir, totalmem, type } from "node:os";
13
13
  import { spawn as spawn$1 } from "child_process";
14
14
  import { Readable, Writable } from "node:stream";
15
15
  import { randomBytes, randomUUID } from "node:crypto";
@@ -7529,7 +7529,7 @@ function toRuntimeDef(entry) {
7529
7529
  return null;
7530
7530
  }
7531
7531
  const REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
7532
- const DEFAULT_TIMEOUT_MS = 4500;
7532
+ const DEFAULT_TIMEOUT_MS$1 = 4500;
7533
7533
  function readCachedDoc(root) {
7534
7534
  const file = registryPath(root);
7535
7535
  if (!existsSync(file)) return null;
@@ -7555,7 +7555,7 @@ async function fetchRegistry(root, opts = {}) {
7555
7555
  if (meta.etag) headers["If-None-Match"] = meta.etag;
7556
7556
  if (meta.lastModified) headers["If-Modified-Since"] = meta.lastModified;
7557
7557
  const ac = new AbortController();
7558
- const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
7558
+ const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS$1);
7559
7559
  try {
7560
7560
  const res = await doFetch(REGISTRY_URL, {
7561
7561
  headers,
@@ -16891,6 +16891,21 @@ CLIENT_METHODS.session_update, CLIENT_METHODS.elicitation_complete;
16891
16891
  //#endregion
16892
16892
  //#region src/acp/acp-host.ts
16893
16893
  const PROTOCOL_VERSION = 1;
16894
+ /**
16895
+ * Extract the model selector from a session's `configOptions`. ACP models are an
16896
+ * (experimental) session config option tagged `category: "model"`, `type:
16897
+ * "select"`; the selectable values (optionally grouped) are the model list.
16898
+ * Returns null when the agent advertises no model selector.
16899
+ */
16900
+ function modelOptionsFrom(configOptions) {
16901
+ const model = configOptions?.find((o) => o.category === "model" && o.type === "select");
16902
+ if (!model || model.type !== "select") return null;
16903
+ const models = model.options.flatMap((o) => "group" in o ? o.options : [o]).map((o) => o.value);
16904
+ return {
16905
+ current: model.currentValue,
16906
+ models
16907
+ };
16908
+ }
16894
16909
  var AcpHost = class {
16895
16910
  runtime;
16896
16911
  opts;
@@ -16900,6 +16915,7 @@ var AcpHost = class {
16900
16915
  loadingSessions = /* @__PURE__ */ new Set();
16901
16916
  canLoad = false;
16902
16917
  promptCaps = {};
16918
+ lastModelOptions = null;
16903
16919
  constructor(runtime, opts) {
16904
16920
  this.runtime = runtime;
16905
16921
  this.opts = opts;
@@ -16953,9 +16969,15 @@ var AcpHost = class {
16953
16969
  cwd,
16954
16970
  mcpServers
16955
16971
  });
16972
+ this.lastModelOptions = modelOptionsFrom(res.configOptions);
16956
16973
  this.live.add(res.sessionId);
16957
16974
  return res.sessionId;
16958
16975
  }
16976
+ /** The model selector advertised by the most recent session/new, or null if the
16977
+ * agent exposed none. Used by the runtime probe to report available models. */
16978
+ modelOptions() {
16979
+ return this.lastModelOptions;
16980
+ }
16959
16981
  /** True iff THIS agent process created or loaded `sessionId` in its current lifetime. */
16960
16982
  hasSession(sessionId) {
16961
16983
  return this.live.has(sessionId);
@@ -81439,13 +81461,18 @@ var Scheduler = class {
81439
81461
  * only advertises genuinely-installed runtimes in `RegisterReq.capabilities`.
81440
81462
  *
81441
81463
  * Two layers:
81442
- * 1. A generic launcher check — is the runtime's `command` resolvable? Most
81443
- * agents ship as `npx`/`uvx` packages, which fetch on demand, so the launcher
81444
- * being present is enough for them.
81445
- * 2. Per-runtime custom probes (`CUSTOM_PROBES`) for the cases where (1) is too
81446
- * weak — almost every agent runs via `npx`, so "npx exists" is meaningless.
81447
- * For those we additionally look for the wrapped CLI's own config/state dir,
81448
- * which appears once the user has installed and run/initialized it.
81464
+ * 1. A generic launcher check — is the runtime's `command` resolvable? For a
81465
+ * runtime distributed as a real binary (`command` is the tool's own
81466
+ * executable) this is a meaningful signal: the binary on `$PATH` means it's
81467
+ * installed.
81468
+ * 2. For runtimes distributed via a package launcher (`npx`/`uvx`), (1) is
81469
+ * meaningless — the launcher fetches on demand, so "npx exists" is true on
81470
+ * virtually every dev host and tells us nothing about the wrapped agent. For
81471
+ * those we REQUIRE a per-runtime custom probe (`CUSTOM_PROBES`) that looks for
81472
+ * the wrapped CLI's own config/state dir, which appears once the user has
81473
+ * installed and run/initialized it. A launcher-distributed runtime with no
81474
+ * probe is treated as NOT installed — otherwise every npx/uvx agent in the
81475
+ * ACP registry would be falsely advertised just because `npx` is on `$PATH`.
81449
81476
  *
81450
81477
  * The custom probes test for the config *directory* (the "installed & initialized
81451
81478
  * on this host" signal). A stricter "is logged in" check would look for the auth
@@ -81511,11 +81538,18 @@ const CUSTOM_PROBES = {
81511
81538
  "factory-droid": (env) => pathExists(join(home(env), ".factory")),
81512
81539
  devin: (env) => anyExists(join(xdgConfigHome(env), "devin"), join(xdgDataHome(env), "devin"))
81513
81540
  };
81541
+ /**
81542
+ * Package launchers that fetch-and-run on demand. When a runtime is launched via
81543
+ * one of these, the launcher being on `$PATH` says nothing about whether the
81544
+ * wrapped agent is installed, so such runtimes require a custom probe to count.
81545
+ */
81546
+ const PACKAGE_LAUNCHERS = /* @__PURE__ */ new Set(["npx", "uvx"]);
81514
81547
  /** Is this runtime actually usable on this host? */
81515
81548
  function isRuntimeAvailable(id, rt, env = process.env) {
81516
81549
  if (!isCommandAvailable(rt.command, env)) return false;
81517
81550
  const custom = CUSTOM_PROBES[id];
81518
- return custom ? custom(env) : true;
81551
+ if (custom) return custom(env);
81552
+ return !PACKAGE_LAUNCHERS.has(rt.command);
81519
81553
  }
81520
81554
  /**
81521
81555
  * Filter a runtime map down to those installed on this host — what the daemon
@@ -81527,6 +81561,89 @@ function installedRuntimes(runtimes, env = process.env) {
81527
81561
  return out;
81528
81562
  }
81529
81563
  //#endregion
81564
+ //#region src/runtimes/runtime-prober.ts
81565
+ const DEFAULT_TIMEOUT_MS = 3e4;
81566
+ const DEFAULT_CONCURRENCY = 3;
81567
+ function makeHost(rt, log) {
81568
+ return new AcpHost(rt, {
81569
+ onUpdate: () => {},
81570
+ log
81571
+ });
81572
+ }
81573
+ /**
81574
+ * Probe one runtime. Never throws: any failure (spawn error, handshake failure,
81575
+ * timeout) is captured in `{ ok: false, error }`. On success, `models` reflects
81576
+ * whatever the agent advertised — an empty list is normal for agents that expose
81577
+ * no model selector, and is not a failure.
81578
+ */
81579
+ async function probeRuntime(id, rt, cwd, opts = {}) {
81580
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
81581
+ const host = (opts.hostFactory ?? ((r) => makeHost(r, opts.log)))(rt);
81582
+ let timer;
81583
+ const timeout = new Promise((_, reject) => {
81584
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`probe timed out after ${timeoutMs}ms`)), timeoutMs);
81585
+ });
81586
+ const run = async () => {
81587
+ await host.start();
81588
+ await host.newSession(cwd);
81589
+ return host.modelOptions();
81590
+ };
81591
+ try {
81592
+ const models = await Promise.race([run(), timeout]);
81593
+ opts.log?.info(`probe: ${id} ok (models: ${models?.models.length ? models.models.join(", ") : "none advertised"})`);
81594
+ return {
81595
+ runtime: id,
81596
+ ok: true,
81597
+ models: models?.models ?? [],
81598
+ currentModel: models?.current
81599
+ };
81600
+ } catch (err) {
81601
+ const error = err.message;
81602
+ opts.log?.warn(`probe: ${id} failed — ${error}`);
81603
+ return {
81604
+ runtime: id,
81605
+ ok: false,
81606
+ models: [],
81607
+ error
81608
+ };
81609
+ } finally {
81610
+ if (timer) clearTimeout(timer);
81611
+ await host.stop().catch(() => {});
81612
+ }
81613
+ }
81614
+ /**
81615
+ * Probe every runtime with bounded concurrency. All probes share one temp cwd
81616
+ * (removed afterward). Result order is not meaningful — callers key by
81617
+ * `result.runtime`.
81618
+ */
81619
+ async function probeAllRuntimes(runtimes, opts = {}) {
81620
+ const ids = Object.keys(runtimes);
81621
+ if (ids.length === 0) return [];
81622
+ const cwd = mkdtempSync(join(tmpdir(), "ac-probe-"));
81623
+ const limit = Math.max(1, opts.concurrency ?? DEFAULT_CONCURRENCY);
81624
+ const results = [];
81625
+ let next = 0;
81626
+ const worker = async () => {
81627
+ for (let i = next++; i < ids.length; i = next++) {
81628
+ const id = ids[i];
81629
+ results.push(await probeRuntime(id, runtimes[id], cwd, opts));
81630
+ }
81631
+ };
81632
+ try {
81633
+ await Promise.all(Array.from({ length: Math.min(limit, ids.length) }, () => worker()));
81634
+ } finally {
81635
+ try {
81636
+ rmSync(cwd, {
81637
+ recursive: true,
81638
+ force: true
81639
+ });
81640
+ } catch (err) {
81641
+ opts.log?.debug(`probe: temp cwd cleanup failed for ${cwd}: ${err.message}`);
81642
+ }
81643
+ }
81644
+ return results;
81645
+ }
81646
+ //#endregion
81530
81647
  //#region src/log.ts
81531
81648
  const ORDER = {
81532
81649
  trace: 10,
@@ -81728,7 +81845,17 @@ var CpClient = class {
81728
81845
  this.heartbeatMs = ok.heartbeatSec > 0 ? ok.heartbeatSec * 1e3 : this.deps.heartbeatDefaultMs;
81729
81846
  this.armHeartbeat();
81730
81847
  this.deps.log.info(`cp: READY (epoch=${this.sessionEpoch}, routingEpoch=${this.routingEpoch})`);
81731
- for (const profile of this.deps.runtimeProfiles()) this.transport?.send(encode(buildEnvelope("facts/runtime-profile", profile)));
81848
+ for (const profile of this.deps.runtimeProfiles()) this.emitRuntimeProfile(profile);
81849
+ this.deps.onReady?.();
81850
+ }
81851
+ /**
81852
+ * Push a single runtime profile mid-session (D→C EVT, fire-and-forget). No-op
81853
+ * unless connected and READY/DRAINING — the CP only accepts control frames
81854
+ * there, and a reconnect's handshake re-emits the latest profiles anyway.
81855
+ */
81856
+ emitRuntimeProfile(profile) {
81857
+ if (this.state !== "READY" && this.state !== "DRAINING") return;
81858
+ this.transport?.send(encode(buildEnvelope("facts/runtime-profile", profile)));
81732
81859
  }
81733
81860
  request(type, payload) {
81734
81861
  const frame = buildEnvelope(type, payload);
@@ -82244,7 +82371,7 @@ function cpuTotals() {
82244
82371
  busy
82245
82372
  };
82246
82373
  }
82247
- var Daemon = class {
82374
+ var Daemon = class Daemon {
82248
82375
  opts;
82249
82376
  store;
82250
82377
  mcp;
@@ -82266,6 +82393,9 @@ var Daemon = class {
82266
82393
  runtimes = {};
82267
82394
  runtimeNames = {};
82268
82395
  runtimeVersions = {};
82396
+ runtimeModels = /* @__PURE__ */ new Map();
82397
+ probing = false;
82398
+ lastProbeAtMs = 0;
82269
82399
  cpClient;
82270
82400
  cpCrons;
82271
82401
  cpAgents;
@@ -83216,13 +83346,8 @@ var Daemon = class {
83216
83346
  acp: true,
83217
83347
  features: []
83218
83348
  }),
83219
- runtimeProfiles: () => Object.keys(this.runtimes).map((id) => ({
83220
- runtime: id,
83221
- version: this.runtimeVersions[id] ?? "",
83222
- models: [],
83223
- acpSupport: "full",
83224
- toolCalling: true
83225
- })),
83349
+ runtimeProfiles: () => Object.keys(this.runtimes).map((id) => this.runtimeProfileFor(id)),
83350
+ onReady: () => void this.probeRuntimesAndEmit(),
83226
83351
  localState: () => ({
83227
83352
  assignments: [],
83228
83353
  crons: this.cpCrons?.ids() ?? [],
@@ -83244,6 +83369,54 @@ var Daemon = class {
83244
83369
  this.cpClient.start();
83245
83370
  this.log.info(`cp: connecting to ${url}…`);
83246
83371
  }
83372
+ /** Build the current `facts/runtime-profile` for a runtime, folding in any
83373
+ * models learned by the probe sweep. */
83374
+ runtimeProfileFor(id) {
83375
+ return {
83376
+ runtime: id,
83377
+ version: this.runtimeVersions[id] ?? "",
83378
+ models: this.runtimeModels.get(id) ?? [],
83379
+ acpSupport: "full",
83380
+ toolCalling: true
83381
+ };
83382
+ }
83383
+ /** How long a completed probe sweep stays fresh — reconnects within this window
83384
+ * re-emit the cached profiles instead of re-spawning every agent. */
83385
+ static PROBE_TTL_MS = 5 * 6e4;
83386
+ /**
83387
+ * Probe every installed runtime in the background (launch → initialize →
83388
+ * session/new → read models → tear down), then emit a refreshed
83389
+ * `facts/runtime-profile` per runtime once the whole sweep completes.
83390
+ *
83391
+ * Triggered on each CP (re)connect. Deduped while in flight; skipped (with a
83392
+ * cached re-emit) when the last sweep is still fresh, so a reconnect storm can't
83393
+ * spawn a fleet of agent subprocesses. Never throws — probing is best-effort and
83394
+ * must not affect the CP connection.
83395
+ */
83396
+ async probeRuntimesAndEmit() {
83397
+ if (this.opts.hostFactory && !this.opts.probeRuntimes) return;
83398
+ const ids = Object.keys(this.runtimes);
83399
+ if (ids.length === 0) return;
83400
+ if (this.probing) return;
83401
+ if (this.lastProbeAtMs > 0 && Date.now() - this.lastProbeAtMs < Daemon.PROBE_TTL_MS) {
83402
+ for (const id of ids) this.cpClient?.emitRuntimeProfile(this.runtimeProfileFor(id));
83403
+ return;
83404
+ }
83405
+ this.probing = true;
83406
+ this.log.info(`probe: sweeping ${ids.length} runtime(s): ${ids.join(", ")}`);
83407
+ try {
83408
+ const results = await (this.opts.probeRuntimes ?? probeAllRuntimes)(this.runtimes, { log: this.log });
83409
+ for (const r of results) this.runtimeModels.set(r.runtime, r.ok ? r.models : []);
83410
+ this.lastProbeAtMs = Date.now();
83411
+ const okCount = results.filter((r) => r.ok).length;
83412
+ this.log.info(`probe: sweep complete — ${okCount}/${results.length} runtime(s) reachable`);
83413
+ for (const id of ids) this.cpClient?.emitRuntimeProfile(this.runtimeProfileFor(id));
83414
+ } catch (err) {
83415
+ this.log.warn(`probe: sweep failed: ${formatErr(err)}`);
83416
+ } finally {
83417
+ this.probing = false;
83418
+ }
83419
+ }
83247
83420
  async stop() {
83248
83421
  this.draining = true;
83249
83422
  clearTimeout(this.debounceTimer);