@agent-team-foundation/first-tree-hub 0.10.14 → 0.10.15

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.
@@ -3,7 +3,7 @@ import { o as logFormatSchema, s as logLevelSchema } from "./logger-core-BTmvdfl
3
3
  import { z } from "zod";
4
4
  import { dirname, join } from "node:path";
5
5
  import { homedir } from "node:os";
6
- import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
6
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
7
7
  import { randomBytes } from "node:crypto";
8
8
  import { parse, stringify } from "yaml";
9
9
  //#region ../shared/dist/config/index.mjs
@@ -650,6 +650,7 @@ const serverConfigSchema = defineConfig({
650
650
  //#region src/core/bootstrap.ts
651
651
  var bootstrap_exports = /* @__PURE__ */ __exportAll({
652
652
  AuthRefreshFailedError: () => AuthRefreshFailedError,
653
+ AuthRefreshRateLimitedError: () => AuthRefreshRateLimitedError,
653
654
  ensureFreshAccessToken: () => ensureFreshAccessToken,
654
655
  ensureFreshAdminToken: () => ensureFreshAdminToken,
655
656
  loadCredentials: () => loadCredentials,
@@ -706,6 +707,39 @@ var AuthRefreshFailedError = class extends Error {
706
707
  }
707
708
  };
708
709
  /**
710
+ * Thrown when `/auth/refresh` returns 429. Carries the server-suggested
711
+ * retry-after (or a sane default) so the WS reconnect loop can wait at
712
+ * least that long instead of pounding the limiter inside the same window
713
+ * with its default 1/2/4/8s exponential backoff — which would just keep
714
+ * the rate-limit bucket full and stretch the outage. Defaults to 30s when
715
+ * the server omits the header.
716
+ */
717
+ var AuthRefreshRateLimitedError = class extends Error {
718
+ retryAfterMs;
719
+ constructor(retryAfterMs, message) {
720
+ super(message ?? `Refresh request rate-limited; retry after ${Math.round(retryAfterMs / 1e3)}s.`);
721
+ this.name = "AuthRefreshRateLimitedError";
722
+ this.retryAfterMs = retryAfterMs;
723
+ }
724
+ };
725
+ /**
726
+ * Parse an HTTP `Retry-After` header. Accepts either an integer seconds
727
+ * value (the form fastify-rate-limit emits) or an RFC 7231 HTTP-date.
728
+ * Returns ms, or `null` when the header is absent / malformed.
729
+ */
730
+ function parseRetryAfterMs(header) {
731
+ if (!header) return null;
732
+ const trimmed = header.trim();
733
+ const seconds = Number(trimmed);
734
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
735
+ const date = Date.parse(trimmed);
736
+ if (Number.isFinite(date)) {
737
+ const delta = date - Date.now();
738
+ return delta > 0 ? delta : 0;
739
+ }
740
+ return null;
741
+ }
742
+ /**
709
743
  * In-flight refresh promise. Multiple callers (WS handshake, proactive
710
744
  * refresh timer, every SDK request) can see an expired token within the same
711
745
  * millisecond — without dedupe each would fire an independent `/auth/refresh`
@@ -747,6 +781,7 @@ async function ensureFreshAccessToken(opts) {
747
781
  signal: AbortSignal.timeout(1e4)
748
782
  });
749
783
  if (res.status === 401) throw new AuthRefreshFailedError("Refresh token rejected by server. Re-run `first-tree-hub connect <token>` (get a fresh token from the Web Computers page → New Connection).");
784
+ if (res.status === 429) throw new AuthRefreshRateLimitedError(parseRetryAfterMs(res.headers.get("retry-after")) ?? 3e4);
750
785
  if (!res.ok) throw new Error(`Refresh request failed with status ${res.status}.`);
751
786
  const data = await res.json();
752
787
  saveCredentials({
@@ -775,13 +810,34 @@ function isTokenStale(token, minValidityMs) {
775
810
  return true;
776
811
  }
777
812
  }
778
- /** Persist credentials to disk. */
813
+ /**
814
+ * Persist credentials to disk atomically.
815
+ *
816
+ * Plain `writeFileSync` opens with `O_TRUNC` then writes — between those
817
+ * calls the file is empty, and a concurrent `loadCredentials()` (e.g. a
818
+ * background daemon refreshing while the user runs a foreground CLI command)
819
+ * reads "" → `JSON.parse` throws → we fall back to "no credentials" and
820
+ * surface a misleading "run `client connect` again" error. write-to-temp +
821
+ * rename gives readers an all-or-nothing view: they see the old file or the
822
+ * new file, never a half-written one. Server-side the sliding-window design
823
+ * already accepts last-writer-wins semantics for the refresh token itself
824
+ * (see auth service comment), so atomicity at the file level is enough.
825
+ */
779
826
  function saveCredentials(creds) {
780
827
  mkdirSync(dirname(CREDENTIALS_PATH), {
781
828
  recursive: true,
782
829
  mode: 448
783
830
  });
784
- writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { mode: 384 });
831
+ const tmp = `${CREDENTIALS_PATH}.tmp.${process.pid}`;
832
+ try {
833
+ writeFileSync(tmp, JSON.stringify(creds, null, 2), { mode: 384 });
834
+ renameSync(tmp, CREDENTIALS_PATH);
835
+ } catch (err) {
836
+ try {
837
+ unlinkSync(tmp);
838
+ } catch {}
839
+ throw err;
840
+ }
785
841
  }
786
842
  /**
787
843
  * Load persisted credentials saved by the `connect` command.
@@ -808,4 +864,4 @@ function saveAgentConfig(agentName, agentId, runtime) {
808
864
  return agentDir;
809
865
  }
810
866
  //#endregion
811
- export { resolveConfigReadonly as C, resetConfigMeta as S, setConfigValue as T, initConfig as _, loadCredentials as a, readConfigFile as b, saveAgentConfig as c, DEFAULT_DATA_DIR as d, DEFAULT_HOME_DIR as f, getConfigValue as g, collectMissingPrompts as h, ensureFreshAdminToken as i, saveCredentials as l, clientConfigSchema as m, bootstrap_exports as n, resolveAccessToken as o, agentConfigSchema as p, ensureFreshAccessToken as r, resolveServerUrl as s, AuthRefreshFailedError as t, DEFAULT_CONFIG_DIR as u, loadAgents as v, serverConfigSchema as w, resetConfig as x, migrateLegacyHome as y };
867
+ export { resetConfigMeta as C, setConfigValue as E, resetConfig as S, serverConfigSchema as T, getConfigValue as _, ensureFreshAdminToken as a, migrateLegacyHome as b, resolveServerUrl as c, DEFAULT_CONFIG_DIR as d, DEFAULT_DATA_DIR as f, collectMissingPrompts as g, clientConfigSchema as h, ensureFreshAccessToken as i, saveAgentConfig as l, agentConfigSchema as m, AuthRefreshRateLimitedError as n, loadCredentials as o, DEFAULT_HOME_DIR as p, bootstrap_exports as r, resolveAccessToken as s, AuthRefreshFailedError as t, saveCredentials as u, initConfig as v, resolveConfigReadonly as w, readConfigFile as x, loadAgents as y };
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import "../observability-DPyf745N-BSc8QNcR.mjs";
3
- import { $ as findStaleAliases, A as checkDatabase, B as installClientService, C as runHomeMigration, D as checkAgentConfigs, E as runMigrations, F as checkServerReachable, G as stopClientService, I as checkWebSocket, L as printResults, M as checkNodeVersion, N as checkServerConfig, O as checkBackgroundService, P as checkServerHealth, R as reconcileAgentConfigs, S as saveOnboardState, T as migrateLocalAgentDirs, U as restartClientService, V as isServiceSupported, W as startClientService, X as ClientRuntime, Y as stopPostgres, Z as handleClientOrgMismatch, _ as promptMissingFields, _t as probeCapabilities, a as declineUpdate, at as fail, b as onboardCheck, c as detectInstallMode, ct as print, d as startServer, dt as ClientOrgMismatchError, et as formatStaleReason, f as COMMAND_VERSION, ft as ClientUserMismatchError, g as promptAddAgent, gt as cleanWorkspaces, h as isInteractive, ht as SessionRegistry, i as createExecuteUpdate, it as resolveReplyToFromEnv, j as checkDocker, k as checkClientConfig, l as fetchLatestVersion, lt as setJsonMode, m as uploadClientCapabilities, mt as SdkError, nt as createOwner, o as promptUpdate, ot as success, p as reconcileLocalRuntimeProviders, pt as FirstTreeHubSDK, r as registerSaaSConnectCommand, s as PACKAGE_NAME, tt as removeLocalAgent, u as installGlobalLatest, v as formatCheckReport, vt as applyClientLoggerConfig, w as createApiNameResolver, x as onboardCreate, y as loadOnboardState, yt as configureClientLoggerForService, z as getClientServiceStatus } from "../saas-connect-DWcxHtjX.mjs";
3
+ import { $ as findStaleAliases, A as checkDatabase, B as installClientService, C as runHomeMigration, D as checkAgentConfigs, E as runMigrations, F as checkServerReachable, G as stopClientService, I as checkWebSocket, L as printResults, M as checkNodeVersion, N as checkServerConfig, O as checkBackgroundService, P as checkServerHealth, R as reconcileAgentConfigs, S as saveOnboardState, T as migrateLocalAgentDirs, U as restartClientService, V as isServiceSupported, W as startClientService, X as ClientRuntime, Y as stopPostgres, Z as handleClientOrgMismatch, _ as promptMissingFields, _t as probeCapabilities, a as declineUpdate, at as fail, b as onboardCheck, c as detectInstallMode, ct as print, d as startServer, dt as ClientOrgMismatchError, et as formatStaleReason, f as COMMAND_VERSION, ft as ClientUserMismatchError, g as promptAddAgent, gt as cleanWorkspaces, h as isInteractive, ht as SessionRegistry, i as createExecuteUpdate, it as resolveReplyToFromEnv, j as checkDocker, k as checkClientConfig, l as fetchLatestVersion, lt as setJsonMode, m as uploadClientCapabilities, mt as SdkError, nt as createOwner, o as promptUpdate, ot as success, p as reconcileLocalRuntimeProviders, pt as FirstTreeHubSDK, r as registerSaaSConnectCommand, s as PACKAGE_NAME, tt as removeLocalAgent, u as installGlobalLatest, v as formatCheckReport, vt as applyClientLoggerConfig, w as createApiNameResolver, x as onboardCreate, y as loadOnboardState, yt as configureClientLoggerForService, z as getClientServiceStatus } from "../saas-connect-CebXWFF-.mjs";
4
4
  import "../logger-core-BTmvdflj-DjW8FM4T.mjs";
5
- import { C as resolveConfigReadonly, S as resetConfigMeta, T as setConfigValue, _ as initConfig, a as loadCredentials, b as readConfigFile, c as saveAgentConfig, d as DEFAULT_DATA_DIR, f as DEFAULT_HOME_DIR, g as getConfigValue, i as ensureFreshAdminToken, l as saveCredentials, m as clientConfigSchema, p as agentConfigSchema, r as ensureFreshAccessToken, s as resolveServerUrl, u as DEFAULT_CONFIG_DIR, v as loadAgents, w as serverConfigSchema, x as resetConfig } from "../bootstrap-B2x4TTyJ.mjs";
5
+ import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, _ as getConfigValue, a as ensureFreshAdminToken, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, x as readConfigFile, y as loadAgents } from "../bootstrap-DUeYbwm-.mjs";
6
6
  import "../dist-D6AOiyNg.mjs";
7
7
  import { n as bindFeishuUser, t as bindFeishuBot } from "../feishu-DQ1l18Ah.mjs";
8
8
  import "../invitation-B1pjAyOz-BaCA9PII.mjs";
@@ -1608,13 +1608,13 @@ function isSecretField(schema, dotPath) {
1608
1608
  //#region src/commands/onboard.ts
1609
1609
  async function promptMissing(args) {
1610
1610
  if (!args.server) try {
1611
- const { resolveServerUrl } = await import("../bootstrap-B2x4TTyJ.mjs").then((n) => n.n);
1611
+ const { resolveServerUrl } = await import("../bootstrap-DUeYbwm-.mjs").then((n) => n.r);
1612
1612
  resolveServerUrl();
1613
1613
  } catch {
1614
1614
  args.server = await input({ message: "Hub server URL:" });
1615
1615
  saveOnboardState(args);
1616
1616
  }
1617
- const { loadCredentials } = await import("../bootstrap-B2x4TTyJ.mjs").then((n) => n.n);
1617
+ const { loadCredentials } = await import("../bootstrap-DUeYbwm-.mjs").then((n) => n.r);
1618
1618
  if (!loadCredentials()) throw new Error("No saved credentials. Run `first-tree-hub client connect <server-url>` before onboarding.");
1619
1619
  if (!args.id) {
1620
1620
  args.id = await input({ message: "Agent ID:" });
package/dist/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  import "./observability-DPyf745N-BSc8QNcR.mjs";
2
- import { A as checkDatabase, B as installClientService, C as runHomeMigration, D as checkAgentConfigs, E as runMigrations, F as checkServerReachable, G as stopClientService, H as resolveCliInvocation, I as checkWebSocket, J as isDockerAvailable, K as uninstallClientService, L as printResults, M as checkNodeVersion, N as checkServerConfig, P as checkServerHealth, Q as rotateClientIdWithBackup, U as restartClientService, V as isServiceSupported, W as startClientService, X as ClientRuntime, Y as stopPostgres, Z as handleClientOrgMismatch, _ as promptMissingFields, b as onboardCheck, d as startServer, g as promptAddAgent, h as isInteractive, j as checkDocker, k as checkClientConfig, mt as SdkError, n as deriveHubUrlFromToken, nt as createOwner, pt as FirstTreeHubSDK, q as ensurePostgres, rt as hasUser, st as blank, t as HubUrlDerivationError, ut as status, v as formatCheckReport, x as onboardCreate, z as getClientServiceStatus } from "./saas-connect-DWcxHtjX.mjs";
2
+ import { A as checkDatabase, B as installClientService, C as runHomeMigration, D as checkAgentConfigs, E as runMigrations, F as checkServerReachable, G as stopClientService, H as resolveCliInvocation, I as checkWebSocket, J as isDockerAvailable, K as uninstallClientService, L as printResults, M as checkNodeVersion, N as checkServerConfig, P as checkServerHealth, Q as rotateClientIdWithBackup, U as restartClientService, V as isServiceSupported, W as startClientService, X as ClientRuntime, Y as stopPostgres, Z as handleClientOrgMismatch, _ as promptMissingFields, b as onboardCheck, d as startServer, g as promptAddAgent, h as isInteractive, j as checkDocker, k as checkClientConfig, mt as SdkError, n as deriveHubUrlFromToken, nt as createOwner, pt as FirstTreeHubSDK, q as ensurePostgres, rt as hasUser, st as blank, t as HubUrlDerivationError, ut as status, v as formatCheckReport, x as onboardCreate, z as getClientServiceStatus } from "./saas-connect-CebXWFF-.mjs";
3
3
  import "./logger-core-BTmvdflj-DjW8FM4T.mjs";
4
- import { i as ensureFreshAdminToken, o as resolveAccessToken, r as ensureFreshAccessToken, s as resolveServerUrl, t as AuthRefreshFailedError } from "./bootstrap-B2x4TTyJ.mjs";
4
+ import { a as ensureFreshAdminToken, c as resolveServerUrl, i as ensureFreshAccessToken, n as AuthRefreshRateLimitedError, s as resolveAccessToken, t as AuthRefreshFailedError } from "./bootstrap-DUeYbwm-.mjs";
5
5
  import "./dist-D6AOiyNg.mjs";
6
6
  import { n as bindFeishuUser, t as bindFeishuBot } from "./feishu-DQ1l18Ah.mjs";
7
7
  import "./invitation-B1pjAyOz-BaCA9PII.mjs";
8
- export { AuthRefreshFailedError, ClientRuntime, FirstTreeHubSDK, HubUrlDerivationError, SdkError, bindFeishuBot, bindFeishuUser, blank, checkAgentConfigs, checkClientConfig, checkDatabase, checkDocker, checkNodeVersion, checkServerConfig, checkServerHealth, checkServerReachable, checkWebSocket, createOwner, deriveHubUrlFromToken, ensureFreshAccessToken, ensureFreshAdminToken, ensurePostgres, formatCheckReport, getClientServiceStatus, handleClientOrgMismatch, hasUser, installClientService, isDockerAvailable, isInteractive, isServiceSupported, onboardCheck, onboardCreate, printResults, promptAddAgent, promptMissingFields, resolveAccessToken, resolveCliInvocation, resolveServerUrl, restartClientService, rotateClientIdWithBackup, runHomeMigration, runMigrations, startClientService, startServer, status, stopClientService, stopPostgres, uninstallClientService };
8
+ export { AuthRefreshFailedError, AuthRefreshRateLimitedError, ClientRuntime, FirstTreeHubSDK, HubUrlDerivationError, SdkError, bindFeishuBot, bindFeishuUser, blank, checkAgentConfigs, checkClientConfig, checkDatabase, checkDocker, checkNodeVersion, checkServerConfig, checkServerHealth, checkServerReachable, checkWebSocket, createOwner, deriveHubUrlFromToken, ensureFreshAccessToken, ensureFreshAdminToken, ensurePostgres, formatCheckReport, getClientServiceStatus, handleClientOrgMismatch, hasUser, installClientService, isDockerAvailable, isInteractive, isServiceSupported, onboardCheck, onboardCreate, printResults, promptAddAgent, promptMissingFields, resolveAccessToken, resolveCliInvocation, resolveServerUrl, restartClientService, rotateClientIdWithBackup, runHomeMigration, runMigrations, startClientService, startServer, status, stopClientService, stopPostgres, uninstallClientService };
@@ -1,6 +1,6 @@
1
1
  import { m as __toESM } from "./esm-CYu4tXXn.mjs";
2
2
  import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DPyf745N-BSc8QNcR.mjs";
3
- import { C as resolveConfigReadonly, S as resetConfigMeta, T as setConfigValue, _ as initConfig, a as loadCredentials, c as saveAgentConfig, d as DEFAULT_DATA_DIR$1, f as DEFAULT_HOME_DIR$1, h as collectMissingPrompts, l as saveCredentials, m as clientConfigSchema, p as agentConfigSchema, r as ensureFreshAccessToken, s as resolveServerUrl, u as DEFAULT_CONFIG_DIR, v as loadAgents, w as serverConfigSchema, x as resetConfig, y as migrateLegacyHome } from "./bootstrap-B2x4TTyJ.mjs";
3
+ import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-DUeYbwm-.mjs";
4
4
  import { $ as refreshTokenSchema, A as createOrgFromMeSchema, B as imageInlineContentSchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as dryRunAgentRuntimeConfigSchema, G as isReservedAgentName$1, H as inboxDeliverFrameSchema$1, I as extractMentions, J as loginSchema, K as joinByInvitationSchema, L as githubCallbackQuerySchema, M as createTaskSchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as rebindAgentSchema, R as githubDevCallbackQuerySchema, S as clientCapabilitiesSchema$1, St as wsAuthFrameSchema, T as createAdapterConfigSchema, U as inboxPollQuerySchema, V as inboxAckFrameSchema, W as isRedactedEnvValue, X as notificationQuerySchema, Y as messageSourceSchema$1, Z as paginationQuerySchema, _ as adminUpdateTaskSchema, _t as updateClientCapabilitiesSchema, a as AGENT_STATUSES, at as sendToAgentSchema, b as agentRuntimeConfigPayloadSchema$1, bt as updateSystemConfigSchema, ct as sessionEventSchema$1, d as TASK_HEALTH_SIGNALS, dt as switchOrgSchema, et as runtimeStateMessageSchema, f as TASK_STATUSES, ft as taskListQuerySchema, g as adminCreateTaskSchema, gt as updateChatSchema, h as addParticipantSchema, ht as updateAgentSchema, i as AGENT_SOURCES, it as sendMessageSchema, j as createOrganizationSchema, k as createMemberSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as sessionReconcileRequestSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateAgentRuntimeConfigSchema, n as AGENT_NAME_REGEX$1, nt as scanMentionTokens, o as AGENT_TYPES, ot as sessionCompletionMessageSchema, p as TASK_TERMINAL_STATUSES, pt as updateAdapterConfigSchema, q as linkTaskChatSchema, r as AGENT_SELECTOR_HEADER$1, rt as selfServiceFeishuBotSchema, s as AGENT_VISIBILITY, st as sessionEventMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as safeRedirectPath, u as TASK_CREATOR_TYPES, ut as sessionStateMessageSchema, v as agentBindRequestSchema, vt as updateMemberSchema, w as connectTokenExchangeSchema, x as agentTypeSchema$1, xt as updateTaskStatusSchema, y as agentPinnedMessageSchema$1, yt as updateOrganizationSchema, z as githubStartQuerySchema } from "./dist-D6AOiyNg.mjs";
5
5
  import { _ as recordRedemption, a as ConflictError, b as uuidv7, c as UnauthorizedError, d as findActiveByToken, f as getActiveInvitation, h as organizations, i as ClientUserMismatchError$1, l as buildInviteUrl, m as invitations, n as BadRequestError, o as ForbiddenError, p as invitationRedemptions, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as ensureActiveInvitation, y as users } from "./invitation-B1pjAyOz-BaCA9PII.mjs";
6
6
  import { createRequire } from "node:module";
@@ -21,7 +21,7 @@ import { fileURLToPath } from "node:url";
21
21
  import * as semver from "semver";
22
22
  import { confirm, input, password, select } from "@inquirer/prompts";
23
23
  import bcrypt from "bcrypt";
24
- import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
24
+ import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
25
25
  import { drizzle } from "drizzle-orm/postgres-js";
26
26
  import postgres from "postgres";
27
27
  import { migrate } from "drizzle-orm/postgres-js/migrator";
@@ -1883,6 +1883,13 @@ var ClientConnection = class extends EventEmitter {
1883
1883
  /** Fires ~60s before JWT exp so we reconnect with a fresh token first. */
1884
1884
  authRefreshTimer = null;
1885
1885
  reconnectAttempt = 0;
1886
+ /**
1887
+ * If the most recent refresh attempt was rate-limited (HTTP 429), the
1888
+ * server-suggested wait in ms — consumed by the next `scheduleReconnect`
1889
+ * to floor its delay so we don't keep retrying inside the same 60s
1890
+ * limiter window. Cleared after one use.
1891
+ */
1892
+ nextReconnectMinDelayMs = 0;
1886
1893
  closing = false;
1887
1894
  registered = false;
1888
1895
  /** Count of `server:welcome` frames received; drives `isReconnect` flag. */
@@ -2095,6 +2102,10 @@ var ClientConnection = class extends EventEmitter {
2095
2102
  if (e.name === "AuthRefreshFailedError") {
2096
2103
  this.closing = true;
2097
2104
  this.emit("auth:fatal", e);
2105
+ } else if (e.name === "AuthRefreshRateLimitedError") {
2106
+ const retryAfterMs = e.retryAfterMs ?? 3e4;
2107
+ this.nextReconnectMinDelayMs = Math.max(this.nextReconnectMinDelayMs, retryAfterMs);
2108
+ this.authLogger.warn({ retryAfterMs }, "refresh rate-limited; deferring reconnect");
2098
2109
  }
2099
2110
  settle(reject, e);
2100
2111
  ws.close();
@@ -2348,10 +2359,18 @@ var ClientConnection = class extends EventEmitter {
2348
2359
  if (this.closing) return;
2349
2360
  this.reconnectAttempt++;
2350
2361
  this.emit("reconnecting", this.reconnectAttempt);
2351
- const delay = Math.min(RECONNECT_BASE_MS * 2 ** (this.reconnectAttempt - 1), RECONNECT_MAX_MS);
2362
+ const exponential = Math.min(RECONNECT_BASE_MS * 2 ** (this.reconnectAttempt - 1), RECONNECT_MAX_MS);
2363
+ const floor = this.nextReconnectMinDelayMs;
2364
+ this.nextReconnectMinDelayMs = 0;
2365
+ let delay = Math.max(exponential, floor);
2366
+ if (floor > 0) {
2367
+ const jitter = delay * .2 * (Math.random() * 2 - 1);
2368
+ delay = Math.max(0, Math.round(delay + jitter));
2369
+ }
2352
2370
  this.wsLogger.debug({
2353
2371
  attempt: this.reconnectAttempt,
2354
- delayMs: delay
2372
+ delayMs: delay,
2373
+ floorMs: floor || void 0
2355
2374
  }, "scheduling reconnect");
2356
2375
  this.reconnectTimer = setTimeout(() => {
2357
2376
  this.reconnectTimer = null;
@@ -2401,6 +2420,18 @@ var ClientConnection = class extends EventEmitter {
2401
2420
  * before the server's scheduleAuthExpiry timer fires. Short-lived tokens
2402
2421
  * (exp <= lead window) skip the proactive reconnect entirely — we let the
2403
2422
  * server push `auth:expired` and handle that path.
2423
+ *
2424
+ * Order is "refresh-then-close", not "close-then-let-reconnect-refresh".
2425
+ * The earlier shape relied on the new connection's open handler to do the
2426
+ * `/auth/refresh` HTTP, which forced ≥1s of WS downtime per cycle even on
2427
+ * the happy path (one base reconnect delay + the refresh round-trip) and
2428
+ * compounded badly under 429: every retry attempt also closed/reopened the
2429
+ * WS, holding the agent offline for 15-20s while the limiter cooled down.
2430
+ * Refreshing first lets us swap the new token onto a still-open WS with no
2431
+ * observable disconnect when the refresh succeeds; the original close-and-
2432
+ * reconnect flow only runs on failure as a last-ditch fallback (it'll hit
2433
+ * the same 429 on its next retry, but at least the Retry-After floor is
2434
+ * now wired up so we don't pile attempts inside the same window).
2404
2435
  */
2405
2436
  scheduleProactiveAuthRefresh(token) {
2406
2437
  this.clearAuthRefreshTimer();
@@ -2410,12 +2441,29 @@ var ClientConnection = class extends EventEmitter {
2410
2441
  if (delay <= 0) return;
2411
2442
  this.authLogger.debug({ delayMs: delay }, "scheduled proactive auth refresh");
2412
2443
  this.authRefreshTimer = setTimeout(() => {
2413
- this.authRefreshTimer = null;
2414
- if (this.closing) return;
2415
- this.authLogger.info("triggering proactive auth refresh");
2416
- this.ws?.close(1e3, "proactive auth refresh");
2444
+ this.runProactiveAuthRefresh();
2417
2445
  }, delay);
2418
2446
  }
2447
+ async runProactiveAuthRefresh() {
2448
+ this.authRefreshTimer = null;
2449
+ if (this.closing) return;
2450
+ this.authLogger.info("triggering proactive auth refresh");
2451
+ try {
2452
+ await this.getAccessToken({ minValidityMs: AUTH_REFRESH_LEAD_MS + 5e3 });
2453
+ } catch (err) {
2454
+ const e = err instanceof Error ? err : new Error(String(err));
2455
+ if (e.name === "AuthRefreshRateLimitedError") {
2456
+ const retryAfterMs = e.retryAfterMs ?? 3e4;
2457
+ this.nextReconnectMinDelayMs = Math.max(this.nextReconnectMinDelayMs, retryAfterMs);
2458
+ this.authLogger.warn({ retryAfterMs }, "proactive refresh rate-limited; deferring reconnect");
2459
+ } else if (e.name === "AuthRefreshFailedError") {
2460
+ this.closing = true;
2461
+ this.emit("auth:fatal", e);
2462
+ return;
2463
+ } else this.authLogger.warn({ err: e }, "proactive refresh failed; falling back to reconnect path");
2464
+ }
2465
+ this.ws?.close(1e3, "proactive auth refresh");
2466
+ }
2419
2467
  };
2420
2468
  /** Built-in handler registry. Populated by handler modules. */
2421
2469
  const HANDLER_REGISTRY = /* @__PURE__ */ new Map();
@@ -9078,7 +9126,7 @@ function createFeedbackHandler(config) {
9078
9126
  return { handle };
9079
9127
  }
9080
9128
  //#endregion
9081
- //#region ../server/dist/app-Le92-WQA.mjs
9129
+ //#region ../server/dist/app-DFDhctwC.mjs
9082
9130
  var __defProp = Object.defineProperty;
9083
9131
  var __exportAll = (all, no_symbols) => {
9084
9132
  let target = {};
@@ -11316,6 +11364,22 @@ function removeClientConnection(clientId, ws) {
11316
11364
  clientConnections.delete(clientId);
11317
11365
  return agentIds;
11318
11366
  }
11367
+ /**
11368
+ * Was `ws` the socket currently registered as `clientId`'s active connection
11369
+ * at the time of the call? Used by ws-client.ts's `socket.on("close")` to
11370
+ * decide whether to write `clients.status='disconnected'` to the DB — when a
11371
+ * fast reconnect happens, the new socket has already swapped itself in via
11372
+ * `setClientConnection`, so the old socket's late-arriving onClose must NOT
11373
+ * stamp the row back to disconnected.
11374
+ *
11375
+ * The check is "this socket equals the registered ws", not "this socket is
11376
+ * still OPEN" — the close handler runs precisely when the socket is no
11377
+ * longer OPEN, but the in-memory entry might still legitimately point at
11378
+ * us if no new connection has taken over yet.
11379
+ */
11380
+ function isActiveClientConnection(clientId, ws) {
11381
+ return clientConnections.get(clientId)?.ws === ws;
11382
+ }
11319
11383
  /** Send a message to a client's WebSocket. Returns true if delivered. */
11320
11384
  function sendToClient(clientId, message) {
11321
11385
  const entry = clientConnections.get(clientId);
@@ -14406,18 +14470,11 @@ async function pollInbox(db, inboxId, limit) {
14406
14470
  }
14407
14471
  async function pollInboxInner(db, inboxId, limit) {
14408
14472
  return db.transaction(async (tx) => {
14409
- return bundleDeliveryWithSilentContext(tx, inboxId, await tx.execute(sql`
14410
- UPDATE inbox_entries
14411
- SET status = 'delivered', delivered_at = NOW()
14412
- WHERE id IN (
14413
- SELECT id FROM inbox_entries
14414
- WHERE inbox_id = ${inboxId} AND status = 'pending' AND notify = true
14415
- ORDER BY created_at
14416
- LIMIT ${limit}
14417
- FOR UPDATE SKIP LOCKED
14418
- )
14419
- RETURNING *
14420
- `));
14473
+ const targetIds = tx.select({ id: inboxEntries.id }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, true))).orderBy(asc(inboxEntries.createdAt)).limit(limit).for("update", { skipLocked: true });
14474
+ return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
14475
+ status: "delivered",
14476
+ deliveredAt: /* @__PURE__ */ new Date()
14477
+ }).where(inArray(inboxEntries.id, targetIds)).returning());
14421
14478
  });
14422
14479
  }
14423
14480
  /**
@@ -14430,7 +14487,7 @@ async function pollInboxInner(db, inboxId, limit) {
14430
14487
  * hub-inbox-ws-data-plane §3.2 risk #1).
14431
14488
  *
14432
14489
  * Steps:
14433
- * 1. Sort by `created_at` ASC (PG `RETURNING` does not guarantee order).
14490
+ * 1. Sort by `createdAt` ASC (PG `RETURNING` does not guarantee order).
14434
14491
  * 2. For each trigger, collect silent context & bulk-ack stale silent rows.
14435
14492
  * 3. Fetch the trigger messages.
14436
14493
  * 4. Build wire payloads via the single dispatcher.
@@ -14439,16 +14496,16 @@ async function pollInboxInner(db, inboxId, limit) {
14439
14496
  */
14440
14497
  async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
14441
14498
  if (claimed.length === 0) return [];
14442
- claimed.sort((a, b) => a.created_at.localeCompare(b.created_at));
14499
+ claimed.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
14443
14500
  const precedingByEntryId = await collectPrecedingContext(tx, inboxId, claimed);
14444
- const messageIds = claimed.map((e) => e.message_id);
14501
+ const messageIds = claimed.map((e) => e.messageId);
14445
14502
  const msgs = await tx.select().from(messages).where(inArray(messages.id, messageIds));
14446
14503
  const msgMap = new Map(msgs.map((m) => [m.id, m]));
14447
14504
  const payloads = await buildClientMessagePayloadsForInbox(tx, inboxId, claimed.map((entry) => {
14448
- const msg = msgMap.get(entry.message_id);
14449
- if (!msg) throw new Error(`Unexpected: message ${entry.message_id} not found`);
14505
+ const msg = msgMap.get(entry.messageId);
14506
+ if (!msg) throw new Error(`Unexpected: message ${entry.messageId} not found`);
14450
14507
  return {
14451
- entryChatId: entry.chat_id,
14508
+ entryChatId: entry.chatId,
14452
14509
  precedingMessages: precedingByEntryId.get(entry.id) ?? [],
14453
14510
  message: {
14454
14511
  id: msg.id,
@@ -14469,15 +14526,15 @@ async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
14469
14526
  const payload = payloads[idx];
14470
14527
  if (!payload) throw new Error(`Unexpected: payload for entry ${entry.id} not built`);
14471
14528
  return {
14472
- id: Number(entry.id),
14473
- inboxId: entry.inbox_id,
14474
- messageId: entry.message_id,
14475
- chatId: entry.chat_id,
14529
+ id: entry.id,
14530
+ inboxId: entry.inboxId,
14531
+ messageId: entry.messageId,
14532
+ chatId: entry.chatId,
14476
14533
  status: entry.status,
14477
- retryCount: entry.retry_count,
14478
- createdAt: entry.created_at,
14479
- deliveredAt: entry.delivered_at ?? null,
14480
- ackedAt: entry.acked_at ?? null,
14534
+ retryCount: entry.retryCount,
14535
+ createdAt: entry.createdAt.toISOString(),
14536
+ deliveredAt: entry.deliveredAt?.toISOString() ?? null,
14537
+ ackedAt: entry.ackedAt?.toISOString() ?? null,
14481
14538
  message: payload
14482
14539
  };
14483
14540
  });
@@ -14512,21 +14569,11 @@ async function claimAndBuildForPush(db, inboxId, messageId) {
14512
14569
  "inbox.id": inboxId,
14513
14570
  "message.id": messageId
14514
14571
  }, () => db.transaction(async (tx) => {
14515
- return bundleDeliveryWithSilentContext(tx, inboxId, await tx.execute(sql`
14516
- UPDATE inbox_entries
14517
- SET status = 'delivered', delivered_at = NOW()
14518
- WHERE id IN (
14519
- SELECT id FROM inbox_entries
14520
- WHERE inbox_id = ${inboxId}
14521
- AND message_id = ${messageId}
14522
- AND status = 'pending'
14523
- AND notify = true
14524
- ORDER BY created_at
14525
- LIMIT ${PUSH_CLAIM_BATCH_LIMIT}
14526
- FOR UPDATE SKIP LOCKED
14527
- )
14528
- RETURNING *
14529
- `));
14572
+ const targetIds = tx.select({ id: inboxEntries.id }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.messageId, messageId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, true))).orderBy(asc(inboxEntries.createdAt)).limit(PUSH_CLAIM_BATCH_LIMIT).for("update", { skipLocked: true });
14573
+ return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
14574
+ status: "delivered",
14575
+ deliveredAt: /* @__PURE__ */ new Date()
14576
+ }).where(inArray(inboxEntries.id, targetIds)).returning());
14530
14577
  }));
14531
14578
  }
14532
14579
  /**
@@ -14549,7 +14596,7 @@ async function claimBacklogForPush(db, inboxId, limit) {
14549
14596
  * `PRECEDING_CONTEXT_WINDOW_SECONDS`. Returned messages are oldest-first.
14550
14597
  *
14551
14598
  * Side effect: bulk-ack ALL silent pending rows in each chat with
14552
- * created_at < latest_trigger.created_at — including ones that fell outside
14599
+ * createdAt < latest_trigger.createdAt — including ones that fell outside
14553
14600
  * the window/cap. Otherwise stale silent rows would accumulate and re-load
14554
14601
  * on every poll.
14555
14602
  */
@@ -14557,51 +14604,41 @@ async function collectPrecedingContext(tx, inboxId, triggers) {
14557
14604
  const result = /* @__PURE__ */ new Map();
14558
14605
  const byChat = /* @__PURE__ */ new Map();
14559
14606
  for (const t of triggers) {
14560
- if (t.chat_id === null) continue;
14561
- const list = byChat.get(t.chat_id) ?? [];
14607
+ if (t.chatId === null) continue;
14608
+ const list = byChat.get(t.chatId) ?? [];
14562
14609
  list.push(t);
14563
- byChat.set(t.chat_id, list);
14610
+ byChat.set(t.chatId, list);
14564
14611
  }
14565
14612
  for (const [chatId, chatTriggers] of byChat) {
14566
- chatTriggers.sort((a, b) => a.created_at.localeCompare(b.created_at));
14613
+ chatTriggers.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
14567
14614
  let prevCreatedAt = null;
14568
14615
  for (const trigger of chatTriggers) {
14569
- const preceding = (await tx.execute(sql`
14570
- SELECT ie.id, m.id AS message_id, m.sender_id, m.format, m.content, m.metadata,
14571
- m.created_at
14572
- FROM inbox_entries ie
14573
- JOIN messages m ON m.id = ie.message_id
14574
- WHERE ie.inbox_id = ${inboxId}
14575
- AND ie.chat_id = ${chatId}
14576
- AND ie.status = 'pending'
14577
- AND ie.notify = false
14578
- AND ie.created_at < ${trigger.created_at}
14579
- ${prevCreatedAt === null ? sql`` : sql`AND ie.created_at > ${prevCreatedAt}`}
14580
- AND ie.created_at > NOW() - make_interval(secs => ${PRECEDING_CONTEXT_WINDOW_SECONDS})
14581
- ORDER BY ie.created_at DESC
14582
- LIMIT ${50}
14583
- FOR UPDATE OF ie SKIP LOCKED
14584
- `)).map((r) => ({
14585
- id: r.message_id,
14586
- senderId: r.sender_id,
14616
+ const preceding = (await tx.select({
14617
+ messageId: messages.id,
14618
+ senderId: messages.senderId,
14619
+ format: messages.format,
14620
+ content: messages.content,
14621
+ metadata: messages.metadata,
14622
+ createdAt: messages.createdAt
14623
+ }).from(inboxEntries).innerJoin(messages, eq(messages.id, inboxEntries.messageId)).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, trigger.createdAt), prevCreatedAt === null ? void 0 : gt(inboxEntries.createdAt, prevCreatedAt), sql`${inboxEntries.createdAt} > NOW() - make_interval(secs => ${PRECEDING_CONTEXT_WINDOW_SECONDS})`)).orderBy(desc(inboxEntries.createdAt)).limit(50).for("update", {
14624
+ of: inboxEntries,
14625
+ skipLocked: true
14626
+ })).map((r) => ({
14627
+ id: r.messageId,
14628
+ senderId: r.senderId,
14587
14629
  format: r.format,
14588
14630
  content: r.content,
14589
14631
  metadata: r.metadata ?? {},
14590
- createdAt: r.created_at
14632
+ createdAt: r.createdAt.toISOString()
14591
14633
  })).reverse();
14592
14634
  result.set(trigger.id, preceding);
14593
- prevCreatedAt = trigger.created_at;
14635
+ prevCreatedAt = trigger.createdAt;
14594
14636
  }
14595
14637
  const latestTrigger = chatTriggers[chatTriggers.length - 1];
14596
- if (latestTrigger) await tx.execute(sql`
14597
- UPDATE inbox_entries
14598
- SET status = 'acked', acked_at = NOW()
14599
- WHERE inbox_id = ${inboxId}
14600
- AND chat_id = ${chatId}
14601
- AND status = 'pending'
14602
- AND notify = false
14603
- AND created_at < ${latestTrigger.created_at}
14604
- `);
14638
+ if (latestTrigger) await tx.update(inboxEntries).set({
14639
+ status: "acked",
14640
+ ackedAt: /* @__PURE__ */ new Date()
14641
+ }).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, latestTrigger.createdAt)));
14605
14642
  }
14606
14643
  return result;
14607
14644
  }
@@ -14644,23 +14681,14 @@ async function renewEntry(db, entryId, inboxId) {
14644
14681
  return entry;
14645
14682
  }
14646
14683
  async function resetTimedOutEntries(db, timeoutSeconds = DEFAULT_INBOX_TIMEOUT_SECONDS, maxRetries = DEFAULT_MAX_RETRY_COUNT) {
14647
- const resetResult = await db.execute(sql`
14648
- UPDATE inbox_entries SET status = 'pending', retry_count = retry_count + 1
14649
- WHERE status = 'delivered'
14650
- AND delivered_at < NOW() - make_interval(secs => ${timeoutSeconds})
14651
- AND retry_count < ${maxRetries}
14652
- RETURNING id
14653
- `);
14654
- const failedResult = await db.execute(sql`
14655
- UPDATE inbox_entries SET status = 'failed'
14656
- WHERE status = 'delivered'
14657
- AND delivered_at < NOW() - make_interval(secs => ${timeoutSeconds})
14658
- AND retry_count >= ${maxRetries}
14659
- RETURNING id
14660
- `);
14684
+ const reset = await db.update(inboxEntries).set({
14685
+ status: "pending",
14686
+ retryCount: sql`${inboxEntries.retryCount} + 1`
14687
+ }).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, lt(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
14688
+ const failed = await db.update(inboxEntries).set({ status: "failed" }).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, gte(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
14661
14689
  return {
14662
- reset: resetResult.length,
14663
- failed: failedResult.length
14690
+ reset: reset.length,
14691
+ failed: failed.length
14664
14692
  };
14665
14693
  }
14666
14694
  /** Default age (30 days) past which silent rows that no notify-true delivery
@@ -14679,7 +14707,7 @@ const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
14679
14707
  * `(inbox_id, message_id, chat_id)` means leaving them around blocks
14680
14708
  * legitimate retries with the same key.
14681
14709
  *
14682
- * 2. `notify=false AND status='pending' AND created_at < NOW() - maxAge` —
14710
+ * 2. `notify=false AND status='pending' AND createdAt < NOW() - maxAge` —
14683
14711
  * stale silent rows that no trigger ever caught up with. After 30
14684
14712
  * days they're useless as preceding context (the @mention almost
14685
14713
  * certainly already happened or the chat went dormant).
@@ -14688,22 +14716,11 @@ const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
14688
14716
  * can log meaningful counts.
14689
14717
  */
14690
14718
  async function pruneStaleSilentEntries(db, maxAgeSeconds = SILENT_ROW_GC_MAX_AGE_SECONDS) {
14691
- const ackedResult = await db.execute(sql`
14692
- DELETE FROM inbox_entries
14693
- WHERE notify = false
14694
- AND status = 'acked'
14695
- RETURNING id
14696
- `);
14697
- const staleResult = await db.execute(sql`
14698
- DELETE FROM inbox_entries
14699
- WHERE notify = false
14700
- AND status = 'pending'
14701
- AND created_at < NOW() - make_interval(secs => ${maxAgeSeconds})
14702
- RETURNING id
14703
- `);
14719
+ const ackedDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "acked"))).returning({ id: inboxEntries.id });
14720
+ const stalePendingDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "pending"), sql`${inboxEntries.createdAt} < NOW() - make_interval(secs => ${maxAgeSeconds})`)).returning({ id: inboxEntries.id });
14704
14721
  return {
14705
- ackedDeleted: ackedResult.length,
14706
- stalePendingDeleted: staleResult.length
14722
+ ackedDeleted: ackedDeleted.length,
14723
+ stalePendingDeleted: stalePendingDeleted.length
14707
14724
  };
14708
14725
  }
14709
14726
  async function agentInboxRoutes(app) {
@@ -15581,8 +15598,9 @@ function clientWsRoutes(notifier, instanceId) {
15581
15598
  }
15582
15599
  boundAgents.clear();
15583
15600
  if (clientId) {
15601
+ const stillActive = isActiveClientConnection(clientId, socket);
15584
15602
  removeClientConnection(clientId, socket);
15585
- try {
15603
+ if (stillActive) try {
15586
15604
  await disconnectClient(app.db, clientId);
15587
15605
  } catch {}
15588
15606
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-team-foundation/first-tree-hub",
3
- "version": "0.10.14",
3
+ "version": "0.10.15",
4
4
  "type": "module",
5
5
  "description": "First Tree Hub — unified CLI for server, client, and agent management",
6
6
  "exports": {