@agent-team-foundation/first-tree-hub 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,11 @@
1
1
  import { ZodError, z } from "zod";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { parse, stringify } from "yaml";
5
5
  import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
6
6
  import { homedir } from "node:os";
7
7
  import bcrypt from "bcrypt";
8
- import { and, desc, eq, inArray, isNotNull, isNull, lt, ne, sql } from "drizzle-orm";
8
+ import { and, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, sql } from "drizzle-orm";
9
9
  import { drizzle } from "drizzle-orm/postgres-js";
10
10
  import postgres from "postgres";
11
11
  import { EventEmitter } from "node:events";
@@ -36,7 +36,7 @@ import { SignJWT, jwtVerify } from "jose";
36
36
  import { Client, EventDispatcher, LoggerLevel, WSClient } from "@larksuiteoapi/node-sdk";
37
37
  //#region ../shared/dist/config/index.mjs
38
38
  /** Declare a config field with a Zod schema and optional metadata. */
39
- function field(schema, options) {
39
+ function field$1(schema, options) {
40
40
  return {
41
41
  _tag: "field",
42
42
  _type: void 0,
@@ -45,46 +45,47 @@ function field(schema, options) {
45
45
  };
46
46
  }
47
47
  /** Mark a config group as optional — present only when at least one field has an explicit value. */
48
- function optional(shape) {
48
+ function optional$1(shape) {
49
49
  return {
50
50
  _tag: "optional",
51
51
  shape
52
52
  };
53
53
  }
54
54
  /** Define a config shape. Identity function used for type inference. */
55
- function defineConfig(shape) {
55
+ function defineConfig$1(shape) {
56
56
  return shape;
57
57
  }
58
- const agentConfigSchema = defineConfig({
59
- token: field(z.string(), { secret: true }),
60
- type: field(z.string().default("claude-code")),
61
- cwd: field(z.string().optional()),
62
- concurrency: field(z.number().int().positive().default(5)),
58
+ const agentConfigSchema = defineConfig$1({
59
+ token: field$1(z.string(), { secret: true }),
60
+ type: field$1(z.string().default("claude-code")),
61
+ concurrency: field$1(z.number().int().positive().default(5)),
63
62
  session: {
64
- idle_timeout: field(z.number().int().positive().default(300)),
65
- max_sessions: field(z.number().int().positive().default(10))
63
+ idle_timeout: field$1(z.number().int().positive().default(300)),
64
+ max_sessions: field$1(z.number().int().positive().default(10))
66
65
  }
67
66
  });
68
67
  /** Store the resolved config as a singleton. Called by initConfig(). */
69
68
  function setConfig(config) {}
70
69
  /** Reset the config singleton. For testing only. */
71
70
  function resetConfig() {}
72
- const clientConfigSchema = defineConfig({
73
- server: { url: field(z.string(), {
71
+ const clientConfigSchema = defineConfig$1({
72
+ server: { url: field$1(z.string(), {
74
73
  env: "FIRST_TREE_HUB_SERVER_URL",
75
74
  prompt: {
76
75
  message: "Server URL:",
77
76
  default: "http://localhost:8000"
78
77
  }
79
78
  }) },
80
- logLevel: field(z.enum([
79
+ logLevel: field$1(z.enum([
81
80
  "debug",
82
81
  "info",
83
82
  "warn",
84
83
  "error"
85
84
  ]).default("info"), { env: "FIRST_TREE_HUB_LOG_LEVEL" })
86
85
  });
87
- const DEFAULT_CONFIG_DIR = join(homedir(), ".first-tree-hub");
86
+ const DEFAULT_HOME_DIR$1 = join(homedir(), ".first-tree-hub");
87
+ const DEFAULT_CONFIG_DIR = join(DEFAULT_HOME_DIR$1, "config");
88
+ const DEFAULT_DATA_DIR$1 = join(DEFAULT_HOME_DIR$1, "data");
88
89
  function isFieldDef(value) {
89
90
  return typeof value === "object" && value !== null && "_tag" in value && value._tag === "field";
90
91
  }
@@ -425,9 +426,9 @@ function loadAgents(options) {
425
426
  }
426
427
  return result;
427
428
  }
428
- const serverConfigSchema = defineConfig({
429
+ const serverConfigSchema = defineConfig$1({
429
430
  database: {
430
- url: field(z.string(), {
431
+ url: field$1(z.string(), {
431
432
  env: "FIRST_TREE_HUB_DATABASE_URL",
432
433
  auto: "docker-pg",
433
434
  prompt: {
@@ -442,34 +443,34 @@ const serverConfigSchema = defineConfig({
442
443
  }]
443
444
  }
444
445
  }),
445
- provider: field(z.enum(["docker", "external"]).default("docker"))
446
+ provider: field$1(z.enum(["docker", "external"]).default("docker"))
446
447
  },
447
448
  server: {
448
- port: field(z.number().default(8e3), { env: "FIRST_TREE_HUB_PORT" }),
449
- host: field(z.string().default("127.0.0.1"), { env: "FIRST_TREE_HUB_HOST" })
449
+ port: field$1(z.number().default(8e3), { env: "FIRST_TREE_HUB_PORT" }),
450
+ host: field$1(z.string().default("127.0.0.1"), { env: "FIRST_TREE_HUB_HOST" })
450
451
  },
451
452
  secrets: {
452
- jwtSecret: field(z.string(), {
453
+ jwtSecret: field$1(z.string(), {
453
454
  env: "FIRST_TREE_HUB_JWT_SECRET",
454
455
  auto: "random:base64url:32",
455
456
  secret: true
456
457
  }),
457
- encryptionKey: field(z.string(), {
458
+ encryptionKey: field$1(z.string(), {
458
459
  env: "FIRST_TREE_HUB_ENCRYPTION_KEY",
459
460
  auto: "random:hex:32",
460
461
  secret: true
461
462
  })
462
463
  },
463
464
  contextTree: {
464
- repo: field(z.string(), {
465
+ repo: field$1(z.string(), {
465
466
  env: "FIRST_TREE_HUB_CONTEXT_TREE_REPO",
466
467
  prompt: { message: "Context Tree repo URL (e.g. https://github.com/org/first-tree):" }
467
468
  }),
468
- branch: field(z.string().default("main")),
469
- syncInterval: field(z.number().default(60))
469
+ branch: field$1(z.string().default("main")),
470
+ syncInterval: field$1(z.number().default(60))
470
471
  },
471
472
  github: {
472
- token: field(z.string(), {
473
+ token: field$1(z.string(), {
473
474
  env: "FIRST_TREE_HUB_GITHUB_TOKEN",
474
475
  secret: true,
475
476
  prompt: {
@@ -477,16 +478,16 @@ const serverConfigSchema = defineConfig({
477
478
  type: "password"
478
479
  }
479
480
  }),
480
- webhookSecret: field(z.string().optional(), {
481
+ webhookSecret: field$1(z.string().optional(), {
481
482
  env: "FIRST_TREE_HUB_GITHUB_WEBHOOK_SECRET",
482
483
  secret: true
483
484
  })
484
485
  },
485
- cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
486
- rateLimit: optional({
487
- max: field(z.number().default(100), { env: "FIRST_TREE_HUB_RATE_LIMIT_MAX" }),
488
- loginMax: field(z.number().default(5), { env: "FIRST_TREE_HUB_RATE_LIMIT_LOGIN_MAX" }),
489
- webhookMax: field(z.number().default(60), { env: "FIRST_TREE_HUB_RATE_LIMIT_WEBHOOK_MAX" })
486
+ cors: optional$1({ origin: field$1(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
487
+ rateLimit: optional$1({
488
+ max: field$1(z.number().default(100), { env: "FIRST_TREE_HUB_RATE_LIMIT_MAX" }),
489
+ loginMax: field$1(z.number().default(5), { env: "FIRST_TREE_HUB_RATE_LIMIT_LOGIN_MAX" }),
490
+ webhookMax: field$1(z.number().default(60), { env: "FIRST_TREE_HUB_RATE_LIMIT_WEBHOOK_MAX" })
490
491
  })
491
492
  });
492
493
  //#endregion
@@ -22846,6 +22847,7 @@ function Aa({ prompt: $, options: X }) {
22846
22847
  }
22847
22848
  //#endregion
22848
22849
  //#region ../client/dist/index.mjs
22850
+ const FETCH_TIMEOUT_MS = 15e3;
22849
22851
  var FirstTreeHubSDK = class {
22850
22852
  _baseUrl;
22851
22853
  _token;
@@ -22868,9 +22870,16 @@ var FirstTreeHubSDK = class {
22868
22870
  agentId: agent.id,
22869
22871
  inboxId: agent.inboxId,
22870
22872
  status: agent.status,
22871
- displayName: agent.displayName
22873
+ displayName: agent.displayName,
22874
+ type: agent.type,
22875
+ delegateMention: agent.delegateMention ?? null,
22876
+ metadata: agent.metadata ?? {}
22872
22877
  };
22873
22878
  }
22879
+ /** Fetch Context Tree configuration from the server. */
22880
+ async getContextTreeConfig() {
22881
+ return this.requestJson("/api/v1/agent/context-tree");
22882
+ }
22874
22883
  /** Fetch pending inbox entries. */
22875
22884
  async pull(limit = 10) {
22876
22885
  return { entries: await this.requestJson(`/api/v1/agent/inbox?limit=${limit}`) };
@@ -22925,9 +22934,12 @@ var FirstTreeHubSDK = class {
22925
22934
  const url = `${this._baseUrl}${path}`;
22926
22935
  const headers = { Authorization: `Bearer ${this._token}` };
22927
22936
  if (init?.body) headers["Content-Type"] = "application/json";
22937
+ const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS);
22938
+ const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
22928
22939
  return fetch(url, {
22929
22940
  ...init,
22930
- headers
22941
+ headers,
22942
+ signal
22931
22943
  });
22932
22944
  }
22933
22945
  async toSdkError(response) {
@@ -22952,12 +22964,16 @@ const DEFAULT_POLLING_INTERVAL = 5e3;
22952
22964
  const DEFAULT_PULL_LIMIT = 10;
22953
22965
  const RECONNECT_BASE_MS = 1e3;
22954
22966
  const RECONNECT_MAX_MS = 3e4;
22967
+ const WS_CONNECT_TIMEOUT_MS = 1e4;
22968
+ const WS_PING_INTERVAL_MS = 3e3;
22955
22969
  var AgentConnection = class extends EventEmitter {
22956
22970
  sdk;
22957
22971
  _state = "disconnected";
22958
22972
  _agent = null;
22959
22973
  handler = null;
22960
22974
  ws = null;
22975
+ wsConnectTimer = null;
22976
+ wsPingTimer = null;
22961
22977
  pollingTimer = null;
22962
22978
  reconnectTimer = null;
22963
22979
  reconnectAttempt = 0;
@@ -22968,6 +22984,7 @@ var AgentConnection = class extends EventEmitter {
22968
22984
  pollingInterval;
22969
22985
  pullLimit;
22970
22986
  serverUrl;
22987
+ rateLimitedUntil = 0;
22971
22988
  constructor(config) {
22972
22989
  super();
22973
22990
  this.serverUrl = config.serverUrl.replace(/\/+$/, "");
@@ -23010,10 +23027,19 @@ var AgentConnection = class extends EventEmitter {
23010
23027
  }
23011
23028
  openWebSocket() {
23012
23029
  const ws = new WebSocket$1(`${this.serverUrl.replace(/^http/, "ws")}/api/v1/agent/ws/inbox`, { headers: { Authorization: `Bearer ${this.token}` } });
23030
+ this.wsConnectTimer = setTimeout(() => {
23031
+ this.wsConnectTimer = null;
23032
+ if (ws.readyState === WebSocket$1.CONNECTING) ws.terminate();
23033
+ }, WS_CONNECT_TIMEOUT_MS);
23013
23034
  ws.on("open", () => {
23035
+ if (this.wsConnectTimer) {
23036
+ clearTimeout(this.wsConnectTimer);
23037
+ this.wsConnectTimer = null;
23038
+ }
23014
23039
  this.reconnectAttempt = 0;
23015
23040
  this._state = "connected";
23016
23041
  this.emit("connected");
23042
+ this.startPing();
23017
23043
  this.startPolling();
23018
23044
  this.pullAndDispatch();
23019
23045
  });
@@ -23023,14 +23049,17 @@ var AgentConnection = class extends EventEmitter {
23023
23049
  } catch {}
23024
23050
  });
23025
23051
  ws.on("close", () => {
23052
+ this.stopPing();
23026
23053
  if (!this.closing) this.scheduleReconnect();
23027
23054
  });
23028
23055
  ws.on("error", (err) => {
23056
+ if (this.handleRateLimit(err)) return;
23029
23057
  this.emit("error", err);
23030
23058
  });
23031
23059
  this.ws = ws;
23032
23060
  }
23033
23061
  scheduleReconnect() {
23062
+ if (Date.now() < this.rateLimitedUntil) return;
23034
23063
  this._state = "reconnecting";
23035
23064
  this.reconnectAttempt++;
23036
23065
  this.emit("reconnecting", this.reconnectAttempt);
@@ -23041,6 +23070,18 @@ var AgentConnection = class extends EventEmitter {
23041
23070
  if (!this.closing) this.openWebSocket();
23042
23071
  }, delay);
23043
23072
  }
23073
+ startPing() {
23074
+ this.stopPing();
23075
+ this.wsPingTimer = setInterval(() => {
23076
+ if (this.ws?.readyState === WebSocket$1.OPEN) this.ws.ping();
23077
+ }, WS_PING_INTERVAL_MS);
23078
+ }
23079
+ stopPing() {
23080
+ if (this.wsPingTimer) {
23081
+ clearInterval(this.wsPingTimer);
23082
+ this.wsPingTimer = null;
23083
+ }
23084
+ }
23044
23085
  startPolling() {
23045
23086
  if (this.pollingTimer) return;
23046
23087
  this.pollingTimer = setInterval(() => {
@@ -23055,6 +23096,7 @@ var AgentConnection = class extends EventEmitter {
23055
23096
  }
23056
23097
  async pullAndDispatch() {
23057
23098
  if (this.closing || !this.handler) return;
23099
+ if (Date.now() < this.rateLimitedUntil) return;
23058
23100
  if (this.isPulling) {
23059
23101
  this.pullAgain = true;
23060
23102
  return;
@@ -23074,13 +23116,42 @@ var AgentConnection = class extends EventEmitter {
23074
23116
  }
23075
23117
  } while (this.pullAgain && !this.closing);
23076
23118
  } catch (err) {
23119
+ if (this.handleRateLimit(err)) return;
23077
23120
  this.emit("error", err instanceof Error ? err : new Error(String(err)));
23078
23121
  } finally {
23079
23122
  this.isPulling = false;
23080
23123
  }
23081
23124
  }
23125
+ /** Detect 429 responses and pause all activity. Returns true if rate-limited. */
23126
+ handleRateLimit(err) {
23127
+ const msg = err instanceof Error ? err.message : String(err);
23128
+ if (!msg.includes("429") && !msg.toLowerCase().includes("rate limit")) return false;
23129
+ const backoff = 6e4;
23130
+ this.rateLimitedUntil = Date.now() + backoff;
23131
+ this.emit("error", /* @__PURE__ */ new Error(`Rate limited, pausing for ${backoff / 1e3}s`));
23132
+ this.stopPolling();
23133
+ if (this.reconnectTimer) {
23134
+ clearTimeout(this.reconnectTimer);
23135
+ this.reconnectTimer = null;
23136
+ }
23137
+ setTimeout(() => {
23138
+ if (this.closing) return;
23139
+ this.rateLimitedUntil = 0;
23140
+ this.startPolling();
23141
+ if (!this.ws || this.ws.readyState !== WebSocket$1.OPEN) {
23142
+ this.reconnectAttempt = 0;
23143
+ this.openWebSocket();
23144
+ }
23145
+ }, backoff);
23146
+ return true;
23147
+ }
23082
23148
  clearTimers() {
23083
23149
  this.stopPolling();
23150
+ this.stopPing();
23151
+ if (this.wsConnectTimer) {
23152
+ clearTimeout(this.wsConnectTimer);
23153
+ this.wsConnectTimer = null;
23154
+ }
23084
23155
  if (this.reconnectTimer) {
23085
23156
  clearTimeout(this.reconnectTimer);
23086
23157
  this.reconnectTimer = null;
@@ -23102,6 +23173,301 @@ function getHandlerFactory(type) {
23102
23173
  }
23103
23174
  return factory;
23104
23175
  }
23176
+ /** Declare a config field with a Zod schema and optional metadata. */
23177
+ function field(schema, options) {
23178
+ return {
23179
+ _tag: "field",
23180
+ _type: void 0,
23181
+ schema,
23182
+ options: options ?? {}
23183
+ };
23184
+ }
23185
+ /** Mark a config group as optional — present only when at least one field has an explicit value. */
23186
+ function optional(shape) {
23187
+ return {
23188
+ _tag: "optional",
23189
+ shape
23190
+ };
23191
+ }
23192
+ /** Define a config shape. Identity function used for type inference. */
23193
+ function defineConfig(shape) {
23194
+ return shape;
23195
+ }
23196
+ defineConfig({
23197
+ token: field(z.string(), { secret: true }),
23198
+ type: field(z.string().default("claude-code")),
23199
+ concurrency: field(z.number().int().positive().default(5)),
23200
+ session: {
23201
+ idle_timeout: field(z.number().int().positive().default(300)),
23202
+ max_sessions: field(z.number().int().positive().default(10))
23203
+ }
23204
+ });
23205
+ defineConfig({
23206
+ server: { url: field(z.string(), {
23207
+ env: "FIRST_TREE_HUB_SERVER_URL",
23208
+ prompt: {
23209
+ message: "Server URL:",
23210
+ default: "http://localhost:8000"
23211
+ }
23212
+ }) },
23213
+ logLevel: field(z.enum([
23214
+ "debug",
23215
+ "info",
23216
+ "warn",
23217
+ "error"
23218
+ ]).default("info"), { env: "FIRST_TREE_HUB_LOG_LEVEL" })
23219
+ });
23220
+ const DEFAULT_HOME_DIR = join(homedir(), ".first-tree-hub");
23221
+ join(DEFAULT_HOME_DIR, "config");
23222
+ const DEFAULT_DATA_DIR = join(DEFAULT_HOME_DIR, "data");
23223
+ defineConfig({
23224
+ database: {
23225
+ url: field(z.string(), {
23226
+ env: "FIRST_TREE_HUB_DATABASE_URL",
23227
+ auto: "docker-pg",
23228
+ prompt: {
23229
+ message: "PostgreSQL:",
23230
+ type: "select",
23231
+ choices: [{
23232
+ name: "Auto-provision via Docker",
23233
+ value: "__auto__"
23234
+ }, {
23235
+ name: "Provide connection URL",
23236
+ value: "__input__"
23237
+ }]
23238
+ }
23239
+ }),
23240
+ provider: field(z.enum(["docker", "external"]).default("docker"))
23241
+ },
23242
+ server: {
23243
+ port: field(z.number().default(8e3), { env: "FIRST_TREE_HUB_PORT" }),
23244
+ host: field(z.string().default("127.0.0.1"), { env: "FIRST_TREE_HUB_HOST" })
23245
+ },
23246
+ secrets: {
23247
+ jwtSecret: field(z.string(), {
23248
+ env: "FIRST_TREE_HUB_JWT_SECRET",
23249
+ auto: "random:base64url:32",
23250
+ secret: true
23251
+ }),
23252
+ encryptionKey: field(z.string(), {
23253
+ env: "FIRST_TREE_HUB_ENCRYPTION_KEY",
23254
+ auto: "random:hex:32",
23255
+ secret: true
23256
+ })
23257
+ },
23258
+ contextTree: {
23259
+ repo: field(z.string(), {
23260
+ env: "FIRST_TREE_HUB_CONTEXT_TREE_REPO",
23261
+ prompt: { message: "Context Tree repo URL (e.g. https://github.com/org/first-tree):" }
23262
+ }),
23263
+ branch: field(z.string().default("main")),
23264
+ syncInterval: field(z.number().default(60))
23265
+ },
23266
+ github: {
23267
+ token: field(z.string(), {
23268
+ env: "FIRST_TREE_HUB_GITHUB_TOKEN",
23269
+ secret: true,
23270
+ prompt: {
23271
+ message: "GitHub token (create at https://github.com/settings/tokens → repo scope):",
23272
+ type: "password"
23273
+ }
23274
+ }),
23275
+ webhookSecret: field(z.string().optional(), {
23276
+ env: "FIRST_TREE_HUB_GITHUB_WEBHOOK_SECRET",
23277
+ secret: true
23278
+ })
23279
+ },
23280
+ cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
23281
+ rateLimit: optional({
23282
+ max: field(z.number().default(100), { env: "FIRST_TREE_HUB_RATE_LIMIT_MAX" }),
23283
+ loginMax: field(z.number().default(5), { env: "FIRST_TREE_HUB_RATE_LIMIT_LOGIN_MAX" }),
23284
+ webhookMax: field(z.number().default(60), { env: "FIRST_TREE_HUB_RATE_LIMIT_WEBHOOK_MAX" })
23285
+ })
23286
+ });
23287
+ const CONTEXT_TREE_DIR = join(DEFAULT_DATA_DIR, "context-tree");
23288
+ /**
23289
+ * Sync the shared Context Tree git clone.
23290
+ *
23291
+ * Clones on first run, pulls on subsequent runs.
23292
+ * Returns the clone path on success, null on failure (graceful degradation).
23293
+ */
23294
+ async function syncContextTree(serverUrl, token, log) {
23295
+ try {
23296
+ execFileSync("git", ["--version"], { stdio: "ignore" });
23297
+ } catch {
23298
+ log("Context Tree sync skipped: git is not installed");
23299
+ return null;
23300
+ }
23301
+ let repo;
23302
+ let branch;
23303
+ try {
23304
+ const config = await new FirstTreeHubSDK({
23305
+ serverUrl,
23306
+ token
23307
+ }).getContextTreeConfig();
23308
+ repo = config.repo;
23309
+ branch = config.branch;
23310
+ } catch (err) {
23311
+ log(`Context Tree sync skipped: failed to fetch config from server (${err instanceof Error ? err.message : String(err)})`);
23312
+ return null;
23313
+ }
23314
+ try {
23315
+ if (existsSync(join(CONTEXT_TREE_DIR, ".git"))) {
23316
+ if (execFileSync("git", [
23317
+ "rev-parse",
23318
+ "--abbrev-ref",
23319
+ "HEAD"
23320
+ ], {
23321
+ cwd: CONTEXT_TREE_DIR,
23322
+ encoding: "utf-8",
23323
+ timeout: 5e3
23324
+ }).trim() !== branch) {
23325
+ execFileSync("git", ["checkout", branch], {
23326
+ cwd: CONTEXT_TREE_DIR,
23327
+ stdio: "pipe",
23328
+ timeout: 1e4
23329
+ });
23330
+ log(`Context Tree switched to branch ${branch}`);
23331
+ }
23332
+ execFileSync("git", ["pull", "--ff-only"], {
23333
+ cwd: CONTEXT_TREE_DIR,
23334
+ stdio: "pipe",
23335
+ timeout: 3e4
23336
+ });
23337
+ log(`Context Tree updated (pull)`);
23338
+ } else {
23339
+ mkdirSync(CONTEXT_TREE_DIR, { recursive: true });
23340
+ execFileSync("git", [
23341
+ "clone",
23342
+ "--branch",
23343
+ branch,
23344
+ "--single-branch",
23345
+ repo,
23346
+ CONTEXT_TREE_DIR
23347
+ ], {
23348
+ stdio: "pipe",
23349
+ timeout: 6e4
23350
+ });
23351
+ log(`Context Tree cloned from ${repo} (branch: ${branch})`);
23352
+ }
23353
+ return CONTEXT_TREE_DIR;
23354
+ } catch (err) {
23355
+ const msg = err instanceof Error ? err.message : String(err);
23356
+ log(`Context Tree sync failed: ${msg}`);
23357
+ log("Check that git credentials (SSH key or credential helper) are configured for this repo");
23358
+ if ((msg.includes("cannot fast-forward") || msg.includes("not possible to fast-forward") || msg.includes("CONFLICT")) && existsSync(join(CONTEXT_TREE_DIR, ".git"))) {
23359
+ log("Diverged history detected, attempting fresh clone...");
23360
+ try {
23361
+ rmSync(CONTEXT_TREE_DIR, {
23362
+ recursive: true,
23363
+ force: true
23364
+ });
23365
+ mkdirSync(CONTEXT_TREE_DIR, { recursive: true });
23366
+ execFileSync("git", [
23367
+ "clone",
23368
+ "--branch",
23369
+ branch,
23370
+ "--single-branch",
23371
+ repo,
23372
+ CONTEXT_TREE_DIR
23373
+ ], {
23374
+ stdio: "pipe",
23375
+ timeout: 6e4
23376
+ });
23377
+ log("Context Tree re-cloned successfully");
23378
+ return CONTEXT_TREE_DIR;
23379
+ } catch {
23380
+ log("Context Tree re-clone also failed, continuing without context");
23381
+ }
23382
+ }
23383
+ if (existsSync(join(CONTEXT_TREE_DIR, ".git"))) {
23384
+ log("Using existing Context Tree clone despite sync failure");
23385
+ return CONTEXT_TREE_DIR;
23386
+ }
23387
+ return null;
23388
+ }
23389
+ }
23390
+ /**
23391
+ * Bootstrap a workspace with .agent/ directory files.
23392
+ *
23393
+ * Writes identity.json, context/self.md (if context tree available), and tools.md.
23394
+ * Designed to be called on every handler start() and conditionally on resume().
23395
+ */
23396
+ function bootstrapWorkspace(options) {
23397
+ const { workspacePath, identity, contextTreePath, serverUrl, chatId } = options;
23398
+ const agentDir = join(workspacePath, ".agent");
23399
+ const contextDir = join(agentDir, "context");
23400
+ if (existsSync(contextDir)) rmSync(contextDir, {
23401
+ recursive: true,
23402
+ force: true
23403
+ });
23404
+ mkdirSync(contextDir, { recursive: true });
23405
+ const identityData = {
23406
+ agentId: identity.agentId,
23407
+ displayName: identity.displayName,
23408
+ type: identity.type,
23409
+ delegateMention: identity.delegateMention,
23410
+ metadata: identity.metadata,
23411
+ chatId,
23412
+ serverUrl,
23413
+ contextTreePath
23414
+ };
23415
+ writeFileSync(join(agentDir, "identity.json"), JSON.stringify(identityData, null, 2), "utf-8");
23416
+ if (contextTreePath) {
23417
+ const selfNodePath = join(contextTreePath, "members", identity.agentId, "NODE.md");
23418
+ if (existsSync(selfNodePath)) copyFileSync(selfNodePath, join(contextDir, "self.md"));
23419
+ const agentMdPath = join(contextTreePath, "AGENT.md");
23420
+ if (existsSync(agentMdPath)) copyFileSync(agentMdPath, join(contextDir, "agent-instructions.md"));
23421
+ const rootNodePath = join(contextTreePath, "NODE.md");
23422
+ if (existsSync(rootNodePath)) copyFileSync(rootNodePath, join(contextDir, "domain-map.md"));
23423
+ } else writeFileSync(join(contextDir, "degraded.md"), "Context Tree is not available for this session.\nOrganizational context, domain structure, and ownership information are not loaded.\n", "utf-8");
23424
+ writeFileSync(join(agentDir, "tools.md"), generateToolsDoc(), "utf-8");
23425
+ }
23426
+ function generateToolsDoc() {
23427
+ return `# Agent Hub SDK
23428
+
23429
+ ## How You Communicate
23430
+
23431
+ You are running inside **Agent Hub**, a messaging platform for agent teams.
23432
+
23433
+ - Messages from other team members arrive as your prompt input
23434
+ - Each message includes a \`[From: sender-id]\` header so you know who sent it
23435
+ - **Your final text response is automatically delivered** to the chat — just respond normally
23436
+ - For **proactive communication** (sending to other agents, other chats, or structured data),
23437
+ use the curl API endpoints below
23438
+ - **Use your judgment about when to respond.** Not every message requires a reply.
23439
+ Your role and responsibilities (defined in your profile above) guide your behavior
23440
+
23441
+ ## Environment Variables
23442
+
23443
+ These are injected automatically when the agent process starts:
23444
+
23445
+ | Variable | Description |
23446
+ |----------|-------------|
23447
+ | \`FIRST_TREE_HUB_SERVER_URL\` | Server address for API calls |
23448
+ | \`FIRST_TREE_HUB_AGENT_TOKEN\` | Bearer token for authentication |
23449
+ | \`FIRST_TREE_HUB_CHAT_ID\` | Current chat context ID |
23450
+ | \`FIRST_TREE_HUB_AGENT_ID\` | Your agent ID |
23451
+
23452
+ ## Sending Messages
23453
+
23454
+ Use curl or any HTTP client with the bearer token:
23455
+
23456
+ \`\`\`bash
23457
+ # Reply in current chat
23458
+ curl -X POST "$FIRST_TREE_HUB_SERVER_URL/api/v1/agent/chats/$FIRST_TREE_HUB_CHAT_ID/messages" \\
23459
+ -H "Authorization: Bearer $FIRST_TREE_HUB_AGENT_TOKEN" \\
23460
+ -H "Content-Type: application/json" \\
23461
+ -d '{"format": "text", "content": "your message"}'
23462
+
23463
+ # Send to another agent directly
23464
+ curl -X POST "$FIRST_TREE_HUB_SERVER_URL/api/v1/agent/agents/{agentId}/messages" \\
23465
+ -H "Authorization: Bearer $FIRST_TREE_HUB_AGENT_TOKEN" \\
23466
+ -H "Content-Type: application/json" \\
23467
+ -d '{"format": "text", "content": "your message"}'
23468
+ \`\`\`
23469
+ `;
23470
+ }
23105
23471
  /**
23106
23472
  * InputController — push-based async iterable bridge.
23107
23473
  *
@@ -23156,6 +23522,46 @@ var InputController = class {
23156
23522
  });
23157
23523
  }
23158
23524
  };
23525
+ const DEFAULT_WORKSPACE_TTL_MS = 10080 * 60 * 1e3;
23526
+ /**
23527
+ * Acquire a per-chat workspace directory.
23528
+ * Creates the directory if it does not exist; returns the path if it does.
23529
+ */
23530
+ function acquireWorkspace(workspaceRoot, chatId) {
23531
+ const dir = join(workspaceRoot, chatId);
23532
+ mkdirSync(dir, { recursive: true });
23533
+ return dir;
23534
+ }
23535
+ /**
23536
+ * Clean stale workspace directories for an agent.
23537
+ *
23538
+ * A workspace is considered stale when:
23539
+ * 1. Its mtime is older than `ttlMs`
23540
+ * 2. Its chatId is NOT in the `activeChatIds` set
23541
+ *
23542
+ * Returns the list of removed chatIds.
23543
+ */
23544
+ function cleanWorkspaces(workspaceRoot, activeChatIds, ttlMs = DEFAULT_WORKSPACE_TTL_MS) {
23545
+ if (!existsSync(workspaceRoot)) return [];
23546
+ const now = Date.now();
23547
+ const removed = [];
23548
+ for (const entry of readdirSync(workspaceRoot)) {
23549
+ if (activeChatIds.has(entry)) continue;
23550
+ const entryPath = join(workspaceRoot, entry);
23551
+ try {
23552
+ const stat = statSync(entryPath);
23553
+ if (!stat.isDirectory()) continue;
23554
+ if (now - stat.mtimeMs > ttlMs) {
23555
+ rmSync(entryPath, {
23556
+ recursive: true,
23557
+ force: true
23558
+ });
23559
+ removed.push(entry);
23560
+ }
23561
+ } catch {}
23562
+ }
23563
+ return removed;
23564
+ }
23159
23565
  const MAX_RETRIES = 2;
23160
23566
  /**
23161
23567
  * Claude Code Handler — session-oriented handler using the Agent SDK.
@@ -23165,7 +23571,8 @@ const MAX_RETRIES = 2;
23165
23571
  * and session resume from disk for idle reclaim recovery.
23166
23572
  */
23167
23573
  const createClaudeCodeHandler = (config) => {
23168
- const cwd = config.cwd;
23574
+ const workspaceRoot = config.workspaceRoot;
23575
+ let cwd = null;
23169
23576
  let claudeSessionId = null;
23170
23577
  let currentQuery = null;
23171
23578
  let inputController = null;
@@ -23174,19 +23581,35 @@ const createClaudeCodeHandler = (config) => {
23174
23581
  let retryCount = 0;
23175
23582
  let ctx = null;
23176
23583
  function toSDKUserMessage(message, sessionId) {
23584
+ const rawContent = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
23177
23585
  return {
23178
23586
  type: "user",
23179
23587
  message: {
23180
23588
  role: "user",
23181
- content: typeof message.content === "string" ? message.content : JSON.stringify(message.content)
23589
+ content: message.senderId ? `[From: ${message.senderId}]\n\n${rawContent}` : rawContent
23182
23590
  },
23183
23591
  parent_tool_use_id: null,
23184
23592
  session_id: sessionId
23185
23593
  };
23186
23594
  }
23595
+ /**
23596
+ * Build env for the child Claude Code process.
23597
+ *
23598
+ * When the client runtime runs inside a Claude Code session (nested env),
23599
+ * process.env contains internal markers (CLAUDECODE, CLAUDE_CODE_ENTRYPOINT,
23600
+ * CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, npm_lifecycle_script) that cause the
23601
+ * child to enable Agent Teams infrastructure and use wrong init paths,
23602
+ * resulting in ~90s cold start vs ~17s standalone. Strip these so the child
23603
+ * starts clean; the SDK sets its own CLAUDE_CODE_ENTRYPOINT="sdk-ts".
23604
+ */
23187
23605
  function buildEnv(sessionCtx) {
23606
+ const env = { ...process.env };
23607
+ delete env.CLAUDECODE;
23608
+ delete env.CLAUDE_CODE_ENTRYPOINT;
23609
+ delete env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
23610
+ delete env.npm_lifecycle_script;
23188
23611
  return {
23189
- ...process.env,
23612
+ ...env,
23190
23613
  FIRST_TREE_HUB_SERVER_URL: sessionCtx.sdk.serverUrl,
23191
23614
  FIRST_TREE_HUB_AGENT_TOKEN: sessionCtx.sdk.agentToken,
23192
23615
  FIRST_TREE_HUB_CHAT_ID: sessionCtx.chatId,
@@ -23211,7 +23634,7 @@ const createClaudeCodeHandler = (config) => {
23211
23634
  options: {
23212
23635
  sessionId: resume ? void 0 : sessionId,
23213
23636
  resume,
23214
- cwd,
23637
+ cwd: cwd ?? void 0,
23215
23638
  persistSession: true,
23216
23639
  abortController,
23217
23640
  permissionMode,
@@ -23228,10 +23651,15 @@ const createClaudeCodeHandler = (config) => {
23228
23651
  sessionCtx.touch();
23229
23652
  if (message.type === "result") {
23230
23653
  const result = message;
23231
- if (result.subtype === "success") retryCount = 0;
23232
- else {
23654
+ if (result.subtype === "success") {
23655
+ retryCount = 0;
23656
+ if (result.result && sessionCtx.chatId) sessionCtx.sdk.sendMessage(sessionCtx.chatId, {
23657
+ format: "text",
23658
+ content: result.result
23659
+ }).then(() => sessionCtx.log("Result forwarded to chat")).catch((err) => sessionCtx.log(`Failed to forward result: ${err instanceof Error ? err.message : String(err)}`));
23660
+ } else {
23233
23661
  const errors = result.errors ? result.errors.join("; ") : result.subtype;
23234
- sessionCtx.log(`Query result error: ${errors}`);
23662
+ sessionCtx.log(`Query result error: ${errors} (subtype=${result.subtype}, turns=${result.num_turns ?? "?"}, duration=${result.duration_ms ?? "?"}ms)`);
23235
23663
  }
23236
23664
  }
23237
23665
  }
@@ -23239,6 +23667,13 @@ const createClaudeCodeHandler = (config) => {
23239
23667
  } catch (err) {
23240
23668
  const errMsg = err instanceof Error ? err.message : String(err);
23241
23669
  sessionCtx.log(`Query error: ${errMsg}`);
23670
+ if (err instanceof Error) {
23671
+ if (err.cause) sessionCtx.log(` cause: ${err.cause instanceof Error ? err.cause.message : String(err.cause)}`);
23672
+ if ("exitCode" in err) sessionCtx.log(` exitCode: ${err.exitCode}`);
23673
+ if ("stderr" in err) sessionCtx.log(` stderr: ${err.stderr}`);
23674
+ if ("code" in err) sessionCtx.log(` code: ${err.code}`);
23675
+ if (err.stack) sessionCtx.log(` stack: ${err.stack.split("\n").slice(1, 4).join(" | ")}`);
23676
+ }
23242
23677
  if (retryCount >= MAX_RETRIES || !claudeSessionId) {
23243
23678
  sessionCtx.log("Exhausted retries, session will be suspended");
23244
23679
  return;
@@ -23254,12 +23689,28 @@ const createClaudeCodeHandler = (config) => {
23254
23689
  }
23255
23690
  }
23256
23691
  }
23692
+ const contextTreePath = config.contextTreePath ?? null;
23693
+ /** Bootstrap workspace and generate CLAUDE.md. */
23694
+ function runBootstrap(workspace, sessionCtx) {
23695
+ bootstrapWorkspace({
23696
+ workspacePath: workspace,
23697
+ identity: sessionCtx.agent,
23698
+ contextTreePath,
23699
+ serverUrl: sessionCtx.sdk.serverUrl,
23700
+ chatId: sessionCtx.chatId
23701
+ });
23702
+ generateClaudeMd(workspace, sessionCtx.agent, contextTreePath);
23703
+ }
23257
23704
  const handler = {
23258
23705
  async start(message, sessionCtx) {
23259
23706
  ctx = sessionCtx;
23260
23707
  claudeSessionId = randomUUID();
23708
+ cwd = acquireWorkspace(workspaceRoot, sessionCtx.chatId);
23709
+ runBootstrap(cwd, sessionCtx);
23710
+ sessionCtx.log(`Starting session (${claudeSessionId}), cwd=${cwd}, permissionMode=${config.permissionMode ?? "bypassPermissions"}`);
23261
23711
  spawnQuery(claudeSessionId, sessionCtx);
23262
- inputController?.push(toSDKUserMessage(message, claudeSessionId));
23712
+ const sdkMsg = toSDKUserMessage(message, claudeSessionId);
23713
+ inputController?.push(sdkMsg);
23263
23714
  sessionCtx.log(`Session started (${claudeSessionId})`);
23264
23715
  return claudeSessionId;
23265
23716
  },
@@ -23267,6 +23718,9 @@ const createClaudeCodeHandler = (config) => {
23267
23718
  ctx = sessionCtx;
23268
23719
  claudeSessionId = sessionId;
23269
23720
  retryCount = 0;
23721
+ cwd = acquireWorkspace(workspaceRoot, sessionCtx.chatId);
23722
+ if (!existsSync(join(cwd, ".agent", "identity.json"))) runBootstrap(cwd, sessionCtx);
23723
+ sessionCtx.log(`Resuming session (${sessionId}), cwd=${cwd}`);
23270
23724
  spawnQuery(sessionId, sessionCtx, sessionId);
23271
23725
  inputController?.push(toSDKUserMessage(message, sessionId));
23272
23726
  sessionCtx.log(`Session resumed (${sessionId})`);
@@ -23301,6 +23755,50 @@ const createClaudeCodeHandler = (config) => {
23301
23755
  };
23302
23756
  return handler;
23303
23757
  };
23758
+ /**
23759
+ * Generate a CLAUDE.md file from .agent/ bootstrap data.
23760
+ *
23761
+ * Layered Bootstrap:
23762
+ * Layer 1 (always): Agent identity + member profile + AGENT.md operating instructions
23763
+ * Layer 2 (if available): Organization domain map from root NODE.md
23764
+ * Layer 3 (on-demand): Agent reads specific domain nodes via contextTreePath
23765
+ */
23766
+ function generateClaudeMd(workspacePath, identity, contextTreePath) {
23767
+ const sections = [];
23768
+ const contextDir = join(workspacePath, ".agent", "context");
23769
+ const name = identity.displayName ?? identity.agentId;
23770
+ if (identity.type === "personal_assistant") sections.push(`# Agent Identity\n\nYou are ${name}, a personal assistant agent.\n`);
23771
+ else sections.push(`# Agent Identity\n\nYou are ${name}, an autonomous agent.\n`);
23772
+ const selfMdPath = join(contextDir, "self.md");
23773
+ if (existsSync(selfMdPath)) {
23774
+ const selfContent = readFileSync(selfMdPath, "utf-8");
23775
+ sections.push(`## Your Profile\n\n${selfContent}\n`);
23776
+ } else sections.push("## Your Profile\n\nNo member profile available. Your responsibilities are not loaded from the Context Tree.\n");
23777
+ const agentInstructionsPath = join(contextDir, "agent-instructions.md");
23778
+ if (existsSync(agentInstructionsPath)) {
23779
+ const instructions = readFileSync(agentInstructionsPath, "utf-8");
23780
+ sections.push(`## Context Tree Operating Instructions\n\n${instructions}\n`);
23781
+ } else sections.push("## Context Tree Operating Instructions\n\nContext Tree instructions unavailable. Organizational context is not loaded for this session.\n");
23782
+ const domainMapPath = join(contextDir, "domain-map.md");
23783
+ if (existsSync(domainMapPath)) {
23784
+ const domainMap = readFileSync(domainMapPath, "utf-8");
23785
+ sections.push(`## Organization Domain Map\n\n${domainMap}\n`);
23786
+ }
23787
+ if (contextTreePath) sections.push(`## Context Tree Location\n\nThe full Context Tree is available at: \`${contextTreePath}\`\n\nRead specific domain nodes as needed following the operating instructions above.\n`);
23788
+ else {
23789
+ const degradedPath = join(contextDir, "degraded.md");
23790
+ if (existsSync(degradedPath)) {
23791
+ const degradedMsg = readFileSync(degradedPath, "utf-8");
23792
+ sections.push(`## Context Tree Location\n\nWARNING: ${degradedMsg}\nYou can still use the SDK tools below, but you lack organizational context for decisions.\n`);
23793
+ }
23794
+ }
23795
+ const toolsPath = join(workspacePath, ".agent", "tools.md");
23796
+ if (existsSync(toolsPath)) {
23797
+ const toolsContent = readFileSync(toolsPath, "utf-8");
23798
+ sections.push(toolsContent);
23799
+ }
23800
+ writeFileSync(join(workspacePath, "CLAUDE.md"), sections.join("\n"), "utf-8");
23801
+ }
23304
23802
  /** Register all built-in handlers. Call once at startup. */
23305
23803
  function registerBuiltinHandlers() {
23306
23804
  registerHandler("claude-code", createClaudeCodeHandler);
@@ -23700,18 +24198,24 @@ var AgentSlot = class {
23700
24198
  this.connection.on("reconnecting", (attempt) => this.logFn(`Reconnecting (attempt ${attempt})...`));
23701
24199
  this.connection.on("error", (err) => this.logFn(`Error: ${err.message}`));
23702
24200
  }
23703
- async start() {
24201
+ async start(contextTreePath) {
23704
24202
  const agent = await this.connection.connect();
23705
24203
  this.logFn(`Registered as ${agent.displayName ?? agent.agentId} (${agent.agentId})`);
23706
- const registryPath = join(homedir(), ".first-tree-hub", "data", "sessions", `${this.config.name}.json`);
24204
+ const registryPath = join(DEFAULT_DATA_DIR, "sessions", `${this.config.name}.json`);
23707
24205
  this.sessionManager = new SessionManager({
23708
24206
  session: this.config.session,
23709
24207
  concurrency: this.config.concurrency,
23710
24208
  handlerFactory: this.config.handlerFactory,
23711
- handlerConfig: { cwd: this.config.cwd ?? process.cwd() },
24209
+ handlerConfig: {
24210
+ workspaceRoot: join(DEFAULT_DATA_DIR, "workspaces", this.config.name),
24211
+ contextTreePath: contextTreePath ?? void 0
24212
+ },
23712
24213
  agentIdentity: {
23713
24214
  agentId: agent.agentId,
23714
- displayName: agent.displayName
24215
+ displayName: agent.displayName,
24216
+ type: agent.type,
24217
+ delegateMention: agent.delegateMention,
24218
+ metadata: agent.metadata
23715
24219
  },
23716
24220
  sdk: this.connection.sdk,
23717
24221
  log: this.logFn,
@@ -23735,7 +24239,6 @@ const sessionConfigSchema = z.object({
23735
24239
  const agentSlotConfigSchema = z.object({
23736
24240
  token: z.string().min(1),
23737
24241
  type: z.string().min(1),
23738
- cwd: z.string().optional(),
23739
24242
  session: sessionConfigSchema.default({}),
23740
24243
  concurrency: z.number().int().positive().default(5)
23741
24244
  });
@@ -23767,30 +24270,34 @@ function loadRuntimeConfig(configPath) {
23767
24270
  const DEFAULT_SHUTDOWN_TIMEOUT = 3e4;
23768
24271
  var AgentRuntime = class {
23769
24272
  slots = [];
24273
+ config;
23770
24274
  shutdownTimeout;
23771
24275
  stopping = false;
23772
24276
  constructor(options) {
23773
- const { config } = options;
24277
+ this.config = options.config;
23774
24278
  this.shutdownTimeout = options.shutdownTimeout ?? DEFAULT_SHUTDOWN_TIMEOUT;
23775
- for (const [name, agentConfig] of Object.entries(config.agents)) {
24279
+ for (const [name, agentConfig] of Object.entries(this.config.agents)) {
23776
24280
  const handlerFactory = getHandlerFactory(agentConfig.type);
23777
24281
  this.slots.push(new AgentSlot({
23778
24282
  name,
23779
- serverUrl: config.server,
24283
+ serverUrl: this.config.server,
23780
24284
  token: agentConfig.token,
23781
24285
  type: agentConfig.type,
23782
24286
  handlerFactory,
23783
24287
  session: agentConfig.session,
23784
- concurrency: agentConfig.concurrency,
23785
- cwd: agentConfig.cwd
24288
+ concurrency: agentConfig.concurrency
23786
24289
  }));
23787
24290
  }
23788
24291
  }
23789
24292
  /** Start all agent slots and block until shutdown signal. */
23790
24293
  async start() {
23791
24294
  const log = (msg) => process.stderr.write(`[runtime] ${msg}\n`);
24295
+ const firstToken = Object.values(this.config.agents)[0]?.token;
24296
+ let contextTreePath = null;
24297
+ if (firstToken) contextTreePath = await syncContextTree(this.config.server, firstToken, log);
24298
+ if (!contextTreePath) log("WARNING: Context Tree sync failed — agents will start without organizational context");
23792
24299
  log(`Starting ${this.slots.length} agent(s)...`);
23793
- const results = await Promise.allSettled(this.slots.map((slot) => slot.start()));
24300
+ const results = await Promise.allSettled(this.slots.map((slot) => slot.start(contextTreePath)));
23794
24301
  let failed = 0;
23795
24302
  for (const result of results) if (result.status === "rejected") {
23796
24303
  log(`Failed to start agent: ${result.reason instanceof Error ? result.reason.message : result.reason}`);
@@ -24705,6 +25212,7 @@ z.object({
24705
25212
  type: agentTypeSchema,
24706
25213
  displayName: z.string().nullable(),
24707
25214
  delegateMention: z.string().nullable(),
25215
+ treePath: z.string().nullable(),
24708
25216
  inboxId: z.string(),
24709
25217
  status: z.string(),
24710
25218
  metadata: z.record(z.unknown()),
@@ -24832,7 +25340,7 @@ const SYSTEM_CONFIG_DEFAULTS = {
24832
25340
  [SYSTEM_CONFIG_KEYS.PRESENCE_CLEANUP_SECONDS]: 60
24833
25341
  };
24834
25342
  //#endregion
24835
- //#region ../server/dist/app-DUdS4lE-.mjs
25343
+ //#region ../server/dist/app-DtKgrri9.mjs
24836
25344
  var __defProp = Object.defineProperty;
24837
25345
  var __exportAll = (all, no_symbols) => {
24838
25346
  let target = {};
@@ -24850,6 +25358,7 @@ const agents = pgTable("agents", {
24850
25358
  type: text("type").notNull(),
24851
25359
  displayName: text("display_name"),
24852
25360
  delegateMention: text("delegate_mention"),
25361
+ treePath: text("tree_path"),
24853
25362
  inboxId: text("inbox_id").unique().notNull(),
24854
25363
  status: text("status").notNull().default("active"),
24855
25364
  metadata: jsonb("metadata").$type().notNull().default({}),
@@ -25331,6 +25840,7 @@ async function adminAdapterRoutes(app) {
25331
25840
  });
25332
25841
  }
25333
25842
  const GRAPHQL_URL = "https://api.github.com/graphql";
25843
+ const REST_API_URL = "https://api.github.com";
25334
25844
  /** Parse "owner/repo" or "https://github.com/owner/repo" into { owner, name }. */
25335
25845
  function parseRepo(input) {
25336
25846
  const urlMatch = /github\.com\/([^/]+)\/([^/.]+)/.exec(input);
@@ -25344,31 +25854,13 @@ function parseRepo(input) {
25344
25854
  name: parts[1] ?? ""
25345
25855
  };
25346
25856
  }
25347
- /**
25348
- * Fetch all members from a Context Tree repo via GitHub GraphQL API.
25349
- * Single request regardless of member count.
25350
- */
25351
- async function fetchMembers(repo, branch, token) {
25352
- const { owner, name } = parseRepo(repo);
25353
- if (!owner || !name) throw new Error(`Invalid repo format: "${repo}" — expected "owner/repo" or a GitHub URL`);
25857
+ /** Step 1: Get the tree OID of the members/ directory via GraphQL. */
25858
+ async function fetchMembersTreeOid(owner, name, branch, token) {
25354
25859
  const query = `
25355
25860
  query($owner: String!, $name: String!, $expr: String!) {
25356
25861
  repository(owner: $owner, name: $name) {
25357
25862
  object(expression: $expr) {
25358
- ... on Tree {
25359
- entries {
25360
- name
25361
- type
25362
- object {
25363
- ... on Tree {
25364
- entries {
25365
- name
25366
- object { ... on Blob { text } }
25367
- }
25368
- }
25369
- }
25370
- }
25371
- }
25863
+ ... on Tree { oid }
25372
25864
  }
25373
25865
  }
25374
25866
  }
@@ -25391,36 +25883,128 @@ async function fetchMembers(repo, branch, token) {
25391
25883
  if (!res.ok) throw new Error(`GitHub API returned ${res.status}: ${await res.text()}`);
25392
25884
  const json = await res.json();
25393
25885
  if (json.errors) throw new Error(`GitHub GraphQL errors: ${json.errors.map((e) => e.message).join(", ")}`);
25394
- const entries = json.data?.repository?.object?.entries ?? [];
25395
- const members = [];
25396
- for (const entry of entries) {
25397
- if (entry.type !== "tree") continue;
25398
- const nodeFile = entry.object?.entries?.find((f) => f.name === "NODE.md");
25399
- members.push({
25400
- name: entry.name,
25401
- nodeContent: nodeFile?.object?.text ?? null
25402
- });
25403
- }
25404
- return members;
25886
+ return json.data?.repository?.object?.oid ?? null;
25887
+ }
25888
+ /** Step 2: Recursively list all entries under the members/ tree via REST API. */
25889
+ async function fetchRecursiveTree(owner, name, treeSha, token) {
25890
+ const url = `${REST_API_URL}/repos/${owner}/${name}/git/trees/${treeSha}?recursive=1`;
25891
+ const res = await fetch(url, { headers: {
25892
+ Authorization: `Bearer ${token}`,
25893
+ Accept: "application/vnd.github+json"
25894
+ } });
25895
+ if (!res.ok) throw new Error(`GitHub REST API returned ${res.status}: ${await res.text()}`);
25896
+ const json = await res.json();
25897
+ if (json.truncated) throw new Error("[context-tree-sync] GitHub REST tree API returned truncated response — members/ subtree is too large. Sync aborted to prevent incorrect agent suspension from partial data.");
25898
+ return json.tree;
25405
25899
  }
25406
- /** Parse NODE.md frontmatter for agent metadata. */
25407
- function parseNodeMetadata(content) {
25408
- const match = /^---\n([\s\S]*?)\n---/.exec(content);
25409
- if (!match) return {
25410
- type: "autonomous_agent",
25411
- displayName: null,
25412
- delegateMention: null
25413
- };
25414
- const frontmatter = match[1] ?? "";
25415
- const getValue = (key) => {
25416
- const lineMatch = new RegExp(`^${key}:\\s*(.+)$`, "m").exec(frontmatter);
25417
- return lineMatch ? lineMatch[1]?.trim().replace(/^["']|["']$/g, "") ?? null : null;
25418
- };
25419
- return {
25420
- type: getValue("type") ?? "autonomous_agent",
25421
- displayName: getValue("display_name") ?? getValue("title") ?? getValue("name"),
25422
- delegateMention: getValue("delegate_mention")
25423
- };
25900
+ /**
25901
+ * Step 3: Batch-fetch NODE.md content for all member directories via GraphQL aliases.
25902
+ * Each alias fetches one NODE.md file by expression.
25903
+ */
25904
+ async function batchFetchNodeMd(owner, name, branch, memberPaths, token) {
25905
+ if (memberPaths.length === 0) return /* @__PURE__ */ new Map();
25906
+ const query = `
25907
+ query($owner: String!, $name: String!) {
25908
+ repository(owner: $owner, name: $name) {
25909
+ ${memberPaths.map((p, i) => {
25910
+ const expr = `${branch}:members/${p}/NODE.md`;
25911
+ return `m${i}: object(expression: ${JSON.stringify(expr)}) { ... on Blob { text } }`;
25912
+ }).join("\n ")}
25913
+ }
25914
+ }
25915
+ `;
25916
+ const res = await fetch(GRAPHQL_URL, {
25917
+ method: "POST",
25918
+ headers: {
25919
+ Authorization: `Bearer ${token}`,
25920
+ "Content-Type": "application/json"
25921
+ },
25922
+ body: JSON.stringify({
25923
+ query,
25924
+ variables: {
25925
+ owner,
25926
+ name
25927
+ }
25928
+ })
25929
+ });
25930
+ if (!res.ok) throw new Error(`GitHub API returned ${res.status}: ${await res.text()}`);
25931
+ const json = await res.json();
25932
+ if (json.errors) throw new Error(`GitHub GraphQL errors: ${json.errors.map((e) => e.message).join(", ")}`);
25933
+ const repo = json.data?.repository ?? {};
25934
+ const result = /* @__PURE__ */ new Map();
25935
+ for (let i = 0; i < memberPaths.length; i++) {
25936
+ const blob = repo[`m${i}`];
25937
+ const path = memberPaths[i];
25938
+ if (blob?.text && path) result.set(path, blob.text);
25939
+ }
25940
+ return result;
25941
+ }
25942
+ /**
25943
+ * Extract member directory paths from a recursive tree listing.
25944
+ * A directory is a member if it contains a NODE.md blob.
25945
+ */
25946
+ function extractMemberDirs(treeEntries) {
25947
+ const nodeMdPaths = /* @__PURE__ */ new Set();
25948
+ for (const entry of treeEntries) if (entry.type === "blob" && entry.path.endsWith("/NODE.md")) nodeMdPaths.add(entry.path);
25949
+ const memberDirs = [];
25950
+ for (const entry of treeEntries) {
25951
+ if (entry.type !== "tree") continue;
25952
+ if (nodeMdPaths.has(`${entry.path}/NODE.md`)) memberDirs.push(entry.path);
25953
+ }
25954
+ return memberDirs.sort();
25955
+ }
25956
+ /**
25957
+ * Fetch all members from a Context Tree repo via GitHub API.
25958
+ * Uses 3 API calls:
25959
+ * 1. GraphQL: get members/ tree OID
25960
+ * 2. REST: recursive tree listing (scoped to members/ only)
25961
+ * 3. GraphQL: batch-fetch all NODE.md contents via aliases
25962
+ */
25963
+ async function fetchMembers(repo, branch, token) {
25964
+ const { owner, name } = parseRepo(repo);
25965
+ if (!owner || !name) throw new Error(`Invalid repo format: "${repo}" — expected "owner/repo" or a GitHub URL`);
25966
+ const treeOid = await fetchMembersTreeOid(owner, name, branch, token);
25967
+ if (!treeOid) {
25968
+ console.warn("[context-tree-sync] members/ directory not found in repo");
25969
+ return [];
25970
+ }
25971
+ const memberDirs = extractMemberDirs(await fetchRecursiveTree(owner, name, treeOid, token));
25972
+ if (memberDirs.length === 0) {
25973
+ console.warn("[context-tree-sync] No member directories with NODE.md found");
25974
+ return [];
25975
+ }
25976
+ const nameMap = /* @__PURE__ */ new Map();
25977
+ for (const dir of memberDirs) {
25978
+ const dirName = dir.split("/").pop() ?? dir;
25979
+ const existing = nameMap.get(dirName);
25980
+ if (existing) throw new Error(`[context-tree-sync] Duplicate member directory name '${dirName}' found at 'members/${existing}' and 'members/${dir}' — directory names must be unique across all levels under members/. Fix this in the Context Tree repo.`);
25981
+ nameMap.set(dirName, dir);
25982
+ }
25983
+ const nodeContents = await batchFetchNodeMd(owner, name, branch, memberDirs, token);
25984
+ return memberDirs.map((dir) => ({
25985
+ name: dir.split("/").pop() ?? dir,
25986
+ treePath: dir,
25987
+ nodeContent: nodeContents.get(dir) ?? null
25988
+ }));
25989
+ }
25990
+ /** Parse NODE.md frontmatter for agent metadata. */
25991
+ function parseNodeMetadata(content) {
25992
+ const match = /^---\n([\s\S]*?)\n---/.exec(content);
25993
+ if (!match) return {
25994
+ type: "autonomous_agent",
25995
+ displayName: null,
25996
+ delegateMention: null
25997
+ };
25998
+ const frontmatter = match[1] ?? "";
25999
+ const getValue = (key) => {
26000
+ const lineMatch = new RegExp(`^${key}:\\s*(.+)$`, "m").exec(frontmatter);
26001
+ return lineMatch ? lineMatch[1]?.trim().replace(/^["']|["']$/g, "") ?? null : null;
26002
+ };
26003
+ return {
26004
+ type: getValue("type") ?? "autonomous_agent",
26005
+ displayName: getValue("display_name") ?? getValue("title") ?? getValue("name"),
26006
+ delegateMention: getValue("delegate_mention")
26007
+ };
25424
26008
  }
25425
26009
  /** Stored for the /status endpoint */
25426
26010
  let _lastSyncResult;
@@ -25455,31 +26039,31 @@ async function syncFromGitHub(db, repo, branch, githubToken) {
25455
26039
  displayName: null,
25456
26040
  delegateMention: null
25457
26041
  };
25458
- const existing = await db.execute(sql`SELECT id, status, type, display_name, delegate_mention FROM agents WHERE id = ${member.name}`);
26042
+ const existing = await db.execute(sql`SELECT id, status, type, display_name, delegate_mention, tree_path FROM agents WHERE id = ${member.name}`);
25459
26043
  if (existing.length === 0) {
25460
26044
  await db.execute(sql`
25461
- INSERT INTO agents (id, type, display_name, delegate_mention, status, inbox_id)
25462
- VALUES (${member.name}, ${meta.type}, ${meta.displayName}, ${meta.delegateMention}, 'active', ${`inbox_${member.name}`})
26045
+ INSERT INTO agents (id, type, display_name, delegate_mention, tree_path, status, inbox_id)
26046
+ VALUES (${member.name}, ${meta.type}, ${meta.displayName}, ${meta.delegateMention}, ${member.treePath}, 'active', ${`inbox_${member.name}`})
25463
26047
  `);
25464
26048
  result.created++;
25465
26049
  } else {
25466
26050
  const agent = existing[0];
25467
26051
  if (agent.status === "suspended") {
25468
26052
  await db.execute(sql`
25469
- UPDATE agents SET status = 'active', type = ${meta.type}, display_name = ${meta.displayName}, delegate_mention = ${meta.delegateMention}
26053
+ UPDATE agents SET status = 'active', type = ${meta.type}, display_name = ${meta.displayName}, delegate_mention = ${meta.delegateMention}, tree_path = ${member.treePath}
25470
26054
  WHERE id = ${member.name}
25471
26055
  `);
25472
26056
  result.reactivated++;
25473
- } else if (agent.type !== meta.type || agent.display_name !== meta.displayName || agent.delegate_mention !== meta.delegateMention) {
26057
+ } else if (agent.type !== meta.type || agent.display_name !== meta.displayName || agent.delegate_mention !== meta.delegateMention || agent.tree_path !== member.treePath) {
25474
26058
  await db.execute(sql`
25475
- UPDATE agents SET type = ${meta.type}, display_name = ${meta.displayName}, delegate_mention = ${meta.delegateMention}
26059
+ UPDATE agents SET type = ${meta.type}, display_name = ${meta.displayName}, delegate_mention = ${meta.delegateMention}, tree_path = ${member.treePath}
25476
26060
  WHERE id = ${member.name}
25477
26061
  `);
25478
26062
  result.updated++;
25479
26063
  } else result.unchanged++;
25480
26064
  }
25481
26065
  } catch (err) {
25482
- console.error(`[context-tree-sync] Failed to sync member "${member.name}":`, err);
26066
+ console.error(`[context-tree-sync] Failed to sync member "${member.name}" (path: members/${member.treePath}):`, err);
25483
26067
  result.errors++;
25484
26068
  }
25485
26069
  try {
@@ -25583,6 +26167,7 @@ async function listAgents(db, limit, cursor) {
25583
26167
  type: agents.type,
25584
26168
  displayName: agents.displayName,
25585
26169
  delegateMention: agents.delegateMention,
26170
+ treePath: agents.treePath,
25586
26171
  inboxId: agents.inboxId,
25587
26172
  status: agents.status,
25588
26173
  metadata: agents.metadata,
@@ -25658,6 +26243,133 @@ async function revokeToken(db, agentId, tokenId) {
25658
26243
  if (!token) throw new NotFoundError("Token not found or already revoked");
25659
26244
  return token;
25660
26245
  }
26246
+ async function createChat(db, creatorId, data) {
26247
+ const chatId = randomUUID();
26248
+ const allParticipantIds = new Set([creatorId, ...data.participantIds]);
26249
+ const existingAgents = await db.select({
26250
+ id: agents.id,
26251
+ organizationId: agents.organizationId
26252
+ }).from(agents).where(inArray(agents.id, [...allParticipantIds]));
26253
+ if (existingAgents.length !== allParticipantIds.size) {
26254
+ const found = new Set(existingAgents.map((a) => a.id));
26255
+ throw new BadRequestError(`Agents not found: ${[...allParticipantIds].filter((id) => !found.has(id)).join(", ")}`);
26256
+ }
26257
+ const creator = existingAgents.find((a) => a.id === creatorId);
26258
+ if (!creator) throw new Error("Unexpected: creator not in existingAgents");
26259
+ const orgId = creator.organizationId;
26260
+ const crossOrg = existingAgents.filter((a) => a.organizationId !== orgId);
26261
+ if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.id).join(", ")}`);
26262
+ return db.transaction(async (tx) => {
26263
+ const [chat] = await tx.insert(chats).values({
26264
+ id: chatId,
26265
+ organizationId: orgId,
26266
+ type: data.type,
26267
+ topic: data.topic ?? null,
26268
+ metadata: data.metadata ?? {}
26269
+ }).returning();
26270
+ const participantRows = [...allParticipantIds].map((agentId) => ({
26271
+ chatId,
26272
+ agentId,
26273
+ role: agentId === creatorId ? "owner" : "member"
26274
+ }));
26275
+ await tx.insert(chatParticipants).values(participantRows);
26276
+ const participants = await tx.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
26277
+ if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
26278
+ return {
26279
+ ...chat,
26280
+ participants
26281
+ };
26282
+ });
26283
+ }
26284
+ async function getChat(db, chatId) {
26285
+ const [chat] = await db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
26286
+ if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
26287
+ return chat;
26288
+ }
26289
+ async function getChatDetail(db, chatId) {
26290
+ const chat = await getChat(db, chatId);
26291
+ const participants = await db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
26292
+ return {
26293
+ ...chat,
26294
+ participants
26295
+ };
26296
+ }
26297
+ async function listChats(db, agentId, limit, cursor) {
26298
+ const chatIds = (await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentId))).map((r) => r.chatId);
26299
+ if (chatIds.length === 0) return {
26300
+ items: [],
26301
+ nextCursor: null
26302
+ };
26303
+ const where = cursor ? and(inArray(chats.id, chatIds), lt(chats.updatedAt, new Date(cursor))) : inArray(chats.id, chatIds);
26304
+ const rows = await db.select().from(chats).where(where).orderBy(desc(chats.updatedAt)).limit(limit + 1);
26305
+ const hasMore = rows.length > limit;
26306
+ const items = hasMore ? rows.slice(0, limit) : rows;
26307
+ const last = items[items.length - 1];
26308
+ return {
26309
+ items,
26310
+ nextCursor: hasMore && last ? last.updatedAt.toISOString() : null
26311
+ };
26312
+ }
26313
+ async function assertParticipant(db, chatId, agentId) {
26314
+ const [row] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
26315
+ if (!row) throw new ForbiddenError("Not a participant of this chat");
26316
+ }
26317
+ async function addParticipant(db, chatId, requesterId, data) {
26318
+ const chat = await getChat(db, chatId);
26319
+ await assertParticipant(db, chatId, requesterId);
26320
+ const [targetAgent] = await db.select({
26321
+ id: agents.id,
26322
+ organizationId: agents.organizationId
26323
+ }).from(agents).where(eq(agents.id, data.agentId)).limit(1);
26324
+ if (!targetAgent) throw new NotFoundError(`Agent "${data.agentId}" not found`);
26325
+ if (targetAgent.organizationId !== chat.organizationId) throw new BadRequestError("Cannot add agent from different organization");
26326
+ const [existing] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, data.agentId))).limit(1);
26327
+ if (existing) throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
26328
+ await db.insert(chatParticipants).values({
26329
+ chatId,
26330
+ agentId: data.agentId,
26331
+ mode: data.mode ?? "full"
26332
+ });
26333
+ return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
26334
+ }
26335
+ async function removeParticipant(db, chatId, requesterId, targetAgentId) {
26336
+ await assertParticipant(db, chatId, requesterId);
26337
+ if (requesterId === targetAgentId) throw new BadRequestError("Cannot remove yourself from a chat");
26338
+ const [removed] = await db.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, targetAgentId))).returning();
26339
+ if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
26340
+ return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
26341
+ }
26342
+ async function findOrCreateDirectChat(db, agentAId, agentBId) {
26343
+ const aChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentAId));
26344
+ const bChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentBId));
26345
+ const bChatIds = new Set(bChats.map((r) => r.chatId));
26346
+ const commonChatIds = aChats.map((r) => r.chatId).filter((id) => bChatIds.has(id));
26347
+ if (commonChatIds.length > 0) {
26348
+ const directChats = await db.select().from(chats).where(and(inArray(chats.id, commonChatIds), eq(chats.type, "direct")));
26349
+ if (directChats.length > 0 && directChats[0]) return directChats[0];
26350
+ }
26351
+ const [agentA] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.id, agentAId)).limit(1);
26352
+ if (!agentA) throw new NotFoundError(`Agent "${agentAId}" not found`);
26353
+ const chatId = randomUUID();
26354
+ return db.transaction(async (tx) => {
26355
+ const [chat] = await tx.insert(chats).values({
26356
+ id: chatId,
26357
+ organizationId: agentA.organizationId,
26358
+ type: "direct"
26359
+ }).returning();
26360
+ await tx.insert(chatParticipants).values([{
26361
+ chatId,
26362
+ agentId: agentAId,
26363
+ role: "member"
26364
+ }, {
26365
+ chatId,
26366
+ agentId: agentBId,
26367
+ role: "member"
26368
+ }]);
26369
+ if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
26370
+ return chat;
26371
+ });
26372
+ }
25661
26373
  /** WS close code: agent already connected from another client. */
25662
26374
  const WS_CLOSE_ALREADY_CONNECTED = 4009;
25663
26375
  /** Track active WS connections per agentId. At most one entry per agent. */
@@ -25687,36 +26399,217 @@ function forceDisconnect(agentId) {
25687
26399
  activeConnections.delete(agentId);
25688
26400
  return true;
25689
26401
  }
25690
- /** Server instance heartbeat. Used to detect crashed instances and clean up associated agent_presence records. */
25691
- const serverInstances = pgTable("server_instances", {
25692
- instanceId: text("instance_id").primaryKey(),
25693
- lastHeartbeat: timestamp("last_heartbeat", { withTimezone: true }).notNull().defaultNow()
25694
- });
25695
- async function setOnline(db, agentId, instanceId) {
25696
- const now = /* @__PURE__ */ new Date();
25697
- await db.insert(agentPresence).values({
25698
- agentId,
25699
- status: "online",
25700
- instanceId,
25701
- connectedAt: now,
25702
- lastSeenAt: now
25703
- }).onConflictDoUpdate({
25704
- target: agentPresence.agentId,
25705
- set: {
25706
- status: "online",
25707
- instanceId,
25708
- connectedAt: now,
25709
- lastSeenAt: now
26402
+ /** Delivery queue (envelope). One entry per recipient created during message fan-out. Uses SKIP LOCKED for concurrent-safe consumption. */
26403
+ const inboxEntries = pgTable("inbox_entries", {
26404
+ id: bigserial("id", { mode: "number" }).primaryKey(),
26405
+ inboxId: text("inbox_id").notNull(),
26406
+ messageId: text("message_id").notNull().references(() => messages.id),
26407
+ chatId: text("chat_id"),
26408
+ status: text("status").notNull().default("pending"),
26409
+ retryCount: integer("retry_count").notNull().default(0),
26410
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
26411
+ deliveredAt: timestamp("delivered_at", { withTimezone: true }),
26412
+ ackedAt: timestamp("acked_at", { withTimezone: true })
26413
+ }, (table) => [unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId), index("idx_inbox_pending").on(table.inboxId, table.createdAt)]);
26414
+ async function sendMessage(db, chatId, senderId, data) {
26415
+ return db.transaction(async (tx) => {
26416
+ const messageId = randomUUID();
26417
+ const [msg] = await tx.insert(messages).values({
26418
+ id: messageId,
26419
+ chatId,
26420
+ senderId,
26421
+ format: data.format,
26422
+ content: data.content,
26423
+ metadata: data.metadata ?? {},
26424
+ replyToInbox: data.replyToInbox ?? null,
26425
+ replyToChat: data.replyToChat ?? null,
26426
+ inReplyTo: data.inReplyTo ?? null
26427
+ }).returning();
26428
+ const entries = (await tx.select({
26429
+ agentId: chatParticipants.agentId,
26430
+ inboxId: agents.inboxId
26431
+ }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.id)).where(eq(chatParticipants.chatId, chatId))).filter((p) => p.agentId !== senderId).map((p) => ({
26432
+ inboxId: p.inboxId,
26433
+ messageId,
26434
+ chatId
26435
+ }));
26436
+ if (entries.length > 0) await tx.insert(inboxEntries).values(entries);
26437
+ const recipients = entries.map((e) => e.inboxId);
26438
+ if (data.inReplyTo) {
26439
+ const [original] = await tx.select({
26440
+ replyToInbox: messages.replyToInbox,
26441
+ replyToChat: messages.replyToChat
26442
+ }).from(messages).where(eq(messages.id, data.inReplyTo)).limit(1);
26443
+ if (original?.replyToInbox && original?.replyToChat) {
26444
+ await tx.insert(inboxEntries).values({
26445
+ inboxId: original.replyToInbox,
26446
+ messageId,
26447
+ chatId: original.replyToChat
26448
+ }).onConflictDoNothing();
26449
+ if (!recipients.includes(original.replyToInbox)) recipients.push(original.replyToInbox);
26450
+ }
25710
26451
  }
25711
- });
25712
- }
25713
- async function setOffline(db, agentId) {
25714
- await db.update(agentPresence).set({
26452
+ await tx.update(chats).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(chats.id, chatId));
26453
+ if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
26454
+ return {
26455
+ message: msg,
26456
+ recipients
26457
+ };
26458
+ });
26459
+ }
26460
+ async function sendToAgent(db, senderId, targetAgentId, data) {
26461
+ const [sender] = await db.select({
26462
+ id: agents.id,
26463
+ organizationId: agents.organizationId
26464
+ }).from(agents).where(eq(agents.id, senderId)).limit(1);
26465
+ if (!sender) throw new NotFoundError(`Agent "${senderId}" not found`);
26466
+ const [target] = await db.select({
26467
+ id: agents.id,
26468
+ organizationId: agents.organizationId
26469
+ }).from(agents).where(eq(agents.id, targetAgentId)).limit(1);
26470
+ if (!target) throw new NotFoundError(`Agent "${targetAgentId}" not found`);
26471
+ return sendMessage(db, (await findOrCreateDirectChat(db, senderId, targetAgentId)).id, senderId, {
26472
+ format: data.format,
26473
+ content: data.content,
26474
+ metadata: data.metadata,
26475
+ replyToInbox: data.replyToInbox,
26476
+ replyToChat: data.replyToChat
26477
+ });
26478
+ }
26479
+ async function editMessage(db, chatId, messageId, senderId, data) {
26480
+ const [msg] = await db.select().from(messages).where(eq(messages.id, messageId)).limit(1);
26481
+ if (!msg) throw new NotFoundError(`Message "${messageId}" not found`);
26482
+ if (msg.chatId !== chatId) throw new NotFoundError(`Message "${messageId}" not found in this chat`);
26483
+ if (msg.senderId !== senderId) throw new ForbiddenError("Only the sender can edit a message");
26484
+ const setClause = {};
26485
+ if (data.format !== void 0) setClause.format = data.format;
26486
+ if (data.content !== void 0) setClause.content = data.content;
26487
+ const meta = msg.metadata ?? {};
26488
+ meta.editedAt = (/* @__PURE__ */ new Date()).toISOString();
26489
+ setClause.metadata = meta;
26490
+ const [updated] = await db.update(messages).set(setClause).where(eq(messages.id, messageId)).returning();
26491
+ if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
26492
+ return updated;
26493
+ }
26494
+ async function listMessages(db, chatId, limit, cursor) {
26495
+ const where = cursor ? and(eq(messages.chatId, chatId), lt(messages.createdAt, new Date(cursor))) : eq(messages.chatId, chatId);
26496
+ const rows = await db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(limit + 1);
26497
+ const hasMore = rows.length > limit;
26498
+ const items = hasMore ? rows.slice(0, limit) : rows;
26499
+ const last = items[items.length - 1];
26500
+ return {
26501
+ items,
26502
+ nextCursor: hasMore && last ? last.createdAt.toISOString() : null
26503
+ };
26504
+ }
26505
+ const INBOX_CHANNEL = "inbox_notifications";
26506
+ const CONFIG_CHANNEL = "config_changes";
26507
+ function createNotifier(listenClient) {
26508
+ const subscriptions = /* @__PURE__ */ new Map();
26509
+ const configChangeHandlers = [];
26510
+ let unlistenInboxFn = null;
26511
+ let unlistenConfigFn = null;
26512
+ function handleNotification(payload) {
26513
+ const sepIdx = payload.indexOf(":");
26514
+ if (sepIdx === -1) return;
26515
+ const inboxId = payload.slice(0, sepIdx);
26516
+ const messageId = payload.slice(sepIdx + 1);
26517
+ const sockets = subscriptions.get(inboxId);
26518
+ if (!sockets) return;
26519
+ const data = JSON.stringify({
26520
+ type: "new_message",
26521
+ inboxId,
26522
+ messageId
26523
+ });
26524
+ for (const ws of sockets) if (ws.readyState === ws.OPEN) ws.send(data);
26525
+ }
26526
+ return {
26527
+ subscribe(inboxId, ws) {
26528
+ let set = subscriptions.get(inboxId);
26529
+ if (!set) {
26530
+ set = /* @__PURE__ */ new Set();
26531
+ subscriptions.set(inboxId, set);
26532
+ }
26533
+ set.add(ws);
26534
+ },
26535
+ unsubscribe(inboxId, ws) {
26536
+ const set = subscriptions.get(inboxId);
26537
+ if (set) {
26538
+ set.delete(ws);
26539
+ if (set.size === 0) subscriptions.delete(inboxId);
26540
+ }
26541
+ },
26542
+ async notify(inboxId, messageId) {
26543
+ try {
26544
+ await listenClient`SELECT pg_notify(${INBOX_CHANNEL}, ${`${inboxId}:${messageId}`})`;
26545
+ } catch {}
26546
+ },
26547
+ async notifyConfigChange(configType) {
26548
+ try {
26549
+ await listenClient`SELECT pg_notify(${CONFIG_CHANNEL}, ${configType})`;
26550
+ } catch {}
26551
+ },
26552
+ onConfigChange(handler) {
26553
+ configChangeHandlers.push(handler);
26554
+ },
26555
+ async start() {
26556
+ unlistenInboxFn = (await listenClient.listen(INBOX_CHANNEL, (payload) => {
26557
+ if (payload) handleNotification(payload);
26558
+ })).unlisten;
26559
+ unlistenConfigFn = (await listenClient.listen(CONFIG_CHANNEL, (payload) => {
26560
+ if (payload) for (const handler of configChangeHandlers) handler(payload);
26561
+ })).unlisten;
26562
+ },
26563
+ async stop() {
26564
+ if (unlistenInboxFn) {
26565
+ await unlistenInboxFn();
26566
+ unlistenInboxFn = null;
26567
+ }
26568
+ if (unlistenConfigFn) {
26569
+ await unlistenConfigFn();
26570
+ unlistenConfigFn = null;
26571
+ }
26572
+ }
26573
+ };
26574
+ }
26575
+ /** Fire-and-forget: notify all recipients that a new message is available. */
26576
+ function notifyRecipients(notifier, recipients, messageId) {
26577
+ for (const inboxId of recipients) notifier.notify(inboxId, messageId).catch(() => {});
26578
+ }
26579
+ /** Server instance heartbeat. Used to detect crashed instances and clean up associated agent_presence records. */
26580
+ const serverInstances = pgTable("server_instances", {
26581
+ instanceId: text("instance_id").primaryKey(),
26582
+ lastHeartbeat: timestamp("last_heartbeat", { withTimezone: true }).notNull().defaultNow()
26583
+ });
26584
+ async function setOnline(db, agentId, instanceId) {
26585
+ const now = /* @__PURE__ */ new Date();
26586
+ await db.insert(agentPresence).values({
26587
+ agentId,
26588
+ status: "online",
26589
+ instanceId,
26590
+ connectedAt: now,
26591
+ lastSeenAt: now
26592
+ }).onConflictDoUpdate({
26593
+ target: agentPresence.agentId,
26594
+ set: {
26595
+ status: "online",
26596
+ instanceId,
26597
+ connectedAt: now,
26598
+ lastSeenAt: now
26599
+ }
26600
+ });
26601
+ }
26602
+ async function setOffline(db, agentId) {
26603
+ await db.update(agentPresence).set({
25715
26604
  status: "offline",
25716
26605
  instanceId: null,
25717
26606
  lastSeenAt: /* @__PURE__ */ new Date()
25718
26607
  }).where(eq(agentPresence.agentId, agentId));
25719
26608
  }
26609
+ async function getPresence(db, agentId) {
26610
+ const [row] = await db.select().from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
26611
+ return row ?? null;
26612
+ }
25720
26613
  async function getOnlineCount(db) {
25721
26614
  const [result] = await db.select({ count: sql`count(*)::int` }).from(agentPresence).where(eq(agentPresence.status, "online"));
25722
26615
  return result?.count ?? 0;
@@ -25801,6 +26694,53 @@ async function adminAgentRoutes(app) {
25801
26694
  await deleteAgent(app.db, request.params.agentId);
25802
26695
  return reply.status(204).send();
25803
26696
  });
26697
+ app.post("/:agentId/test", async (request, reply) => {
26698
+ const { agentId } = request.params;
26699
+ const [, presence] = await Promise.all([getAgent(app.db, agentId), getPresence(app.db, agentId)]);
26700
+ if (!presence || presence.status !== "online") return reply.status(200).send({
26701
+ status: "offline",
26702
+ message: "Agent is not connected. Start the client first."
26703
+ });
26704
+ const [owner] = await app.db.select({ id: agents.id }).from(agents).where(and(eq(agents.delegateMention, agentId), eq(agents.status, "active"))).limit(1);
26705
+ let senderId = owner?.id ?? null;
26706
+ if (!senderId) {
26707
+ const [other] = await app.db.select({ id: agents.id }).from(agents).where(and(ne(agents.id, agentId), eq(agents.status, "active"))).limit(1);
26708
+ senderId = other?.id ?? null;
26709
+ }
26710
+ if (!senderId) return reply.status(200).send({
26711
+ status: "error",
26712
+ message: "No suitable sender found. Need at least one other active agent."
26713
+ });
26714
+ const chat = await findOrCreateDirectChat(app.db, senderId, agentId);
26715
+ const testContent = `[System Test] Verify your connection. Respond with your identity and role. Time: ${(/* @__PURE__ */ new Date()).toISOString()}`;
26716
+ const result = await sendMessage(app.db, chat.id, senderId, {
26717
+ format: "text",
26718
+ content: testContent
26719
+ });
26720
+ notifyRecipients(app.notifier, result.recipients, result.message.id);
26721
+ const POLL_TIMEOUT = 3e4;
26722
+ const POLL_INTERVAL = 1e3;
26723
+ const threshold = result.message.createdAt;
26724
+ const pollStart = Date.now();
26725
+ while (Date.now() - pollStart < POLL_TIMEOUT) {
26726
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL));
26727
+ const [response] = await app.db.select().from(messages).where(and(eq(messages.chatId, chat.id), eq(messages.senderId, agentId), gt(messages.createdAt, threshold))).limit(1);
26728
+ if (response) {
26729
+ const content = typeof response.content === "string" ? response.content.slice(0, 500) : JSON.stringify(response.content).slice(0, 500);
26730
+ return reply.status(200).send({
26731
+ status: "success",
26732
+ chatId: chat.id,
26733
+ responseContent: content,
26734
+ responseTime: response.createdAt.getTime() - threshold.getTime()
26735
+ });
26736
+ }
26737
+ }
26738
+ return reply.status(200).send({
26739
+ status: "timeout",
26740
+ chatId: chat.id,
26741
+ message: "Agent is connected but did not respond within 30 seconds."
26742
+ });
26743
+ });
25804
26744
  }
25805
26745
  /** Admin accounts. Passwords are stored as bcrypt hashes. */
25806
26746
  const adminUsers = pgTable("admin_users", {
@@ -26039,8 +26979,10 @@ async function updateAdminUser(db, id, data) {
26039
26979
  return toResponse(row);
26040
26980
  }
26041
26981
  async function deleteAdminUser(db, id) {
26042
- const [row] = await db.delete(adminUsers).where(eq(adminUsers.id, id)).returning();
26043
- if (!row) throw new NotFoundError(`Admin user "${id}" not found`);
26982
+ const [target] = await db.select().from(adminUsers).where(eq(adminUsers.id, id)).limit(1);
26983
+ if (!target) throw new NotFoundError(`Admin user "${id}" not found`);
26984
+ if (target.role === "super_admin") throw new ForbiddenError("Cannot delete a super_admin account");
26985
+ await db.delete(adminUsers).where(eq(adminUsers.id, id));
26044
26986
  }
26045
26987
  function serializeDate(d) {
26046
26988
  return d ? d.toISOString() : null;
@@ -26087,133 +27029,6 @@ function requireAgent(request) {
26087
27029
  if (!agent) throw new UnauthorizedError("Agent authentication required");
26088
27030
  return agent;
26089
27031
  }
26090
- async function createChat(db, creatorId, data) {
26091
- const chatId = randomUUID();
26092
- const allParticipantIds = new Set([creatorId, ...data.participantIds]);
26093
- const existingAgents = await db.select({
26094
- id: agents.id,
26095
- organizationId: agents.organizationId
26096
- }).from(agents).where(inArray(agents.id, [...allParticipantIds]));
26097
- if (existingAgents.length !== allParticipantIds.size) {
26098
- const found = new Set(existingAgents.map((a) => a.id));
26099
- throw new BadRequestError(`Agents not found: ${[...allParticipantIds].filter((id) => !found.has(id)).join(", ")}`);
26100
- }
26101
- const creator = existingAgents.find((a) => a.id === creatorId);
26102
- if (!creator) throw new Error("Unexpected: creator not in existingAgents");
26103
- const orgId = creator.organizationId;
26104
- const crossOrg = existingAgents.filter((a) => a.organizationId !== orgId);
26105
- if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.id).join(", ")}`);
26106
- return db.transaction(async (tx) => {
26107
- const [chat] = await tx.insert(chats).values({
26108
- id: chatId,
26109
- organizationId: orgId,
26110
- type: data.type,
26111
- topic: data.topic ?? null,
26112
- metadata: data.metadata ?? {}
26113
- }).returning();
26114
- const participantRows = [...allParticipantIds].map((agentId) => ({
26115
- chatId,
26116
- agentId,
26117
- role: agentId === creatorId ? "owner" : "member"
26118
- }));
26119
- await tx.insert(chatParticipants).values(participantRows);
26120
- const participants = await tx.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
26121
- if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
26122
- return {
26123
- ...chat,
26124
- participants
26125
- };
26126
- });
26127
- }
26128
- async function getChat(db, chatId) {
26129
- const [chat] = await db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
26130
- if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
26131
- return chat;
26132
- }
26133
- async function getChatDetail(db, chatId) {
26134
- const chat = await getChat(db, chatId);
26135
- const participants = await db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
26136
- return {
26137
- ...chat,
26138
- participants
26139
- };
26140
- }
26141
- async function listChats(db, agentId, limit, cursor) {
26142
- const chatIds = (await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentId))).map((r) => r.chatId);
26143
- if (chatIds.length === 0) return {
26144
- items: [],
26145
- nextCursor: null
26146
- };
26147
- const where = cursor ? and(inArray(chats.id, chatIds), lt(chats.updatedAt, new Date(cursor))) : inArray(chats.id, chatIds);
26148
- const rows = await db.select().from(chats).where(where).orderBy(desc(chats.updatedAt)).limit(limit + 1);
26149
- const hasMore = rows.length > limit;
26150
- const items = hasMore ? rows.slice(0, limit) : rows;
26151
- const last = items[items.length - 1];
26152
- return {
26153
- items,
26154
- nextCursor: hasMore && last ? last.updatedAt.toISOString() : null
26155
- };
26156
- }
26157
- async function assertParticipant(db, chatId, agentId) {
26158
- const [row] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
26159
- if (!row) throw new ForbiddenError("Not a participant of this chat");
26160
- }
26161
- async function addParticipant(db, chatId, requesterId, data) {
26162
- const chat = await getChat(db, chatId);
26163
- await assertParticipant(db, chatId, requesterId);
26164
- const [targetAgent] = await db.select({
26165
- id: agents.id,
26166
- organizationId: agents.organizationId
26167
- }).from(agents).where(eq(agents.id, data.agentId)).limit(1);
26168
- if (!targetAgent) throw new NotFoundError(`Agent "${data.agentId}" not found`);
26169
- if (targetAgent.organizationId !== chat.organizationId) throw new BadRequestError("Cannot add agent from different organization");
26170
- const [existing] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, data.agentId))).limit(1);
26171
- if (existing) throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
26172
- await db.insert(chatParticipants).values({
26173
- chatId,
26174
- agentId: data.agentId,
26175
- mode: data.mode ?? "full"
26176
- });
26177
- return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
26178
- }
26179
- async function removeParticipant(db, chatId, requesterId, targetAgentId) {
26180
- await assertParticipant(db, chatId, requesterId);
26181
- if (requesterId === targetAgentId) throw new BadRequestError("Cannot remove yourself from a chat");
26182
- const [removed] = await db.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, targetAgentId))).returning();
26183
- if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
26184
- return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
26185
- }
26186
- async function findOrCreateDirectChat(db, agentAId, agentBId) {
26187
- const aChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentAId));
26188
- const bChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentBId));
26189
- const bChatIds = new Set(bChats.map((r) => r.chatId));
26190
- const commonChatIds = aChats.map((r) => r.chatId).filter((id) => bChatIds.has(id));
26191
- if (commonChatIds.length > 0) {
26192
- const directChats = await db.select().from(chats).where(and(inArray(chats.id, commonChatIds), eq(chats.type, "direct")));
26193
- if (directChats.length > 0 && directChats[0]) return directChats[0];
26194
- }
26195
- const [agentA] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.id, agentAId)).limit(1);
26196
- if (!agentA) throw new NotFoundError(`Agent "${agentAId}" not found`);
26197
- const chatId = randomUUID();
26198
- return db.transaction(async (tx) => {
26199
- const [chat] = await tx.insert(chats).values({
26200
- id: chatId,
26201
- organizationId: agentA.organizationId,
26202
- type: "direct"
26203
- }).returning();
26204
- await tx.insert(chatParticipants).values([{
26205
- chatId,
26206
- agentId: agentAId,
26207
- role: "member"
26208
- }, {
26209
- chatId,
26210
- agentId: agentBId,
26211
- role: "member"
26212
- }]);
26213
- if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
26214
- return chat;
26215
- });
26216
- }
26217
27032
  function serializeChat(chat) {
26218
27033
  return {
26219
27034
  ...chat,
@@ -26270,18 +27085,16 @@ async function agentChatRoutes(app) {
26270
27085
  return reply.status(204).send();
26271
27086
  });
26272
27087
  }
26273
- /** Delivery queue (envelope). One entry per recipient created during message fan-out. Uses SKIP LOCKED for concurrent-safe consumption. */
26274
- const inboxEntries = pgTable("inbox_entries", {
26275
- id: bigserial("id", { mode: "number" }).primaryKey(),
26276
- inboxId: text("inbox_id").notNull(),
26277
- messageId: text("message_id").notNull().references(() => messages.id),
26278
- chatId: text("chat_id"),
26279
- status: text("status").notNull().default("pending"),
26280
- retryCount: integer("retry_count").notNull().default(0),
26281
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
26282
- deliveredAt: timestamp("delivered_at", { withTimezone: true }),
26283
- ackedAt: timestamp("acked_at", { withTimezone: true })
26284
- }, (table) => [unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId), index("idx_inbox_pending").on(table.inboxId, table.createdAt)]);
27088
+ async function agentContextTreeRoutes(app) {
27089
+ app.get("/", async (_request, reply) => {
27090
+ const { repo, branch } = app.config.contextTree;
27091
+ if (!repo) return reply.status(404).send({ error: "Context Tree not configured" });
27092
+ return reply.send({
27093
+ repo,
27094
+ branch
27095
+ });
27096
+ });
27097
+ }
26285
27098
  const DEFAULT_INBOX_TIMEOUT_SECONDS = 300;
26286
27099
  const DEFAULT_MAX_RETRY_COUNT = 3;
26287
27100
  async function pollInbox(db, inboxId, limit) {
@@ -26394,171 +27207,6 @@ async function agentMeRoutes(app) {
26394
27207
  };
26395
27208
  });
26396
27209
  }
26397
- async function sendMessage(db, chatId, senderId, data) {
26398
- return db.transaction(async (tx) => {
26399
- const messageId = randomUUID();
26400
- const [msg] = await tx.insert(messages).values({
26401
- id: messageId,
26402
- chatId,
26403
- senderId,
26404
- format: data.format,
26405
- content: data.content,
26406
- metadata: data.metadata ?? {},
26407
- replyToInbox: data.replyToInbox ?? null,
26408
- replyToChat: data.replyToChat ?? null,
26409
- inReplyTo: data.inReplyTo ?? null
26410
- }).returning();
26411
- const entries = (await tx.select({
26412
- agentId: chatParticipants.agentId,
26413
- inboxId: agents.inboxId
26414
- }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.id)).where(eq(chatParticipants.chatId, chatId))).filter((p) => p.agentId !== senderId).map((p) => ({
26415
- inboxId: p.inboxId,
26416
- messageId,
26417
- chatId
26418
- }));
26419
- if (entries.length > 0) await tx.insert(inboxEntries).values(entries);
26420
- const recipients = entries.map((e) => e.inboxId);
26421
- if (data.inReplyTo) {
26422
- const [original] = await tx.select({
26423
- replyToInbox: messages.replyToInbox,
26424
- replyToChat: messages.replyToChat
26425
- }).from(messages).where(eq(messages.id, data.inReplyTo)).limit(1);
26426
- if (original?.replyToInbox && original?.replyToChat) {
26427
- await tx.insert(inboxEntries).values({
26428
- inboxId: original.replyToInbox,
26429
- messageId,
26430
- chatId: original.replyToChat
26431
- }).onConflictDoNothing();
26432
- if (!recipients.includes(original.replyToInbox)) recipients.push(original.replyToInbox);
26433
- }
26434
- }
26435
- await tx.update(chats).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(chats.id, chatId));
26436
- if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
26437
- return {
26438
- message: msg,
26439
- recipients
26440
- };
26441
- });
26442
- }
26443
- async function sendToAgent(db, senderId, targetAgentId, data) {
26444
- const [sender] = await db.select({
26445
- id: agents.id,
26446
- organizationId: agents.organizationId
26447
- }).from(agents).where(eq(agents.id, senderId)).limit(1);
26448
- if (!sender) throw new NotFoundError(`Agent "${senderId}" not found`);
26449
- const [target] = await db.select({
26450
- id: agents.id,
26451
- organizationId: agents.organizationId
26452
- }).from(agents).where(eq(agents.id, targetAgentId)).limit(1);
26453
- if (!target) throw new NotFoundError(`Agent "${targetAgentId}" not found`);
26454
- return sendMessage(db, (await findOrCreateDirectChat(db, senderId, targetAgentId)).id, senderId, {
26455
- format: data.format,
26456
- content: data.content,
26457
- metadata: data.metadata,
26458
- replyToInbox: data.replyToInbox,
26459
- replyToChat: data.replyToChat
26460
- });
26461
- }
26462
- async function editMessage(db, chatId, messageId, senderId, data) {
26463
- const [msg] = await db.select().from(messages).where(eq(messages.id, messageId)).limit(1);
26464
- if (!msg) throw new NotFoundError(`Message "${messageId}" not found`);
26465
- if (msg.chatId !== chatId) throw new NotFoundError(`Message "${messageId}" not found in this chat`);
26466
- if (msg.senderId !== senderId) throw new ForbiddenError("Only the sender can edit a message");
26467
- const setClause = {};
26468
- if (data.format !== void 0) setClause.format = data.format;
26469
- if (data.content !== void 0) setClause.content = data.content;
26470
- const meta = msg.metadata ?? {};
26471
- meta.editedAt = (/* @__PURE__ */ new Date()).toISOString();
26472
- setClause.metadata = meta;
26473
- const [updated] = await db.update(messages).set(setClause).where(eq(messages.id, messageId)).returning();
26474
- if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
26475
- return updated;
26476
- }
26477
- async function listMessages(db, chatId, limit, cursor) {
26478
- const where = cursor ? and(eq(messages.chatId, chatId), lt(messages.createdAt, new Date(cursor))) : eq(messages.chatId, chatId);
26479
- const rows = await db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(limit + 1);
26480
- const hasMore = rows.length > limit;
26481
- const items = hasMore ? rows.slice(0, limit) : rows;
26482
- const last = items[items.length - 1];
26483
- return {
26484
- items,
26485
- nextCursor: hasMore && last ? last.createdAt.toISOString() : null
26486
- };
26487
- }
26488
- const INBOX_CHANNEL = "inbox_notifications";
26489
- const CONFIG_CHANNEL = "config_changes";
26490
- function createNotifier(listenClient) {
26491
- const subscriptions = /* @__PURE__ */ new Map();
26492
- const configChangeHandlers = [];
26493
- let unlistenInboxFn = null;
26494
- let unlistenConfigFn = null;
26495
- function handleNotification(payload) {
26496
- const sepIdx = payload.indexOf(":");
26497
- if (sepIdx === -1) return;
26498
- const inboxId = payload.slice(0, sepIdx);
26499
- const messageId = payload.slice(sepIdx + 1);
26500
- const sockets = subscriptions.get(inboxId);
26501
- if (!sockets) return;
26502
- const data = JSON.stringify({
26503
- type: "new_message",
26504
- inboxId,
26505
- messageId
26506
- });
26507
- for (const ws of sockets) if (ws.readyState === ws.OPEN) ws.send(data);
26508
- }
26509
- return {
26510
- subscribe(inboxId, ws) {
26511
- let set = subscriptions.get(inboxId);
26512
- if (!set) {
26513
- set = /* @__PURE__ */ new Set();
26514
- subscriptions.set(inboxId, set);
26515
- }
26516
- set.add(ws);
26517
- },
26518
- unsubscribe(inboxId, ws) {
26519
- const set = subscriptions.get(inboxId);
26520
- if (set) {
26521
- set.delete(ws);
26522
- if (set.size === 0) subscriptions.delete(inboxId);
26523
- }
26524
- },
26525
- async notify(inboxId, messageId) {
26526
- try {
26527
- await listenClient`SELECT pg_notify(${INBOX_CHANNEL}, ${`${inboxId}:${messageId}`})`;
26528
- } catch {}
26529
- },
26530
- async notifyConfigChange(configType) {
26531
- try {
26532
- await listenClient`SELECT pg_notify(${CONFIG_CHANNEL}, ${configType})`;
26533
- } catch {}
26534
- },
26535
- onConfigChange(handler) {
26536
- configChangeHandlers.push(handler);
26537
- },
26538
- async start() {
26539
- unlistenInboxFn = (await listenClient.listen(INBOX_CHANNEL, (payload) => {
26540
- if (payload) handleNotification(payload);
26541
- })).unlisten;
26542
- unlistenConfigFn = (await listenClient.listen(CONFIG_CHANNEL, (payload) => {
26543
- if (payload) for (const handler of configChangeHandlers) handler(payload);
26544
- })).unlisten;
26545
- },
26546
- async stop() {
26547
- if (unlistenInboxFn) {
26548
- await unlistenInboxFn();
26549
- unlistenInboxFn = null;
26550
- }
26551
- if (unlistenConfigFn) {
26552
- await unlistenConfigFn();
26553
- unlistenConfigFn = null;
26554
- }
26555
- }
26556
- };
26557
- }
26558
- /** Fire-and-forget: notify all recipients that a new message is available. */
26559
- function notifyRecipients(notifier, recipients, messageId) {
26560
- for (const inboxId of recipients) notifier.notify(inboxId, messageId).catch(() => {});
26561
- }
26562
27210
  const editMessageSchema = z.object({
26563
27211
  format: z.string().optional(),
26564
27212
  content: z.unknown()
@@ -27752,6 +28400,7 @@ async function buildApp(config) {
27752
28400
  await agentApp.register(agentMessageRoutes, { prefix: "/chats" });
27753
28401
  await agentApp.register(agentSendToAgentRoutes, { prefix: "/agents" });
27754
28402
  await agentApp.register(agentInboxRoutes, { prefix: "/inbox" });
28403
+ await agentApp.register(agentContextTreeRoutes, { prefix: "/context-tree" });
27755
28404
  await agentApp.register(agentWsRoutes(notifier, config.instanceId), { prefix: "/ws" });
27756
28405
  }, { prefix: "/agent" });
27757
28406
  }, { prefix: "/api/v1" });
@@ -27911,4 +28560,4 @@ function resolveWebDist() {
27911
28560
  } catch {}
27912
28561
  }
27913
28562
  //#endregion
27914
- export { loadRuntimeConfig as A, readConfigFile as B, stopPostgres as C, FirstTreeHubSDK as D, AgentSlot as E, agentConfigSchema as F, resetConfigMeta as H, clientConfigSchema as I, getConfigValue as L, createAdminUser$1 as M, hasAdminUser as N, SdkError as O, DEFAULT_CONFIG_DIR as P, initConfig as R, isDockerAvailable as S, AgentRuntime as T, serverConfigSchema as U, resetConfig as V, setConfigValue as W, checkWebSocket as _, runMigrations as a, status as b, checkClientConfig as c, checkDocker as d, checkGitHubToken as f, checkServerReachable as g, checkServerHealth as h, promptMissingFields as i, registerBuiltinHandlers as j, getHandlerFactory as k, checkContextTreeRepo as l, checkServerConfig as m, isInteractive as n, checkAgentConfigs as o, checkNodeVersion as p, promptAddAgent as r, checkAgentTokens as s, startServer as t, checkDatabase as u, printResults as v, ClientRuntime as w, ensurePostgres as x, blank as y, loadAgents as z };
28563
+ export { SessionRegistry as A, clientConfigSchema as B, stopPostgres as C, DEFAULT_WORKSPACE_TTL_MS as D, AgentSlot as E, createAdminUser$1 as F, resetConfig as G, initConfig as H, hasAdminUser as I, setConfigValue as J, resetConfigMeta as K, DEFAULT_CONFIG_DIR as L, getHandlerFactory as M, loadRuntimeConfig as N, FirstTreeHubSDK as O, registerBuiltinHandlers as P, DEFAULT_DATA_DIR$1 as R, isDockerAvailable as S, AgentRuntime as T, loadAgents as U, getConfigValue as V, readConfigFile as W, checkWebSocket as _, runMigrations as a, status as b, checkClientConfig as c, checkDocker as d, checkGitHubToken as f, checkServerReachable as g, checkServerHealth as h, promptMissingFields as i, cleanWorkspaces as j, SdkError as k, checkContextTreeRepo as l, checkServerConfig as m, isInteractive as n, checkAgentConfigs as o, checkNodeVersion as p, serverConfigSchema as q, promptAddAgent as r, checkAgentTokens as s, startServer as t, checkDatabase as u, printResults as v, ClientRuntime as w, ensurePostgres as x, blank as y, agentConfigSchema as z };