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

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,13 +1,15 @@
1
+ import { a as getGitHubUsername, b as serverConfigSchema, c as DEFAULT_CONFIG_DIR, d as clientConfigSchema, f as collectMissingPrompts, h as loadAgents, m as initConfig, r as checkBootstrapStatus, s as resolveServerUrl, t as bootstrapToken$1, u as agentConfigSchema, x as setConfigValue, y as resolveConfigReadonly } from "./bootstrap-B9JsJR3Z.mjs";
1
2
  import { ZodError, z } from "zod";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
3
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
4
  import { dirname, join, resolve } from "node:path";
4
- import { parse, stringify } from "yaml";
5
+ import { parse } from "yaml";
5
6
  import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
6
7
  import { homedir } from "node:os";
7
8
  import bcrypt from "bcrypt";
8
- import { and, desc, eq, inArray, isNotNull, isNull, lt, ne, sql } from "drizzle-orm";
9
+ import { and, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, sql } from "drizzle-orm";
9
10
  import { drizzle } from "drizzle-orm/postgres-js";
10
11
  import postgres from "postgres";
12
+ import { execFileSync, execSync } from "node:child_process";
11
13
  import { EventEmitter } from "node:events";
12
14
  import WebSocket$1 from "ws";
13
15
  import { dirname as dirname$1, join as join$1 } from "path";
@@ -22,7 +24,6 @@ import { cwd } from "process";
22
24
  import * as r from "fs";
23
25
  import { realpathSync } from "fs";
24
26
  import { promisify } from "util";
25
- import { execFileSync, execSync } from "node:child_process";
26
27
  import { fileURLToPath as fileURLToPath$1 } from "node:url";
27
28
  import { migrate } from "drizzle-orm/postgres-js/migrator";
28
29
  import { input, password, select } from "@inquirer/prompts";
@@ -34,462 +35,6 @@ import Fastify from "fastify";
34
35
  import { bigserial, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique } from "drizzle-orm/pg-core";
35
36
  import { SignJWT, jwtVerify } from "jose";
36
37
  import { Client, EventDispatcher, LoggerLevel, WSClient } from "@larksuiteoapi/node-sdk";
37
- //#region ../shared/dist/config/index.mjs
38
- /** Declare a config field with a Zod schema and optional metadata. */
39
- function field(schema, options) {
40
- return {
41
- _tag: "field",
42
- _type: void 0,
43
- schema,
44
- options: options ?? {}
45
- };
46
- }
47
- /** Mark a config group as optional — present only when at least one field has an explicit value. */
48
- function optional(shape) {
49
- return {
50
- _tag: "optional",
51
- shape
52
- };
53
- }
54
- /** Define a config shape. Identity function used for type inference. */
55
- function defineConfig(shape) {
56
- return shape;
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)),
63
- session: {
64
- idle_timeout: field(z.number().int().positive().default(300)),
65
- max_sessions: field(z.number().int().positive().default(10))
66
- }
67
- });
68
- /** Store the resolved config as a singleton. Called by initConfig(). */
69
- function setConfig(config) {}
70
- /** Reset the config singleton. For testing only. */
71
- function resetConfig() {}
72
- const clientConfigSchema = defineConfig({
73
- server: { url: field(z.string(), {
74
- env: "FIRST_TREE_HUB_SERVER_URL",
75
- prompt: {
76
- message: "Server URL:",
77
- default: "http://localhost:8000"
78
- }
79
- }) },
80
- logLevel: field(z.enum([
81
- "debug",
82
- "info",
83
- "warn",
84
- "error"
85
- ]).default("info"), { env: "FIRST_TREE_HUB_LOG_LEVEL" })
86
- });
87
- const DEFAULT_CONFIG_DIR = join(homedir(), ".first-tree-hub");
88
- function isFieldDef(value) {
89
- return typeof value === "object" && value !== null && "_tag" in value && value._tag === "field";
90
- }
91
- function isOptionalGroup(value) {
92
- return typeof value === "object" && value !== null && "_tag" in value && value._tag === "optional";
93
- }
94
- function getByPath(obj, path) {
95
- let current = obj;
96
- for (const key of path) {
97
- if (current === null || current === void 0 || typeof current !== "object") return;
98
- current = current[key];
99
- }
100
- return current;
101
- }
102
- function setByPath(obj, path, value) {
103
- let current = obj;
104
- for (let i = 0; i < path.length - 1; i++) {
105
- const key = path[i];
106
- if (key === void 0) continue;
107
- if (!(key in current) || typeof current[key] !== "object" || current[key] === null) current[key] = {};
108
- current = current[key];
109
- }
110
- const lastKey = path.at(-1);
111
- if (lastKey !== void 0) current[lastKey] = value;
112
- }
113
- /** Unwrap ZodDefault / ZodOptional to get the inner type for coercion. */
114
- function unwrapZodType(schema) {
115
- if (schema instanceof z.ZodDefault) return unwrapZodType(schema._def.innerType);
116
- if (schema instanceof z.ZodOptional) return unwrapZodType(schema._def.innerType);
117
- return schema;
118
- }
119
- /** Coerce a string env var to the JS type expected by the Zod schema. */
120
- function coerceEnvValue(value, schema) {
121
- const inner = unwrapZodType(schema);
122
- if (inner instanceof z.ZodNumber) {
123
- const num = Number(value);
124
- return Number.isNaN(num) ? value : num;
125
- }
126
- if (inner instanceof z.ZodBoolean) {
127
- if (value === "true" || value === "1") return true;
128
- if (value === "false" || value === "0") return false;
129
- return value;
130
- }
131
- return value;
132
- }
133
- function builtinAutoGenerate(strategy) {
134
- const match = /^random:(\w+):(\d+)$/.exec(strategy);
135
- if (!match) throw new Error(`Unknown auto-generation strategy: ${strategy}`);
136
- const encoding = match[1];
137
- const bytes = Number(match[2]);
138
- if (!encoding) throw new Error(`Invalid auto-generation strategy: ${strategy}`);
139
- if (encoding === "base64url") return randomBytes(bytes).toString("base64url");
140
- if (encoding === "hex") return randomBytes(bytes).toString("hex");
141
- throw new Error(`Unknown random encoding: ${encoding}`);
142
- }
143
- function ensureDir(dir) {
144
- if (!existsSync(dir)) mkdirSync(dir, {
145
- recursive: true,
146
- mode: 448
147
- });
148
- }
149
- function deepMerge(target, source) {
150
- const result = { ...target };
151
- for (const [key, value] of Object.entries(source)) if (typeof value === "object" && value !== null && !Array.isArray(value) && typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key])) result[key] = deepMerge(result[key], value);
152
- else result[key] = value;
153
- return result;
154
- }
155
- function deepFreeze(obj) {
156
- if (typeof obj !== "object" || obj === null) return obj;
157
- Object.freeze(obj);
158
- for (const value of Object.values(obj)) deepFreeze(value);
159
- return obj;
160
- }
161
- function collectFields(shape, path = [], optionalGroupPath = null) {
162
- const fields = [];
163
- for (const [key, value] of Object.entries(shape)) {
164
- const currentPath = [...path, key];
165
- if (isFieldDef(value)) fields.push({
166
- path: currentPath,
167
- fieldDef: value,
168
- optionalGroupPath
169
- });
170
- else if (isOptionalGroup(value)) fields.push(...collectFields(value.shape, currentPath, currentPath));
171
- else if (typeof value === "object" && value !== null) fields.push(...collectFields(value, currentPath, optionalGroupPath));
172
- }
173
- return fields;
174
- }
175
- /** Build a Zod object schema from the config shape for validation. */
176
- function buildZodSchema(shape) {
177
- const zodShape = {};
178
- for (const [key, value] of Object.entries(shape)) if (isFieldDef(value)) zodShape[key] = value.schema;
179
- else if (isOptionalGroup(value)) zodShape[key] = buildZodSchema(value.shape).optional();
180
- else if (typeof value === "object" && value !== null) zodShape[key] = buildZodSchema(value).default({});
181
- return z.object(zodShape);
182
- }
183
- function resetConfigMeta() {}
184
- const CONFIG_HEADER = "# Generated by first-tree-hub. Edit as needed.\n# https://github.com/agent-team-foundation/first-tree-hub\n\n";
185
- /**
186
- * Initialize config from the priority chain:
187
- * CLI args > env vars > YAML file > auto-generated > Zod defaults
188
- *
189
- * Auto-generated values are written back to the YAML file.
190
- * Result is frozen and stored as a singleton accessible via getConfig().
191
- */
192
- async function initConfig(options) {
193
- const { schema, role, cliArgs = {}, autoGenerators = {} } = options;
194
- const configPath = join(options.configDir ?? DEFAULT_CONFIG_DIR, `${role}.yaml`);
195
- let fileValues = {};
196
- if (existsSync(configPath)) {
197
- const raw = parse(readFileSync(configPath, "utf-8"));
198
- if (typeof raw === "object" && raw !== null) fileValues = raw;
199
- }
200
- const fields = collectFields(schema);
201
- const resolved = {};
202
- const meta = /* @__PURE__ */ new Map();
203
- const autoGenerated = {};
204
- const activeOptionalGroups = /* @__PURE__ */ new Set();
205
- for (const { path, fieldDef, optionalGroupPath } of fields) {
206
- if (!optionalGroupPath) continue;
207
- const groupKey = optionalGroupPath.join(".");
208
- if (activeOptionalGroups.has(groupKey)) continue;
209
- if (getByPath(cliArgs, path) !== void 0) {
210
- activeOptionalGroups.add(groupKey);
211
- continue;
212
- }
213
- if (fieldDef.options.env) {
214
- const envValue = process.env[fieldDef.options.env];
215
- if (envValue !== void 0 && envValue !== "") {
216
- activeOptionalGroups.add(groupKey);
217
- continue;
218
- }
219
- }
220
- if (getByPath(fileValues, path) !== void 0) activeOptionalGroups.add(groupKey);
221
- }
222
- for (const { path, fieldDef, optionalGroupPath } of fields) {
223
- const dotPath = path.join(".");
224
- if (optionalGroupPath && !activeOptionalGroups.has(optionalGroupPath.join("."))) continue;
225
- const cliValue = getByPath(cliArgs, path);
226
- if (cliValue !== void 0) {
227
- setByPath(resolved, path, cliValue);
228
- meta.set(dotPath, {
229
- value: cliValue,
230
- source: "cli",
231
- secret: fieldDef.options.secret ?? false
232
- });
233
- continue;
234
- }
235
- if (fieldDef.options.env) {
236
- const envValue = process.env[fieldDef.options.env];
237
- if (envValue !== void 0 && envValue !== "") {
238
- const coerced = coerceEnvValue(envValue, fieldDef.schema);
239
- setByPath(resolved, path, coerced);
240
- meta.set(dotPath, {
241
- value: coerced,
242
- source: "env",
243
- secret: fieldDef.options.secret ?? false
244
- });
245
- continue;
246
- }
247
- }
248
- const fileValue = getByPath(fileValues, path);
249
- if (fileValue !== void 0) {
250
- setByPath(resolved, path, fileValue);
251
- meta.set(dotPath, {
252
- value: fileValue,
253
- source: "file",
254
- secret: fieldDef.options.secret ?? false
255
- });
256
- continue;
257
- }
258
- if (fieldDef.options.auto) {
259
- const strategy = fieldDef.options.auto;
260
- const customGen = autoGenerators[strategy];
261
- let generated;
262
- if (customGen) generated = await customGen();
263
- else generated = builtinAutoGenerate(strategy);
264
- setByPath(resolved, path, generated);
265
- setByPath(autoGenerated, path, generated);
266
- meta.set(dotPath, {
267
- value: generated,
268
- source: "auto",
269
- secret: fieldDef.options.secret ?? false
270
- });
271
- continue;
272
- }
273
- meta.set(dotPath, {
274
- value: void 0,
275
- source: "default",
276
- secret: fieldDef.options.secret ?? false
277
- });
278
- }
279
- const result = buildZodSchema(schema).safeParse(resolved);
280
- if (!result.success) {
281
- const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n");
282
- throw new Error(`Configuration validation failed:\n${issues}`);
283
- }
284
- const config = result.data;
285
- for (const { path, fieldDef } of fields) {
286
- const dotPath = path.join(".");
287
- if (meta.get(dotPath)?.value === void 0) {
288
- const val = getByPath(config, path);
289
- if (val !== void 0) meta.set(dotPath, {
290
- value: val,
291
- source: "default",
292
- secret: fieldDef.options.secret ?? false
293
- });
294
- }
295
- }
296
- if (Object.keys(autoGenerated).length > 0) {
297
- const merged = deepMerge(fileValues, autoGenerated);
298
- ensureDir(dirname(configPath));
299
- writeFileSync(configPath, CONFIG_HEADER + stringify(merged), { mode: 384 });
300
- }
301
- const frozen = deepFreeze(config);
302
- setConfig(frozen);
303
- return frozen;
304
- }
305
- /** Set a value in a YAML config file by dot-path. */
306
- function setConfigValue(configPath, dotPath, value) {
307
- let fileValues = {};
308
- if (existsSync(configPath)) {
309
- const raw = parse(readFileSync(configPath, "utf-8"));
310
- if (typeof raw === "object" && raw !== null) fileValues = raw;
311
- }
312
- setByPath(fileValues, dotPath.split("."), value);
313
- ensureDir(dirname(configPath));
314
- writeFileSync(configPath, CONFIG_HEADER + stringify(fileValues), { mode: 384 });
315
- }
316
- /** Get a value from a YAML config file by dot-path. */
317
- function getConfigValue(configPath, dotPath) {
318
- if (!existsSync(configPath)) return void 0;
319
- const raw = parse(readFileSync(configPath, "utf-8"));
320
- if (typeof raw !== "object" || raw === null) return void 0;
321
- return getByPath(raw, dotPath.split("."));
322
- }
323
- /** Read all values from a YAML config file. */
324
- function readConfigFile(configPath) {
325
- if (!existsSync(configPath)) return {};
326
- const raw = parse(readFileSync(configPath, "utf-8"));
327
- if (typeof raw !== "object" || raw === null) return {};
328
- return raw;
329
- }
330
- /**
331
- * Scan a config schema and return fields that:
332
- * 1. Have a `prompt` definition
333
- * 2. Don't have a value from CLI args, env vars, or the config file
334
- * 3. Don't have an `auto` strategy (auto-gen fields don't need prompting)
335
- *
336
- * Used by CLI to show interactive prompts before calling initConfig().
337
- */
338
- function collectMissingPrompts(options) {
339
- const { schema, role, cliArgs = {} } = options;
340
- const configPath = join(options.configDir ?? DEFAULT_CONFIG_DIR, `${role}.yaml`);
341
- let fileValues = {};
342
- if (existsSync(configPath)) {
343
- const raw = parse(readFileSync(configPath, "utf-8"));
344
- if (typeof raw === "object" && raw !== null) fileValues = raw;
345
- }
346
- const fields = collectFields(schema);
347
- const missing = [];
348
- for (const { path, fieldDef, optionalGroupPath } of fields) {
349
- if (optionalGroupPath) continue;
350
- if (!fieldDef.options.prompt) continue;
351
- if (getByPath(cliArgs, path) !== void 0) continue;
352
- if (fieldDef.options.env) {
353
- const envValue = process.env[fieldDef.options.env];
354
- if (envValue !== void 0 && envValue !== "") continue;
355
- }
356
- if (getByPath(fileValues, path) !== void 0) continue;
357
- missing.push({
358
- dotPath: path.join("."),
359
- prompt: fieldDef.options.prompt
360
- });
361
- }
362
- return missing;
363
- }
364
- /**
365
- * Resolve config values through the same priority chain as initConfig()
366
- * (env vars > YAML file > Zod defaults), but **without side effects**:
367
- * - No auto-generation
368
- * - No file writes
369
- * - No singleton mutation
370
- * - Partial results (unresolvable fields are omitted, no validation error)
371
- *
372
- * Returns the best-effort resolved config as a plain object.
373
- */
374
- function resolveConfigReadonly(options) {
375
- const { schema, role } = options;
376
- const configPath = join(options.configDir ?? DEFAULT_CONFIG_DIR, `${role}.yaml`);
377
- let fileValues = {};
378
- if (existsSync(configPath)) {
379
- const raw = parse(readFileSync(configPath, "utf-8"));
380
- if (typeof raw === "object" && raw !== null) fileValues = raw;
381
- }
382
- const fields = collectFields(schema);
383
- const resolved = {};
384
- for (const { path, fieldDef } of fields) {
385
- if (fieldDef.options.env) {
386
- const envValue = process.env[fieldDef.options.env];
387
- if (envValue !== void 0 && envValue !== "") {
388
- setByPath(resolved, path, coerceEnvValue(envValue, fieldDef.schema));
389
- continue;
390
- }
391
- }
392
- const fileValue = getByPath(fileValues, path);
393
- if (fileValue !== void 0) {
394
- setByPath(resolved, path, fileValue);
395
- continue;
396
- }
397
- const defaultResult = fieldDef.schema.safeParse(void 0);
398
- if (defaultResult.success && defaultResult.data !== void 0) setByPath(resolved, path, defaultResult.data);
399
- }
400
- return resolved;
401
- }
402
- /**
403
- * Scan an agents directory and load each agent's config.
404
- *
405
- * Expected structure:
406
- * {agentsDir}/
407
- * code-reviewer/agent.yaml
408
- * scheduler/agent.yaml
409
- *
410
- * Returns a Map keyed by directory name (agent name).
411
- */
412
- function loadAgents(options) {
413
- const { schema, agentsDir } = options;
414
- const result = /* @__PURE__ */ new Map();
415
- if (!existsSync(agentsDir)) return result;
416
- const zodSchema = buildZodSchema(schema);
417
- for (const entry of readdirSync(agentsDir)) {
418
- const agentDir = join(agentsDir, entry);
419
- if (!statSync(agentDir).isDirectory()) continue;
420
- const configPath = join(agentDir, "agent.yaml");
421
- if (!existsSync(configPath)) continue;
422
- const raw = parse(readFileSync(configPath, "utf-8"));
423
- const parsed = zodSchema.parse(raw);
424
- result.set(entry, parsed);
425
- }
426
- return result;
427
- }
428
- const serverConfigSchema = defineConfig({
429
- database: {
430
- url: field(z.string(), {
431
- env: "FIRST_TREE_HUB_DATABASE_URL",
432
- auto: "docker-pg",
433
- prompt: {
434
- message: "PostgreSQL:",
435
- type: "select",
436
- choices: [{
437
- name: "Auto-provision via Docker",
438
- value: "__auto__"
439
- }, {
440
- name: "Provide connection URL",
441
- value: "__input__"
442
- }]
443
- }
444
- }),
445
- provider: field(z.enum(["docker", "external"]).default("docker"))
446
- },
447
- 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" })
450
- },
451
- secrets: {
452
- jwtSecret: field(z.string(), {
453
- env: "FIRST_TREE_HUB_JWT_SECRET",
454
- auto: "random:base64url:32",
455
- secret: true
456
- }),
457
- encryptionKey: field(z.string(), {
458
- env: "FIRST_TREE_HUB_ENCRYPTION_KEY",
459
- auto: "random:hex:32",
460
- secret: true
461
- })
462
- },
463
- contextTree: {
464
- repo: field(z.string(), {
465
- env: "FIRST_TREE_HUB_CONTEXT_TREE_REPO",
466
- prompt: { message: "Context Tree repo URL (e.g. https://github.com/org/first-tree):" }
467
- }),
468
- branch: field(z.string().default("main")),
469
- syncInterval: field(z.number().default(60))
470
- },
471
- github: {
472
- token: field(z.string(), {
473
- env: "FIRST_TREE_HUB_GITHUB_TOKEN",
474
- secret: true,
475
- prompt: {
476
- message: "GitHub token (create at https://github.com/settings/tokens → repo scope):",
477
- type: "password"
478
- }
479
- }),
480
- webhookSecret: field(z.string().optional(), {
481
- env: "FIRST_TREE_HUB_GITHUB_WEBHOOK_SECRET",
482
- secret: true
483
- })
484
- },
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" })
490
- })
491
- });
492
- //#endregion
493
38
  //#region src/core/admin.ts
494
39
  /**
495
40
  * Check if any admin user exists.
@@ -22846,6 +22391,7 @@ function Aa({ prompt: $, options: X }) {
22846
22391
  }
22847
22392
  //#endregion
22848
22393
  //#region ../client/dist/index.mjs
22394
+ const FETCH_TIMEOUT_MS = 15e3;
22849
22395
  var FirstTreeHubSDK = class {
22850
22396
  _baseUrl;
22851
22397
  _token;
@@ -22868,9 +22414,16 @@ var FirstTreeHubSDK = class {
22868
22414
  agentId: agent.id,
22869
22415
  inboxId: agent.inboxId,
22870
22416
  status: agent.status,
22871
- displayName: agent.displayName
22417
+ displayName: agent.displayName,
22418
+ type: agent.type,
22419
+ delegateMention: agent.delegateMention ?? null,
22420
+ metadata: agent.metadata ?? {}
22872
22421
  };
22873
22422
  }
22423
+ /** Fetch Context Tree configuration from the server. */
22424
+ async getContextTreeConfig() {
22425
+ return this.requestJson("/api/v1/agent/context-tree");
22426
+ }
22874
22427
  /** Fetch pending inbox entries. */
22875
22428
  async pull(limit = 10) {
22876
22429
  return { entries: await this.requestJson(`/api/v1/agent/inbox?limit=${limit}`) };
@@ -22925,9 +22478,12 @@ var FirstTreeHubSDK = class {
22925
22478
  const url = `${this._baseUrl}${path}`;
22926
22479
  const headers = { Authorization: `Bearer ${this._token}` };
22927
22480
  if (init?.body) headers["Content-Type"] = "application/json";
22481
+ const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS);
22482
+ const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
22928
22483
  return fetch(url, {
22929
22484
  ...init,
22930
- headers
22485
+ headers,
22486
+ signal
22931
22487
  });
22932
22488
  }
22933
22489
  async toSdkError(response) {
@@ -22952,12 +22508,16 @@ const DEFAULT_POLLING_INTERVAL = 5e3;
22952
22508
  const DEFAULT_PULL_LIMIT = 10;
22953
22509
  const RECONNECT_BASE_MS = 1e3;
22954
22510
  const RECONNECT_MAX_MS = 3e4;
22511
+ const WS_CONNECT_TIMEOUT_MS = 1e4;
22512
+ const WS_PING_INTERVAL_MS = 3e3;
22955
22513
  var AgentConnection = class extends EventEmitter {
22956
22514
  sdk;
22957
22515
  _state = "disconnected";
22958
22516
  _agent = null;
22959
22517
  handler = null;
22960
22518
  ws = null;
22519
+ wsConnectTimer = null;
22520
+ wsPingTimer = null;
22961
22521
  pollingTimer = null;
22962
22522
  reconnectTimer = null;
22963
22523
  reconnectAttempt = 0;
@@ -22968,6 +22528,7 @@ var AgentConnection = class extends EventEmitter {
22968
22528
  pollingInterval;
22969
22529
  pullLimit;
22970
22530
  serverUrl;
22531
+ rateLimitedUntil = 0;
22971
22532
  constructor(config) {
22972
22533
  super();
22973
22534
  this.serverUrl = config.serverUrl.replace(/\/+$/, "");
@@ -23010,10 +22571,19 @@ var AgentConnection = class extends EventEmitter {
23010
22571
  }
23011
22572
  openWebSocket() {
23012
22573
  const ws = new WebSocket$1(`${this.serverUrl.replace(/^http/, "ws")}/api/v1/agent/ws/inbox`, { headers: { Authorization: `Bearer ${this.token}` } });
22574
+ this.wsConnectTimer = setTimeout(() => {
22575
+ this.wsConnectTimer = null;
22576
+ if (ws.readyState === WebSocket$1.CONNECTING) ws.terminate();
22577
+ }, WS_CONNECT_TIMEOUT_MS);
23013
22578
  ws.on("open", () => {
22579
+ if (this.wsConnectTimer) {
22580
+ clearTimeout(this.wsConnectTimer);
22581
+ this.wsConnectTimer = null;
22582
+ }
23014
22583
  this.reconnectAttempt = 0;
23015
22584
  this._state = "connected";
23016
22585
  this.emit("connected");
22586
+ this.startPing();
23017
22587
  this.startPolling();
23018
22588
  this.pullAndDispatch();
23019
22589
  });
@@ -23023,14 +22593,17 @@ var AgentConnection = class extends EventEmitter {
23023
22593
  } catch {}
23024
22594
  });
23025
22595
  ws.on("close", () => {
22596
+ this.stopPing();
23026
22597
  if (!this.closing) this.scheduleReconnect();
23027
22598
  });
23028
22599
  ws.on("error", (err) => {
22600
+ if (this.handleRateLimit(err)) return;
23029
22601
  this.emit("error", err);
23030
22602
  });
23031
22603
  this.ws = ws;
23032
22604
  }
23033
22605
  scheduleReconnect() {
22606
+ if (Date.now() < this.rateLimitedUntil) return;
23034
22607
  this._state = "reconnecting";
23035
22608
  this.reconnectAttempt++;
23036
22609
  this.emit("reconnecting", this.reconnectAttempt);
@@ -23041,6 +22614,18 @@ var AgentConnection = class extends EventEmitter {
23041
22614
  if (!this.closing) this.openWebSocket();
23042
22615
  }, delay);
23043
22616
  }
22617
+ startPing() {
22618
+ this.stopPing();
22619
+ this.wsPingTimer = setInterval(() => {
22620
+ if (this.ws?.readyState === WebSocket$1.OPEN) this.ws.ping();
22621
+ }, WS_PING_INTERVAL_MS);
22622
+ }
22623
+ stopPing() {
22624
+ if (this.wsPingTimer) {
22625
+ clearInterval(this.wsPingTimer);
22626
+ this.wsPingTimer = null;
22627
+ }
22628
+ }
23044
22629
  startPolling() {
23045
22630
  if (this.pollingTimer) return;
23046
22631
  this.pollingTimer = setInterval(() => {
@@ -23055,6 +22640,7 @@ var AgentConnection = class extends EventEmitter {
23055
22640
  }
23056
22641
  async pullAndDispatch() {
23057
22642
  if (this.closing || !this.handler) return;
22643
+ if (Date.now() < this.rateLimitedUntil) return;
23058
22644
  if (this.isPulling) {
23059
22645
  this.pullAgain = true;
23060
22646
  return;
@@ -23074,13 +22660,42 @@ var AgentConnection = class extends EventEmitter {
23074
22660
  }
23075
22661
  } while (this.pullAgain && !this.closing);
23076
22662
  } catch (err) {
22663
+ if (this.handleRateLimit(err)) return;
23077
22664
  this.emit("error", err instanceof Error ? err : new Error(String(err)));
23078
22665
  } finally {
23079
22666
  this.isPulling = false;
23080
22667
  }
23081
22668
  }
22669
+ /** Detect 429 responses and pause all activity. Returns true if rate-limited. */
22670
+ handleRateLimit(err) {
22671
+ const msg = err instanceof Error ? err.message : String(err);
22672
+ if (!msg.includes("429") && !msg.toLowerCase().includes("rate limit")) return false;
22673
+ const backoff = 6e4;
22674
+ this.rateLimitedUntil = Date.now() + backoff;
22675
+ this.emit("error", /* @__PURE__ */ new Error(`Rate limited, pausing for ${backoff / 1e3}s`));
22676
+ this.stopPolling();
22677
+ if (this.reconnectTimer) {
22678
+ clearTimeout(this.reconnectTimer);
22679
+ this.reconnectTimer = null;
22680
+ }
22681
+ setTimeout(() => {
22682
+ if (this.closing) return;
22683
+ this.rateLimitedUntil = 0;
22684
+ this.startPolling();
22685
+ if (!this.ws || this.ws.readyState !== WebSocket$1.OPEN) {
22686
+ this.reconnectAttempt = 0;
22687
+ this.openWebSocket();
22688
+ }
22689
+ }, backoff);
22690
+ return true;
22691
+ }
23082
22692
  clearTimers() {
23083
22693
  this.stopPolling();
22694
+ this.stopPing();
22695
+ if (this.wsConnectTimer) {
22696
+ clearTimeout(this.wsConnectTimer);
22697
+ this.wsConnectTimer = null;
22698
+ }
23084
22699
  if (this.reconnectTimer) {
23085
22700
  clearTimeout(this.reconnectTimer);
23086
22701
  this.reconnectTimer = null;
@@ -23102,40 +22717,335 @@ function getHandlerFactory(type) {
23102
22717
  }
23103
22718
  return factory;
23104
22719
  }
23105
- /**
23106
- * InputController push-based async iterable bridge.
23107
- *
23108
- * Bridges imperative `push()` calls to the `AsyncIterable` that
23109
- * the Agent SDK `query()` expects as streaming input.
23110
- */
23111
- var InputController = class {
23112
- buffer = [];
23113
- waiter = null;
23114
- done = false;
23115
- /** Push a message for the consumer. Buffered if consumer is busy. */
23116
- push(value) {
23117
- if (this.done) return;
23118
- if (this.waiter) {
23119
- const resolve = this.waiter;
23120
- this.waiter = null;
23121
- resolve({
23122
- value,
23123
- done: false
23124
- });
23125
- } else this.buffer.push(value);
22720
+ /** Declare a config field with a Zod schema and optional metadata. */
22721
+ function field(schema, options) {
22722
+ return {
22723
+ _tag: "field",
22724
+ _type: void 0,
22725
+ schema,
22726
+ options: options ?? {}
22727
+ };
22728
+ }
22729
+ /** Mark a config group as optional — present only when at least one field has an explicit value. */
22730
+ function optional(shape) {
22731
+ return {
22732
+ _tag: "optional",
22733
+ shape
22734
+ };
22735
+ }
22736
+ /** Define a config shape. Identity function used for type inference. */
22737
+ function defineConfig(shape) {
22738
+ return shape;
22739
+ }
22740
+ defineConfig({
22741
+ token: field(z.string(), { secret: true }),
22742
+ type: field(z.string().default("claude-code")),
22743
+ concurrency: field(z.number().int().positive().default(5)),
22744
+ session: {
22745
+ idle_timeout: field(z.number().int().positive().default(300)),
22746
+ max_sessions: field(z.number().int().positive().default(10))
23126
22747
  }
23127
- /** Signal no more messages will be sent. */
23128
- end() {
23129
- if (this.done) return;
23130
- this.done = true;
23131
- if (this.waiter) {
23132
- const resolve = this.waiter;
23133
- this.waiter = null;
23134
- resolve({
23135
- value: void 0,
23136
- done: true
23137
- });
23138
- }
22748
+ });
22749
+ defineConfig({
22750
+ server: { url: field(z.string(), {
22751
+ env: "FIRST_TREE_HUB_SERVER_URL",
22752
+ prompt: {
22753
+ message: "Server URL:",
22754
+ default: "http://localhost:8000"
22755
+ }
22756
+ }) },
22757
+ logLevel: field(z.enum([
22758
+ "debug",
22759
+ "info",
22760
+ "warn",
22761
+ "error"
22762
+ ]).default("info"), { env: "FIRST_TREE_HUB_LOG_LEVEL" })
22763
+ });
22764
+ const DEFAULT_HOME_DIR = join(homedir(), ".first-tree-hub");
22765
+ join(DEFAULT_HOME_DIR, "config");
22766
+ const DEFAULT_DATA_DIR = join(DEFAULT_HOME_DIR, "data");
22767
+ defineConfig({
22768
+ database: {
22769
+ url: field(z.string(), {
22770
+ env: "FIRST_TREE_HUB_DATABASE_URL",
22771
+ auto: "docker-pg",
22772
+ prompt: {
22773
+ message: "PostgreSQL:",
22774
+ type: "select",
22775
+ choices: [{
22776
+ name: "Auto-provision via Docker",
22777
+ value: "__auto__"
22778
+ }, {
22779
+ name: "Provide connection URL",
22780
+ value: "__input__"
22781
+ }]
22782
+ }
22783
+ }),
22784
+ provider: field(z.enum(["docker", "external"]).default("docker"))
22785
+ },
22786
+ server: {
22787
+ port: field(z.number().default(8e3), { env: "FIRST_TREE_HUB_PORT" }),
22788
+ host: field(z.string().default("127.0.0.1"), { env: "FIRST_TREE_HUB_HOST" })
22789
+ },
22790
+ secrets: {
22791
+ jwtSecret: field(z.string(), {
22792
+ env: "FIRST_TREE_HUB_JWT_SECRET",
22793
+ auto: "random:base64url:32",
22794
+ secret: true
22795
+ }),
22796
+ encryptionKey: field(z.string(), {
22797
+ env: "FIRST_TREE_HUB_ENCRYPTION_KEY",
22798
+ auto: "random:hex:32",
22799
+ secret: true
22800
+ })
22801
+ },
22802
+ contextTree: {
22803
+ repo: field(z.string(), {
22804
+ env: "FIRST_TREE_HUB_CONTEXT_TREE_REPO",
22805
+ prompt: { message: "Context Tree repo URL (e.g. https://github.com/org/first-tree):" }
22806
+ }),
22807
+ branch: field(z.string().default("main")),
22808
+ syncInterval: field(z.number().default(60))
22809
+ },
22810
+ github: {
22811
+ token: field(z.string(), {
22812
+ env: "FIRST_TREE_HUB_GITHUB_TOKEN",
22813
+ secret: true,
22814
+ prompt: {
22815
+ message: "GitHub token (create at https://github.com/settings/tokens → repo scope):",
22816
+ type: "password"
22817
+ }
22818
+ }),
22819
+ webhookSecret: field(z.string().optional(), {
22820
+ env: "FIRST_TREE_HUB_GITHUB_WEBHOOK_SECRET",
22821
+ secret: true
22822
+ })
22823
+ },
22824
+ cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
22825
+ rateLimit: optional({
22826
+ max: field(z.number().default(100), { env: "FIRST_TREE_HUB_RATE_LIMIT_MAX" }),
22827
+ loginMax: field(z.number().default(5), { env: "FIRST_TREE_HUB_RATE_LIMIT_LOGIN_MAX" }),
22828
+ webhookMax: field(z.number().default(60), { env: "FIRST_TREE_HUB_RATE_LIMIT_WEBHOOK_MAX" })
22829
+ })
22830
+ });
22831
+ const CONTEXT_TREE_DIR$1 = join(DEFAULT_DATA_DIR, "context-tree");
22832
+ /**
22833
+ * Sync the shared Context Tree git clone.
22834
+ *
22835
+ * Clones on first run, pulls on subsequent runs.
22836
+ * Returns the clone path on success, null on failure (graceful degradation).
22837
+ */
22838
+ async function syncContextTree(serverUrl, token, log) {
22839
+ try {
22840
+ execFileSync("git", ["--version"], { stdio: "ignore" });
22841
+ } catch {
22842
+ log("Context Tree sync skipped: git is not installed");
22843
+ return null;
22844
+ }
22845
+ let repo;
22846
+ let branch;
22847
+ try {
22848
+ const config = await new FirstTreeHubSDK({
22849
+ serverUrl,
22850
+ token
22851
+ }).getContextTreeConfig();
22852
+ repo = config.repo;
22853
+ branch = config.branch;
22854
+ } catch (err) {
22855
+ log(`Context Tree sync skipped: failed to fetch config from server (${err instanceof Error ? err.message : String(err)})`);
22856
+ return null;
22857
+ }
22858
+ try {
22859
+ if (existsSync(join(CONTEXT_TREE_DIR$1, ".git"))) {
22860
+ if (execFileSync("git", [
22861
+ "rev-parse",
22862
+ "--abbrev-ref",
22863
+ "HEAD"
22864
+ ], {
22865
+ cwd: CONTEXT_TREE_DIR$1,
22866
+ encoding: "utf-8",
22867
+ timeout: 5e3
22868
+ }).trim() !== branch) {
22869
+ execFileSync("git", ["checkout", branch], {
22870
+ cwd: CONTEXT_TREE_DIR$1,
22871
+ stdio: "pipe",
22872
+ timeout: 1e4
22873
+ });
22874
+ log(`Context Tree switched to branch ${branch}`);
22875
+ }
22876
+ execFileSync("git", ["pull", "--ff-only"], {
22877
+ cwd: CONTEXT_TREE_DIR$1,
22878
+ stdio: "pipe",
22879
+ timeout: 3e4
22880
+ });
22881
+ log(`Context Tree updated (pull)`);
22882
+ } else {
22883
+ mkdirSync(CONTEXT_TREE_DIR$1, { recursive: true });
22884
+ execFileSync("git", [
22885
+ "clone",
22886
+ "--branch",
22887
+ branch,
22888
+ "--single-branch",
22889
+ repo,
22890
+ CONTEXT_TREE_DIR$1
22891
+ ], {
22892
+ stdio: "pipe",
22893
+ timeout: 6e4
22894
+ });
22895
+ log(`Context Tree cloned from ${repo} (branch: ${branch})`);
22896
+ }
22897
+ return CONTEXT_TREE_DIR$1;
22898
+ } catch (err) {
22899
+ const msg = err instanceof Error ? err.message : String(err);
22900
+ log(`Context Tree sync failed: ${msg}`);
22901
+ log("Check that git credentials (SSH key or credential helper) are configured for this repo");
22902
+ if ((msg.includes("cannot fast-forward") || msg.includes("not possible to fast-forward") || msg.includes("CONFLICT")) && existsSync(join(CONTEXT_TREE_DIR$1, ".git"))) {
22903
+ log("Diverged history detected, attempting fresh clone...");
22904
+ try {
22905
+ rmSync(CONTEXT_TREE_DIR$1, {
22906
+ recursive: true,
22907
+ force: true
22908
+ });
22909
+ mkdirSync(CONTEXT_TREE_DIR$1, { recursive: true });
22910
+ execFileSync("git", [
22911
+ "clone",
22912
+ "--branch",
22913
+ branch,
22914
+ "--single-branch",
22915
+ repo,
22916
+ CONTEXT_TREE_DIR$1
22917
+ ], {
22918
+ stdio: "pipe",
22919
+ timeout: 6e4
22920
+ });
22921
+ log("Context Tree re-cloned successfully");
22922
+ return CONTEXT_TREE_DIR$1;
22923
+ } catch {
22924
+ log("Context Tree re-clone also failed, continuing without context");
22925
+ }
22926
+ }
22927
+ if (existsSync(join(CONTEXT_TREE_DIR$1, ".git"))) {
22928
+ log("Using existing Context Tree clone despite sync failure");
22929
+ return CONTEXT_TREE_DIR$1;
22930
+ }
22931
+ return null;
22932
+ }
22933
+ }
22934
+ /**
22935
+ * Bootstrap a workspace with .agent/ directory files.
22936
+ *
22937
+ * Writes identity.json, context/self.md (if context tree available), and tools.md.
22938
+ * Designed to be called on every handler start() and conditionally on resume().
22939
+ */
22940
+ function bootstrapWorkspace(options) {
22941
+ const { workspacePath, identity, contextTreePath, serverUrl, chatId } = options;
22942
+ const agentDir = join(workspacePath, ".agent");
22943
+ const contextDir = join(agentDir, "context");
22944
+ if (existsSync(contextDir)) rmSync(contextDir, {
22945
+ recursive: true,
22946
+ force: true
22947
+ });
22948
+ mkdirSync(contextDir, { recursive: true });
22949
+ const identityData = {
22950
+ agentId: identity.agentId,
22951
+ displayName: identity.displayName,
22952
+ type: identity.type,
22953
+ delegateMention: identity.delegateMention,
22954
+ metadata: identity.metadata,
22955
+ chatId,
22956
+ serverUrl,
22957
+ contextTreePath
22958
+ };
22959
+ writeFileSync(join(agentDir, "identity.json"), JSON.stringify(identityData, null, 2), "utf-8");
22960
+ if (contextTreePath) {
22961
+ const selfNodePath = join(contextTreePath, "members", identity.agentId, "NODE.md");
22962
+ if (existsSync(selfNodePath)) copyFileSync(selfNodePath, join(contextDir, "self.md"));
22963
+ const agentMdPath = join(contextTreePath, "AGENT.md");
22964
+ if (existsSync(agentMdPath)) copyFileSync(agentMdPath, join(contextDir, "agent-instructions.md"));
22965
+ const rootNodePath = join(contextTreePath, "NODE.md");
22966
+ if (existsSync(rootNodePath)) copyFileSync(rootNodePath, join(contextDir, "domain-map.md"));
22967
+ } 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");
22968
+ writeFileSync(join(agentDir, "tools.md"), generateToolsDoc(), "utf-8");
22969
+ }
22970
+ function generateToolsDoc() {
22971
+ return `# Agent Hub SDK
22972
+
22973
+ ## How You Communicate
22974
+
22975
+ You are running inside **Agent Hub**, a messaging platform for agent teams.
22976
+
22977
+ - Messages from other team members arrive as your prompt input
22978
+ - Each message includes a \`[From: sender-id]\` header so you know who sent it
22979
+ - **Your final text response is automatically delivered** to the chat — just respond normally
22980
+ - For **proactive communication** (sending to other agents, other chats, or structured data),
22981
+ use the curl API endpoints below
22982
+ - **Use your judgment about when to respond.** Not every message requires a reply.
22983
+ Your role and responsibilities (defined in your profile above) guide your behavior
22984
+
22985
+ ## Environment Variables
22986
+
22987
+ These are injected automatically when the agent process starts:
22988
+
22989
+ | Variable | Description |
22990
+ |----------|-------------|
22991
+ | \`FIRST_TREE_HUB_SERVER_URL\` | Server address for API calls |
22992
+ | \`FIRST_TREE_HUB_AGENT_TOKEN\` | Bearer token for authentication |
22993
+ | \`FIRST_TREE_HUB_CHAT_ID\` | Current chat context ID |
22994
+ | \`FIRST_TREE_HUB_AGENT_ID\` | Your agent ID |
22995
+
22996
+ ## Sending Messages
22997
+
22998
+ Use curl or any HTTP client with the bearer token:
22999
+
23000
+ \`\`\`bash
23001
+ # Reply in current chat
23002
+ curl -X POST "$FIRST_TREE_HUB_SERVER_URL/api/v1/agent/chats/$FIRST_TREE_HUB_CHAT_ID/messages" \\
23003
+ -H "Authorization: Bearer $FIRST_TREE_HUB_AGENT_TOKEN" \\
23004
+ -H "Content-Type: application/json" \\
23005
+ -d '{"format": "text", "content": "your message"}'
23006
+
23007
+ # Send to another agent directly
23008
+ curl -X POST "$FIRST_TREE_HUB_SERVER_URL/api/v1/agent/agents/{agentId}/messages" \\
23009
+ -H "Authorization: Bearer $FIRST_TREE_HUB_AGENT_TOKEN" \\
23010
+ -H "Content-Type: application/json" \\
23011
+ -d '{"format": "text", "content": "your message"}'
23012
+ \`\`\`
23013
+ `;
23014
+ }
23015
+ /**
23016
+ * InputController — push-based async iterable bridge.
23017
+ *
23018
+ * Bridges imperative `push()` calls to the `AsyncIterable` that
23019
+ * the Agent SDK `query()` expects as streaming input.
23020
+ */
23021
+ var InputController = class {
23022
+ buffer = [];
23023
+ waiter = null;
23024
+ done = false;
23025
+ /** Push a message for the consumer. Buffered if consumer is busy. */
23026
+ push(value) {
23027
+ if (this.done) return;
23028
+ if (this.waiter) {
23029
+ const resolve = this.waiter;
23030
+ this.waiter = null;
23031
+ resolve({
23032
+ value,
23033
+ done: false
23034
+ });
23035
+ } else this.buffer.push(value);
23036
+ }
23037
+ /** Signal no more messages will be sent. */
23038
+ end() {
23039
+ if (this.done) return;
23040
+ this.done = true;
23041
+ if (this.waiter) {
23042
+ const resolve = this.waiter;
23043
+ this.waiter = null;
23044
+ resolve({
23045
+ value: void 0,
23046
+ done: true
23047
+ });
23048
+ }
23139
23049
  }
23140
23050
  /** The async iterable consumed by `query()`. */
23141
23051
  get iterable() {
@@ -23156,6 +23066,46 @@ var InputController = class {
23156
23066
  });
23157
23067
  }
23158
23068
  };
23069
+ const DEFAULT_WORKSPACE_TTL_MS = 10080 * 60 * 1e3;
23070
+ /**
23071
+ * Acquire a per-chat workspace directory.
23072
+ * Creates the directory if it does not exist; returns the path if it does.
23073
+ */
23074
+ function acquireWorkspace(workspaceRoot, chatId) {
23075
+ const dir = join(workspaceRoot, chatId);
23076
+ mkdirSync(dir, { recursive: true });
23077
+ return dir;
23078
+ }
23079
+ /**
23080
+ * Clean stale workspace directories for an agent.
23081
+ *
23082
+ * A workspace is considered stale when:
23083
+ * 1. Its mtime is older than `ttlMs`
23084
+ * 2. Its chatId is NOT in the `activeChatIds` set
23085
+ *
23086
+ * Returns the list of removed chatIds.
23087
+ */
23088
+ function cleanWorkspaces(workspaceRoot, activeChatIds, ttlMs = DEFAULT_WORKSPACE_TTL_MS) {
23089
+ if (!existsSync(workspaceRoot)) return [];
23090
+ const now = Date.now();
23091
+ const removed = [];
23092
+ for (const entry of readdirSync(workspaceRoot)) {
23093
+ if (activeChatIds.has(entry)) continue;
23094
+ const entryPath = join(workspaceRoot, entry);
23095
+ try {
23096
+ const stat = statSync(entryPath);
23097
+ if (!stat.isDirectory()) continue;
23098
+ if (now - stat.mtimeMs > ttlMs) {
23099
+ rmSync(entryPath, {
23100
+ recursive: true,
23101
+ force: true
23102
+ });
23103
+ removed.push(entry);
23104
+ }
23105
+ } catch {}
23106
+ }
23107
+ return removed;
23108
+ }
23159
23109
  const MAX_RETRIES = 2;
23160
23110
  /**
23161
23111
  * Claude Code Handler — session-oriented handler using the Agent SDK.
@@ -23165,7 +23115,8 @@ const MAX_RETRIES = 2;
23165
23115
  * and session resume from disk for idle reclaim recovery.
23166
23116
  */
23167
23117
  const createClaudeCodeHandler = (config) => {
23168
- const cwd = config.cwd;
23118
+ const workspaceRoot = config.workspaceRoot;
23119
+ let cwd = null;
23169
23120
  let claudeSessionId = null;
23170
23121
  let currentQuery = null;
23171
23122
  let inputController = null;
@@ -23174,19 +23125,35 @@ const createClaudeCodeHandler = (config) => {
23174
23125
  let retryCount = 0;
23175
23126
  let ctx = null;
23176
23127
  function toSDKUserMessage(message, sessionId) {
23128
+ const rawContent = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
23177
23129
  return {
23178
23130
  type: "user",
23179
23131
  message: {
23180
23132
  role: "user",
23181
- content: typeof message.content === "string" ? message.content : JSON.stringify(message.content)
23133
+ content: message.senderId ? `[From: ${message.senderId}]\n\n${rawContent}` : rawContent
23182
23134
  },
23183
23135
  parent_tool_use_id: null,
23184
23136
  session_id: sessionId
23185
23137
  };
23186
23138
  }
23139
+ /**
23140
+ * Build env for the child Claude Code process.
23141
+ *
23142
+ * When the client runtime runs inside a Claude Code session (nested env),
23143
+ * process.env contains internal markers (CLAUDECODE, CLAUDE_CODE_ENTRYPOINT,
23144
+ * CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, npm_lifecycle_script) that cause the
23145
+ * child to enable Agent Teams infrastructure and use wrong init paths,
23146
+ * resulting in ~90s cold start vs ~17s standalone. Strip these so the child
23147
+ * starts clean; the SDK sets its own CLAUDE_CODE_ENTRYPOINT="sdk-ts".
23148
+ */
23187
23149
  function buildEnv(sessionCtx) {
23150
+ const env = { ...process.env };
23151
+ delete env.CLAUDECODE;
23152
+ delete env.CLAUDE_CODE_ENTRYPOINT;
23153
+ delete env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
23154
+ delete env.npm_lifecycle_script;
23188
23155
  return {
23189
- ...process.env,
23156
+ ...env,
23190
23157
  FIRST_TREE_HUB_SERVER_URL: sessionCtx.sdk.serverUrl,
23191
23158
  FIRST_TREE_HUB_AGENT_TOKEN: sessionCtx.sdk.agentToken,
23192
23159
  FIRST_TREE_HUB_CHAT_ID: sessionCtx.chatId,
@@ -23211,7 +23178,7 @@ const createClaudeCodeHandler = (config) => {
23211
23178
  options: {
23212
23179
  sessionId: resume ? void 0 : sessionId,
23213
23180
  resume,
23214
- cwd,
23181
+ cwd: cwd ?? void 0,
23215
23182
  persistSession: true,
23216
23183
  abortController,
23217
23184
  permissionMode,
@@ -23228,10 +23195,15 @@ const createClaudeCodeHandler = (config) => {
23228
23195
  sessionCtx.touch();
23229
23196
  if (message.type === "result") {
23230
23197
  const result = message;
23231
- if (result.subtype === "success") retryCount = 0;
23232
- else {
23198
+ if (result.subtype === "success") {
23199
+ retryCount = 0;
23200
+ if (result.result && sessionCtx.chatId) sessionCtx.sdk.sendMessage(sessionCtx.chatId, {
23201
+ format: "text",
23202
+ content: result.result
23203
+ }).then(() => sessionCtx.log("Result forwarded to chat")).catch((err) => sessionCtx.log(`Failed to forward result: ${err instanceof Error ? err.message : String(err)}`));
23204
+ } else {
23233
23205
  const errors = result.errors ? result.errors.join("; ") : result.subtype;
23234
- sessionCtx.log(`Query result error: ${errors}`);
23206
+ sessionCtx.log(`Query result error: ${errors} (subtype=${result.subtype}, turns=${result.num_turns ?? "?"}, duration=${result.duration_ms ?? "?"}ms)`);
23235
23207
  }
23236
23208
  }
23237
23209
  }
@@ -23239,6 +23211,13 @@ const createClaudeCodeHandler = (config) => {
23239
23211
  } catch (err) {
23240
23212
  const errMsg = err instanceof Error ? err.message : String(err);
23241
23213
  sessionCtx.log(`Query error: ${errMsg}`);
23214
+ if (err instanceof Error) {
23215
+ if (err.cause) sessionCtx.log(` cause: ${err.cause instanceof Error ? err.cause.message : String(err.cause)}`);
23216
+ if ("exitCode" in err) sessionCtx.log(` exitCode: ${err.exitCode}`);
23217
+ if ("stderr" in err) sessionCtx.log(` stderr: ${err.stderr}`);
23218
+ if ("code" in err) sessionCtx.log(` code: ${err.code}`);
23219
+ if (err.stack) sessionCtx.log(` stack: ${err.stack.split("\n").slice(1, 4).join(" | ")}`);
23220
+ }
23242
23221
  if (retryCount >= MAX_RETRIES || !claudeSessionId) {
23243
23222
  sessionCtx.log("Exhausted retries, session will be suspended");
23244
23223
  return;
@@ -23254,12 +23233,28 @@ const createClaudeCodeHandler = (config) => {
23254
23233
  }
23255
23234
  }
23256
23235
  }
23236
+ const contextTreePath = config.contextTreePath ?? null;
23237
+ /** Bootstrap workspace and generate CLAUDE.md. */
23238
+ function runBootstrap(workspace, sessionCtx) {
23239
+ bootstrapWorkspace({
23240
+ workspacePath: workspace,
23241
+ identity: sessionCtx.agent,
23242
+ contextTreePath,
23243
+ serverUrl: sessionCtx.sdk.serverUrl,
23244
+ chatId: sessionCtx.chatId
23245
+ });
23246
+ generateClaudeMd(workspace, sessionCtx.agent, contextTreePath);
23247
+ }
23257
23248
  const handler = {
23258
23249
  async start(message, sessionCtx) {
23259
23250
  ctx = sessionCtx;
23260
23251
  claudeSessionId = randomUUID();
23252
+ cwd = acquireWorkspace(workspaceRoot, sessionCtx.chatId);
23253
+ runBootstrap(cwd, sessionCtx);
23254
+ sessionCtx.log(`Starting session (${claudeSessionId}), cwd=${cwd}, permissionMode=${config.permissionMode ?? "bypassPermissions"}`);
23261
23255
  spawnQuery(claudeSessionId, sessionCtx);
23262
- inputController?.push(toSDKUserMessage(message, claudeSessionId));
23256
+ const sdkMsg = toSDKUserMessage(message, claudeSessionId);
23257
+ inputController?.push(sdkMsg);
23263
23258
  sessionCtx.log(`Session started (${claudeSessionId})`);
23264
23259
  return claudeSessionId;
23265
23260
  },
@@ -23267,6 +23262,9 @@ const createClaudeCodeHandler = (config) => {
23267
23262
  ctx = sessionCtx;
23268
23263
  claudeSessionId = sessionId;
23269
23264
  retryCount = 0;
23265
+ cwd = acquireWorkspace(workspaceRoot, sessionCtx.chatId);
23266
+ if (!existsSync(join(cwd, ".agent", "identity.json"))) runBootstrap(cwd, sessionCtx);
23267
+ sessionCtx.log(`Resuming session (${sessionId}), cwd=${cwd}`);
23270
23268
  spawnQuery(sessionId, sessionCtx, sessionId);
23271
23269
  inputController?.push(toSDKUserMessage(message, sessionId));
23272
23270
  sessionCtx.log(`Session resumed (${sessionId})`);
@@ -23301,6 +23299,50 @@ const createClaudeCodeHandler = (config) => {
23301
23299
  };
23302
23300
  return handler;
23303
23301
  };
23302
+ /**
23303
+ * Generate a CLAUDE.md file from .agent/ bootstrap data.
23304
+ *
23305
+ * Layered Bootstrap:
23306
+ * Layer 1 (always): Agent identity + member profile + AGENT.md operating instructions
23307
+ * Layer 2 (if available): Organization domain map from root NODE.md
23308
+ * Layer 3 (on-demand): Agent reads specific domain nodes via contextTreePath
23309
+ */
23310
+ function generateClaudeMd(workspacePath, identity, contextTreePath) {
23311
+ const sections = [];
23312
+ const contextDir = join(workspacePath, ".agent", "context");
23313
+ const name = identity.displayName ?? identity.agentId;
23314
+ if (identity.type === "personal_assistant") sections.push(`# Agent Identity\n\nYou are ${name}, a personal assistant agent.\n`);
23315
+ else sections.push(`# Agent Identity\n\nYou are ${name}, an autonomous agent.\n`);
23316
+ const selfMdPath = join(contextDir, "self.md");
23317
+ if (existsSync(selfMdPath)) {
23318
+ const selfContent = readFileSync(selfMdPath, "utf-8");
23319
+ sections.push(`## Your Profile\n\n${selfContent}\n`);
23320
+ } else sections.push("## Your Profile\n\nNo member profile available. Your responsibilities are not loaded from the Context Tree.\n");
23321
+ const agentInstructionsPath = join(contextDir, "agent-instructions.md");
23322
+ if (existsSync(agentInstructionsPath)) {
23323
+ const instructions = readFileSync(agentInstructionsPath, "utf-8");
23324
+ sections.push(`## Context Tree Operating Instructions\n\n${instructions}\n`);
23325
+ } else sections.push("## Context Tree Operating Instructions\n\nContext Tree instructions unavailable. Organizational context is not loaded for this session.\n");
23326
+ const domainMapPath = join(contextDir, "domain-map.md");
23327
+ if (existsSync(domainMapPath)) {
23328
+ const domainMap = readFileSync(domainMapPath, "utf-8");
23329
+ sections.push(`## Organization Domain Map\n\n${domainMap}\n`);
23330
+ }
23331
+ 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`);
23332
+ else {
23333
+ const degradedPath = join(contextDir, "degraded.md");
23334
+ if (existsSync(degradedPath)) {
23335
+ const degradedMsg = readFileSync(degradedPath, "utf-8");
23336
+ sections.push(`## Context Tree Location\n\nWARNING: ${degradedMsg}\nYou can still use the SDK tools below, but you lack organizational context for decisions.\n`);
23337
+ }
23338
+ }
23339
+ const toolsPath = join(workspacePath, ".agent", "tools.md");
23340
+ if (existsSync(toolsPath)) {
23341
+ const toolsContent = readFileSync(toolsPath, "utf-8");
23342
+ sections.push(toolsContent);
23343
+ }
23344
+ writeFileSync(join(workspacePath, "CLAUDE.md"), sections.join("\n"), "utf-8");
23345
+ }
23304
23346
  /** Register all built-in handlers. Call once at startup. */
23305
23347
  function registerBuiltinHandlers() {
23306
23348
  registerHandler("claude-code", createClaudeCodeHandler);
@@ -23700,18 +23742,24 @@ var AgentSlot = class {
23700
23742
  this.connection.on("reconnecting", (attempt) => this.logFn(`Reconnecting (attempt ${attempt})...`));
23701
23743
  this.connection.on("error", (err) => this.logFn(`Error: ${err.message}`));
23702
23744
  }
23703
- async start() {
23745
+ async start(contextTreePath) {
23704
23746
  const agent = await this.connection.connect();
23705
23747
  this.logFn(`Registered as ${agent.displayName ?? agent.agentId} (${agent.agentId})`);
23706
- const registryPath = join(homedir(), ".first-tree-hub", "data", "sessions", `${this.config.name}.json`);
23748
+ const registryPath = join(DEFAULT_DATA_DIR, "sessions", `${this.config.name}.json`);
23707
23749
  this.sessionManager = new SessionManager({
23708
23750
  session: this.config.session,
23709
23751
  concurrency: this.config.concurrency,
23710
23752
  handlerFactory: this.config.handlerFactory,
23711
- handlerConfig: { cwd: this.config.cwd ?? process.cwd() },
23753
+ handlerConfig: {
23754
+ workspaceRoot: join(DEFAULT_DATA_DIR, "workspaces", this.config.name),
23755
+ contextTreePath: contextTreePath ?? void 0
23756
+ },
23712
23757
  agentIdentity: {
23713
23758
  agentId: agent.agentId,
23714
- displayName: agent.displayName
23759
+ displayName: agent.displayName,
23760
+ type: agent.type,
23761
+ delegateMention: agent.delegateMention,
23762
+ metadata: agent.metadata
23715
23763
  },
23716
23764
  sdk: this.connection.sdk,
23717
23765
  log: this.logFn,
@@ -23735,7 +23783,6 @@ const sessionConfigSchema = z.object({
23735
23783
  const agentSlotConfigSchema = z.object({
23736
23784
  token: z.string().min(1),
23737
23785
  type: z.string().min(1),
23738
- cwd: z.string().optional(),
23739
23786
  session: sessionConfigSchema.default({}),
23740
23787
  concurrency: z.number().int().positive().default(5)
23741
23788
  });
@@ -23767,30 +23814,34 @@ function loadRuntimeConfig(configPath) {
23767
23814
  const DEFAULT_SHUTDOWN_TIMEOUT = 3e4;
23768
23815
  var AgentRuntime = class {
23769
23816
  slots = [];
23817
+ config;
23770
23818
  shutdownTimeout;
23771
23819
  stopping = false;
23772
23820
  constructor(options) {
23773
- const { config } = options;
23821
+ this.config = options.config;
23774
23822
  this.shutdownTimeout = options.shutdownTimeout ?? DEFAULT_SHUTDOWN_TIMEOUT;
23775
- for (const [name, agentConfig] of Object.entries(config.agents)) {
23823
+ for (const [name, agentConfig] of Object.entries(this.config.agents)) {
23776
23824
  const handlerFactory = getHandlerFactory(agentConfig.type);
23777
23825
  this.slots.push(new AgentSlot({
23778
23826
  name,
23779
- serverUrl: config.server,
23827
+ serverUrl: this.config.server,
23780
23828
  token: agentConfig.token,
23781
23829
  type: agentConfig.type,
23782
23830
  handlerFactory,
23783
23831
  session: agentConfig.session,
23784
- concurrency: agentConfig.concurrency,
23785
- cwd: agentConfig.cwd
23832
+ concurrency: agentConfig.concurrency
23786
23833
  }));
23787
23834
  }
23788
23835
  }
23789
23836
  /** Start all agent slots and block until shutdown signal. */
23790
23837
  async start() {
23791
23838
  const log = (msg) => process.stderr.write(`[runtime] ${msg}\n`);
23839
+ const firstToken = Object.values(this.config.agents)[0]?.token;
23840
+ let contextTreePath = null;
23841
+ if (firstToken) contextTreePath = await syncContextTree(this.config.server, firstToken, log);
23842
+ if (!contextTreePath) log("WARNING: Context Tree sync failed — agents will start without organizational context");
23792
23843
  log(`Starting ${this.slots.length} agent(s)...`);
23793
- const results = await Promise.allSettled(this.slots.map((slot) => slot.start()));
23844
+ const results = await Promise.allSettled(this.slots.map((slot) => slot.start(contextTreePath)));
23794
23845
  let failed = 0;
23795
23846
  for (const result of results) if (result.status === "rejected") {
23796
23847
  log(`Failed to start agent: ${result.reason instanceof Error ? result.reason.message : result.reason}`);
@@ -24467,57 +24518,568 @@ async function runMigrations(databaseUrl) {
24467
24518
  }
24468
24519
  }
24469
24520
  //#endregion
24470
- //#region src/core/prompt.ts
24471
- /**
24472
- * Check if interactive mode is available.
24473
- * Returns false if --no-interactive flag is set or stdin is not a TTY.
24474
- */
24475
- function isInteractive(noInteractiveFlag) {
24476
- if (noInteractiveFlag) return false;
24477
- return process.stdin.isTTY === true;
24521
+ //#region src/core/onboard.ts
24522
+ const STATE_FILE = join(homedir(), ".first-tree-hub", ".onboard-state.json");
24523
+ /** Save current onboard args to state file for resume. */
24524
+ function saveOnboardState(args) {
24525
+ mkdirSync(join(homedir(), ".first-tree-hub"), { recursive: true });
24526
+ writeFileSync(STATE_FILE, JSON.stringify({ args }, null, 2));
24527
+ }
24528
+ /** Load saved onboard args from state file. */
24529
+ function loadOnboardState() {
24530
+ try {
24531
+ return JSON.parse(readFileSync(STATE_FILE, "utf-8")).args;
24532
+ } catch {
24533
+ return null;
24534
+ }
24478
24535
  }
24479
- /**
24480
- * Schema-driven interactive setup.
24481
- * Scans the config schema for fields with `prompt` that are missing.
24482
- *
24483
- * In interactive mode: prompts the user and writes results to YAML.
24484
- * In non-interactive mode: fails with a clear error listing missing fields.
24485
- */
24486
- async function promptMissingFields(options) {
24487
- const missing = collectMissingPrompts({
24488
- schema: options.schema,
24489
- role: options.role,
24490
- configDir: options.configDir,
24491
- cliArgs: options.cliArgs
24492
- });
24493
- if (missing.length === 0) return {};
24494
- if (!isInteractive(options.noInteractive)) {
24495
- const lines = missing.map((m) => {
24496
- const envHint = findEnvVar(options.schema, m.dotPath);
24497
- const envStr = envHint ? ` (env: ${envHint})` : "";
24498
- return ` ${m.dotPath}${envStr}`;
24536
+ async function onboardCheck(args) {
24537
+ const items = [];
24538
+ let ghUsername = null;
24539
+ try {
24540
+ ghUsername = getGitHubUsername();
24541
+ items.push({
24542
+ key: "github_cli",
24543
+ label: "GitHub CLI",
24544
+ status: "ok",
24545
+ value: `authenticated as ${ghUsername}`
24546
+ });
24547
+ } catch {
24548
+ items.push({
24549
+ key: "github_cli",
24550
+ label: "GitHub CLI",
24551
+ status: "missing_required",
24552
+ hint: "Install and authenticate: gh auth login"
24499
24553
  });
24500
- throw new Error(`Missing required configuration:\n${lines.join("\n")}\n\nProvide values via environment variables, config file (~/.first-tree-hub/server.yaml),
24501
- or run without --no-interactive to use the interactive setup wizard.`);
24502
24554
  }
24503
- const configPath = join(options.configDir ?? DEFAULT_CONFIG_DIR, `${options.role}.yaml`);
24504
- const results = {};
24505
- for (const { dotPath, prompt } of missing) {
24506
- const value = await askPrompt(dotPath, prompt);
24507
- if (value !== void 0) {
24508
- setConfigValue(configPath, dotPath, value);
24509
- setNestedByDot(results, dotPath, value);
24555
+ try {
24556
+ const serverUrl = resolveServerUrl(args.server);
24557
+ items.push({
24558
+ key: "server",
24559
+ label: "Server URL",
24560
+ status: "ok",
24561
+ value: serverUrl
24562
+ });
24563
+ try {
24564
+ const res = await fetch(`${serverUrl}/api/v1/health`);
24565
+ items.push({
24566
+ key: "server_reachable",
24567
+ label: "Server reachable",
24568
+ status: res.ok ? "ok" : "error",
24569
+ value: res.ok ? "yes" : `HTTP ${res.status}`
24570
+ });
24571
+ } catch {
24572
+ items.push({
24573
+ key: "server_reachable",
24574
+ label: "Server reachable",
24575
+ status: "error",
24576
+ value: "no"
24577
+ });
24510
24578
  }
24579
+ } catch {
24580
+ items.push({
24581
+ key: "server",
24582
+ label: "Server URL",
24583
+ status: "missing_required",
24584
+ hint: "--server <url> or FIRST_TREE_HUB_SERVER"
24585
+ });
24511
24586
  }
24512
- return results;
24513
- }
24514
- /**
24515
- * Interactive add agent — simple two-field prompt.
24516
- */
24517
- async function promptAddAgent() {
24518
- return {
24519
- name: await input({
24520
- message: "Agent name:",
24587
+ const repoPath = await resolveContextTreeRepo(args.server);
24588
+ if (repoPath) items.push({
24589
+ key: "repo",
24590
+ label: "Context Tree repo",
24591
+ status: "ok",
24592
+ value: repoPath
24593
+ });
24594
+ else {
24595
+ const serverAvailable = items.some((i) => i.key === "server" && i.status === "ok");
24596
+ items.push({
24597
+ key: "repo",
24598
+ label: "Context Tree repo",
24599
+ status: "missing_required",
24600
+ hint: serverAvailable ? "auto-clone failed (check server Context Tree config and gh auth)" : "configure --server first (repo will be auto-cloned from server)"
24601
+ });
24602
+ }
24603
+ items.push(args.id ? {
24604
+ key: "id",
24605
+ label: "id",
24606
+ status: "ok",
24607
+ value: args.id
24608
+ } : {
24609
+ key: "id",
24610
+ label: "id",
24611
+ status: "missing_required",
24612
+ hint: "Member directory name"
24613
+ });
24614
+ items.push(args.type ? {
24615
+ key: "type",
24616
+ label: "type",
24617
+ status: "ok",
24618
+ value: args.type
24619
+ } : {
24620
+ key: "type",
24621
+ label: "type",
24622
+ status: "missing_required",
24623
+ hint: "human | personal_assistant | autonomous_agent"
24624
+ });
24625
+ items.push(args.role ? {
24626
+ key: "role",
24627
+ label: "role",
24628
+ status: "ok",
24629
+ value: args.role
24630
+ } : {
24631
+ key: "role",
24632
+ label: "role",
24633
+ status: "missing_required",
24634
+ hint: "e.g. \"Engineer\""
24635
+ });
24636
+ items.push(args.domains ? {
24637
+ key: "domains",
24638
+ label: "domains",
24639
+ status: "ok",
24640
+ value: args.domains
24641
+ } : {
24642
+ key: "domains",
24643
+ label: "domains",
24644
+ status: "missing_required",
24645
+ hint: "Comma-separated, e.g. \"backend,infra\""
24646
+ });
24647
+ items.push(args.displayName ? {
24648
+ key: "display_name",
24649
+ label: "display-name",
24650
+ status: "ok",
24651
+ value: args.displayName
24652
+ } : {
24653
+ key: "display_name",
24654
+ label: "display-name",
24655
+ status: "missing_optional",
24656
+ hint: `defaults to "${args.id ?? ""}"`
24657
+ });
24658
+ items.push(args.assistant ? {
24659
+ key: "assistant",
24660
+ label: "assistant",
24661
+ status: "ok",
24662
+ value: args.assistant
24663
+ } : {
24664
+ key: "assistant",
24665
+ label: "assistant",
24666
+ status: "missing_optional",
24667
+ hint: "Also create a personal_assistant"
24668
+ });
24669
+ items.push(args.feishuBotAppId ? {
24670
+ key: "feishu_bot",
24671
+ label: "feishu-bot-app-id",
24672
+ status: "ok",
24673
+ value: args.feishuBotAppId
24674
+ } : {
24675
+ key: "feishu_bot",
24676
+ label: "feishu-bot-app-id",
24677
+ status: "missing_optional",
24678
+ hint: "Feishu bot App ID for assistant"
24679
+ });
24680
+ if (args.id && repoPath) if (existsSync(join(repoPath, "members", args.id))) try {
24681
+ execSync(`git ls-files --error-unmatch members/${args.id}/NODE.md`, {
24682
+ cwd: repoPath,
24683
+ stdio: "pipe"
24684
+ });
24685
+ items.push({
24686
+ key: "conflict",
24687
+ label: `ID "${args.id}" availability`,
24688
+ status: "warning",
24689
+ value: "already exists (will overwrite)"
24690
+ });
24691
+ } catch {
24692
+ items.push({
24693
+ key: "conflict",
24694
+ label: `ID "${args.id}" availability`,
24695
+ status: "ok",
24696
+ value: "resuming (local files from previous run)"
24697
+ });
24698
+ }
24699
+ else items.push({
24700
+ key: "conflict",
24701
+ label: `ID "${args.id}" availability`,
24702
+ status: "ok",
24703
+ value: "available"
24704
+ });
24705
+ return items;
24706
+ }
24707
+ function formatCheckReport(items) {
24708
+ const lines = [];
24709
+ for (const item of items) {
24710
+ const icon = item.status === "ok" ? "✅" : item.status === "missing_required" ? "❌" : item.status === "error" ? "❌" : item.status === "warning" ? "⚠️" : "⬜";
24711
+ const valueStr = item.value ? ` ${item.value}` : "";
24712
+ const hintStr = item.hint ? ` (${item.hint})` : "";
24713
+ lines.push(` ${icon} ${item.label.padEnd(20)}${valueStr}${hintStr}`);
24714
+ }
24715
+ return lines.join("\n");
24716
+ }
24717
+ async function onboardCreate(args) {
24718
+ const repoPath = await resolveContextTreeRepo(args.server);
24719
+ if (!repoPath) throw new Error("Context Tree repo not available. Ensure --server is configured and the server is running.");
24720
+ const ghUsername = getGitHubUsername();
24721
+ const githubField = args.type === "human" ? ghUsername : null;
24722
+ const humanNodePath = join(repoPath, "members", args.id, "NODE.md");
24723
+ if (existsSync(humanNodePath) && isTrackedByGit(repoPath, join("members", args.id, "NODE.md"))) {
24724
+ process.stderr.write(`Member "${args.id}" already exists, skipping NODE.md creation.\n`);
24725
+ if (args.assistant) {
24726
+ const existingContent = readFileSync(humanNodePath, "utf-8");
24727
+ if (!existingContent.includes("delegate_mention")) {
24728
+ writeFileSync(humanNodePath, existingContent.replace(/^(---\n[\s\S]*?)(---)/m, `$1delegate_mention: ${args.assistant}\n$2`));
24729
+ process.stderr.write(`Updated delegate_mention → ${args.assistant}\n`);
24730
+ }
24731
+ }
24732
+ } else createMemberNodeMd(repoPath, {
24733
+ id: args.id,
24734
+ type: args.type,
24735
+ displayName: args.displayName ?? args.id,
24736
+ role: args.role,
24737
+ domains: args.domains.split(",").map((d) => d.trim()),
24738
+ owner: ghUsername,
24739
+ github: githubField,
24740
+ delegateMention: args.assistant ?? args.delegateMention ?? null
24741
+ });
24742
+ if (args.assistant) if (existsSync(join(repoPath, "members", args.id, args.assistant, "NODE.md")) && isTrackedByGit(repoPath, join("members", args.id, args.assistant, "NODE.md"))) process.stderr.write(`Assistant "${args.assistant}" already exists, skipping.\n`);
24743
+ else createMemberNodeMd(repoPath, {
24744
+ parentPath: join("members", args.id),
24745
+ id: args.assistant,
24746
+ type: "personal_assistant",
24747
+ displayName: args.assistant,
24748
+ role: `Personal Assistant to ${args.id}`,
24749
+ domains: ["message triage", "task coordination"],
24750
+ owner: ghUsername,
24751
+ github: null,
24752
+ delegateMention: null
24753
+ });
24754
+ try {
24755
+ execSync("npx -y first-tree verify", {
24756
+ cwd: repoPath,
24757
+ stdio: "pipe"
24758
+ });
24759
+ } catch (err) {
24760
+ const stderr = err instanceof Error && "stderr" in err ? err.stderr.toString() : "";
24761
+ const stdout = err instanceof Error && "stdout" in err ? err.stdout.toString() : "";
24762
+ const output = stderr || stdout || String(err);
24763
+ if (output.includes("VERSION") || output.includes("AGENT.md") || output.includes("Root NODE.md")) throw new Error("Context Tree repo is not properly initialized.\nRun 'context-tree init' in the repo first, or see:\n https://github.com/agent-team-foundation/first-tree\n\n" + output);
24764
+ throw new Error(`Verification failed:\n${output}`);
24765
+ }
24766
+ const baseBranch = `onboard/${args.id}`;
24767
+ let branch = baseBranch;
24768
+ const branchExists = (name) => {
24769
+ try {
24770
+ execSync(`git rev-parse --verify ${name}`, {
24771
+ cwd: repoPath,
24772
+ stdio: "pipe"
24773
+ });
24774
+ return true;
24775
+ } catch {
24776
+ return false;
24777
+ }
24778
+ };
24779
+ if (branchExists(branch)) branch = `${baseBranch}-${Date.now().toString(36)}`;
24780
+ try {
24781
+ execSync("git checkout main", {
24782
+ cwd: repoPath,
24783
+ stdio: "pipe"
24784
+ });
24785
+ } catch {
24786
+ try {
24787
+ execSync("git checkout master", {
24788
+ cwd: repoPath,
24789
+ stdio: "pipe"
24790
+ });
24791
+ } catch {}
24792
+ }
24793
+ execSync(`git checkout -b ${branch}`, {
24794
+ cwd: repoPath,
24795
+ stdio: "pipe"
24796
+ });
24797
+ execSync(`git add members/${args.id}`, {
24798
+ cwd: repoPath,
24799
+ stdio: "pipe"
24800
+ });
24801
+ execFileSync("git", [
24802
+ "commit",
24803
+ "-m",
24804
+ args.assistant ? `feat: onboard ${args.id} + ${args.assistant}` : `feat: onboard ${args.id}`
24805
+ ], {
24806
+ cwd: repoPath,
24807
+ stdio: "pipe"
24808
+ });
24809
+ const pushToken = execSync("gh auth token", {
24810
+ encoding: "utf-8",
24811
+ stdio: "pipe"
24812
+ }).trim();
24813
+ const cleanRemote = execSync("git remote get-url origin", {
24814
+ cwd: repoPath,
24815
+ encoding: "utf-8",
24816
+ stdio: "pipe"
24817
+ }).trim();
24818
+ execSync(`git remote set-url origin "${cleanRemote.replace("https://github.com/", `https://x-access-token:${pushToken}@github.com/`)}"`, {
24819
+ cwd: repoPath,
24820
+ stdio: "pipe"
24821
+ });
24822
+ try {
24823
+ execSync(`git push -u origin ${branch}`, {
24824
+ cwd: repoPath,
24825
+ stdio: "pipe"
24826
+ });
24827
+ } finally {
24828
+ execSync(`git remote set-url origin "${cleanRemote}"`, {
24829
+ cwd: repoPath,
24830
+ stdio: "pipe"
24831
+ });
24832
+ }
24833
+ const prOutput = execSync(`gh pr create --title "${args.assistant ? `Onboard ${args.id} + assistant` : `Onboard ${args.id}`}" --body "Automated onboard via first-tree-hub CLI"`, {
24834
+ cwd: repoPath,
24835
+ encoding: "utf-8"
24836
+ }).trim();
24837
+ const state = {
24838
+ args,
24839
+ branch,
24840
+ prUrl: prOutput
24841
+ };
24842
+ mkdirSync(join(homedir(), ".first-tree-hub"), { recursive: true });
24843
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
24844
+ return { prUrl: prOutput };
24845
+ }
24846
+ async function onboardContinue(args) {
24847
+ let state = null;
24848
+ try {
24849
+ state = JSON.parse(readFileSync(STATE_FILE, "utf-8"));
24850
+ } catch {}
24851
+ if (!state && !args.id) throw new Error("No onboard in progress. Run 'first-tree-hub onboard' first to start a new onboard.");
24852
+ const mergedArgs = {
24853
+ ...state?.args,
24854
+ ...stripUndefined(args)
24855
+ };
24856
+ const serverUrl = resolveServerUrl(mergedArgs.server).replace(/\/+$/, "");
24857
+ const agentToBootstrap = mergedArgs.assistant ?? mergedArgs.id;
24858
+ if (!agentToBootstrap) throw new Error("Cannot determine which agent to bootstrap. Provide --id or run onboard first.");
24859
+ if (!mergedArgs.id) throw new Error("Cannot determine member ID. Provide --id or run onboard first.");
24860
+ process.stderr.write(`Waiting for agent "${agentToBootstrap}" to be synced...\n`);
24861
+ let synced = false;
24862
+ for (let i = 0; i < 30; i++) {
24863
+ try {
24864
+ const status = await checkBootstrapStatus(serverUrl, agentToBootstrap);
24865
+ if (status.exists && status.status === "active") {
24866
+ synced = true;
24867
+ break;
24868
+ }
24869
+ } catch (err) {
24870
+ if (i === 0) process.stderr.write(` (check failed: ${err instanceof Error ? err.message : String(err)})\n`);
24871
+ }
24872
+ await sleep(2e3);
24873
+ }
24874
+ if (!synced) throw new Error(`Agent "${agentToBootstrap}" not found after 60s. Trigger sync manually or wait for auto-sync.`);
24875
+ process.stderr.write(`Bootstrapping token for "${agentToBootstrap}"...\n`);
24876
+ let token;
24877
+ try {
24878
+ token = (await bootstrapToken$1(serverUrl, agentToBootstrap, { saveTo: "agent" })).token;
24879
+ } catch (err) {
24880
+ const msg = err instanceof Error ? err.message : String(err);
24881
+ if (msg.includes("already has") || msg.includes("409")) throw new Error(`Agent "${agentToBootstrap}" already has an active token.\nAsk an admin to revoke the existing token in the Web UI, then re-run:
24882
+ first-tree-hub onboard --continue`);
24883
+ throw err;
24884
+ }
24885
+ process.stderr.write(`Token saved to ~/.first-tree-hub/agents/${agentToBootstrap}/agent.yaml\n`);
24886
+ if (mergedArgs.feishuBotAppId && mergedArgs.feishuBotAppSecret) {
24887
+ const { bindFeishuBot } = await import("./feishu-Y4m2zFc3.mjs").then((n) => n.r);
24888
+ process.stderr.write("Binding Feishu bot...\n");
24889
+ await bindFeishuBot(serverUrl, token, mergedArgs.feishuBotAppId, mergedArgs.feishuBotAppSecret);
24890
+ process.stderr.write("Feishu bot bound.\n");
24891
+ }
24892
+ try {
24893
+ const { unlinkSync } = await import("node:fs");
24894
+ unlinkSync(STATE_FILE);
24895
+ } catch {}
24896
+ process.stderr.write("\n✅ Onboard complete!\n\n");
24897
+ process.stderr.write(` Human: ${mergedArgs.id}\n`);
24898
+ if (mergedArgs.assistant) process.stderr.write(` Assistant: ${mergedArgs.assistant}\n`);
24899
+ process.stderr.write(` Token: ~/.first-tree-hub/agents/${agentToBootstrap}/agent.yaml\n`);
24900
+ if (mergedArgs.feishuBotAppId) process.stderr.write(` Feishu: bot bound (${mergedArgs.feishuBotAppId})\n`);
24901
+ if (mergedArgs.type === "human") {
24902
+ process.stderr.write("\n Next step — bind your Feishu account:\n");
24903
+ process.stderr.write(` Send this message to the bot in Feishu: /bind ${mergedArgs.id}\n`);
24904
+ if (!mergedArgs.feishuBotAppId) process.stderr.write(" (requires a Feishu bot to be configured in the system)\n");
24905
+ }
24906
+ process.stderr.write("\n");
24907
+ }
24908
+ function createMemberNodeMd(repoPath, data) {
24909
+ const memberDir = join(repoPath, data.parentPath ?? "members", data.id);
24910
+ mkdirSync(memberDir, { recursive: true });
24911
+ const domainsList = data.domains.map((d) => ` - "${d}"`).join("\n");
24912
+ const githubLine = data.github ? `\ngithub: ${data.github}` : "";
24913
+ const delegateLine = data.delegateMention ? `\ndelegate_mention: ${data.delegateMention}` : "";
24914
+ const content = `---
24915
+ title: "${data.displayName}"
24916
+ owners: [${data.owner}]
24917
+ type: ${data.type}
24918
+ role: "${data.role}"
24919
+ domains:
24920
+ ${domainsList}${githubLine}${delegateLine}
24921
+ ---
24922
+
24923
+ # ${data.displayName}
24924
+
24925
+ ## About
24926
+
24927
+ ## Current Focus
24928
+ `;
24929
+ writeFileSync(join(memberDir, "NODE.md"), content);
24930
+ }
24931
+ function isTrackedByGit(repoPath, filePath) {
24932
+ try {
24933
+ execSync(`git ls-files --error-unmatch ${filePath}`, {
24934
+ cwd: repoPath,
24935
+ stdio: "pipe"
24936
+ });
24937
+ return true;
24938
+ } catch {
24939
+ return false;
24940
+ }
24941
+ }
24942
+ const CONTEXT_TREE_DIR = join(homedir(), ".first-tree-hub", "context-tree");
24943
+ /**
24944
+ * Resolve Context Tree to a **local path** at ~/.first-tree-hub/context-tree/.
24945
+ *
24946
+ * Repo URL is obtained from the Hub server. The local clone is always
24947
+ * managed in the standard location — no custom paths allowed.
24948
+ */
24949
+ async function resolveContextTreeRepo(serverUrl) {
24950
+ const repoUrl = await fetchRepoUrlFromServer(serverUrl);
24951
+ if (!repoUrl) return null;
24952
+ let ghToken;
24953
+ try {
24954
+ ghToken = execSync("gh auth token", {
24955
+ encoding: "utf-8",
24956
+ stdio: "pipe"
24957
+ }).trim();
24958
+ } catch {
24959
+ return null;
24960
+ }
24961
+ const gitEnv = {
24962
+ ...process.env,
24963
+ GIT_ASKPASS: "echo",
24964
+ GIT_TERMINAL_PROMPT: "0",
24965
+ GH_TOKEN: ghToken,
24966
+ GITHUB_TOKEN: ghToken
24967
+ };
24968
+ const gitConfigArgs = `-c url."https://x-access-token:${ghToken}@github.com/".insteadOf="https://github.com/"`;
24969
+ if (existsSync(join(CONTEXT_TREE_DIR, ".git"))) {
24970
+ try {
24971
+ if (execSync("git remote get-url origin", {
24972
+ cwd: CONTEXT_TREE_DIR,
24973
+ encoding: "utf-8",
24974
+ stdio: "pipe"
24975
+ }).trim().includes(repoUrl.replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, ""))) {
24976
+ process.stderr.write("Updating Context Tree...\n");
24977
+ execSync("git checkout main 2>/dev/null || git checkout master", {
24978
+ cwd: CONTEXT_TREE_DIR,
24979
+ stdio: "pipe"
24980
+ });
24981
+ try {
24982
+ execSync(`git ${gitConfigArgs} pull --ff-only`, {
24983
+ cwd: CONTEXT_TREE_DIR,
24984
+ stdio: "pipe",
24985
+ env: gitEnv
24986
+ });
24987
+ } catch {}
24988
+ return CONTEXT_TREE_DIR;
24989
+ }
24990
+ } catch {}
24991
+ const safePrefix = join(homedir(), ".first-tree-hub");
24992
+ if (!CONTEXT_TREE_DIR.startsWith(safePrefix) || CONTEXT_TREE_DIR === safePrefix) throw new Error(`Refusing to delete unsafe path: ${CONTEXT_TREE_DIR}`);
24993
+ execSync(`rm -rf ${CONTEXT_TREE_DIR}`);
24994
+ }
24995
+ try {
24996
+ process.stderr.write(`Cloning Context Tree to ${CONTEXT_TREE_DIR}...\n`);
24997
+ mkdirSync(join(homedir(), ".first-tree-hub"), { recursive: true });
24998
+ execSync(`git ${gitConfigArgs} clone ${repoUrl} ${CONTEXT_TREE_DIR}`, {
24999
+ stdio: "pipe",
25000
+ env: gitEnv
25001
+ });
25002
+ return CONTEXT_TREE_DIR;
25003
+ } catch {
25004
+ return null;
25005
+ }
25006
+ }
25007
+ /** Query server for Context Tree repo URL. */
25008
+ async function fetchRepoUrlFromServer(serverUrl) {
25009
+ if (!serverUrl) try {
25010
+ serverUrl = resolveServerUrl();
25011
+ } catch {
25012
+ return null;
25013
+ }
25014
+ try {
25015
+ const url = `${serverUrl.replace(/\/+$/, "")}/api/v1/context-tree/info`;
25016
+ const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
25017
+ if (!res.ok) return null;
25018
+ return (await res.json()).repo ?? null;
25019
+ } catch {
25020
+ return null;
25021
+ }
25022
+ }
25023
+ function sleep(ms) {
25024
+ return new Promise((resolve) => setTimeout(resolve, ms));
25025
+ }
25026
+ function stripUndefined(obj) {
25027
+ const result = {};
25028
+ for (const [key, value] of Object.entries(obj)) if (value !== void 0) result[key] = value;
25029
+ return result;
25030
+ }
25031
+ //#endregion
25032
+ //#region src/core/prompt.ts
25033
+ /**
25034
+ * Check if interactive mode is available.
25035
+ * Returns false if --no-interactive flag is set or stdin is not a TTY.
25036
+ */
25037
+ function isInteractive(noInteractiveFlag) {
25038
+ if (noInteractiveFlag) return false;
25039
+ return process.stdin.isTTY === true;
25040
+ }
25041
+ /**
25042
+ * Schema-driven interactive setup.
25043
+ * Scans the config schema for fields with `prompt` that are missing.
25044
+ *
25045
+ * In interactive mode: prompts the user and writes results to YAML.
25046
+ * In non-interactive mode: fails with a clear error listing missing fields.
25047
+ */
25048
+ async function promptMissingFields(options) {
25049
+ const missing = collectMissingPrompts({
25050
+ schema: options.schema,
25051
+ role: options.role,
25052
+ configDir: options.configDir,
25053
+ cliArgs: options.cliArgs
25054
+ });
25055
+ if (missing.length === 0) return {};
25056
+ if (!isInteractive(options.noInteractive)) {
25057
+ const lines = missing.map((m) => {
25058
+ const envHint = findEnvVar(options.schema, m.dotPath);
25059
+ const envStr = envHint ? ` (env: ${envHint})` : "";
25060
+ return ` ${m.dotPath}${envStr}`;
25061
+ });
25062
+ throw new Error(`Missing required configuration:\n${lines.join("\n")}\n\nProvide values via environment variables, config file (~/.first-tree-hub/server.yaml),
25063
+ or run without --no-interactive to use the interactive setup wizard.`);
25064
+ }
25065
+ const configPath = join(options.configDir ?? DEFAULT_CONFIG_DIR, `${options.role}.yaml`);
25066
+ const results = {};
25067
+ for (const { dotPath, prompt } of missing) {
25068
+ const value = await askPrompt(dotPath, prompt);
25069
+ if (value !== void 0) {
25070
+ setConfigValue(configPath, dotPath, value);
25071
+ setNestedByDot(results, dotPath, value);
25072
+ }
25073
+ }
25074
+ return results;
25075
+ }
25076
+ /**
25077
+ * Interactive add agent — simple two-field prompt.
25078
+ */
25079
+ async function promptAddAgent() {
25080
+ return {
25081
+ name: await input({
25082
+ message: "Agent name:",
24521
25083
  validate: (v) => /^[a-z0-9][a-z0-9-]*$/.test(v) ? true : "Lowercase alphanumeric and hyphens only"
24522
25084
  }),
24523
25085
  token: await input({
@@ -24606,6 +25168,10 @@ const adapterBindMethodSchema = z.enum([
24606
25168
  "oauth",
24607
25169
  "manual"
24608
25170
  ]);
25171
+ const selfServiceFeishuBotSchema = z.object({
25172
+ appId: z.string().min(1),
25173
+ appSecret: z.string().min(1)
25174
+ });
24609
25175
  const createAdapterMappingSchema = z.object({
24610
25176
  platform: adapterPlatformSchema,
24611
25177
  externalUserId: z.string().min(1),
@@ -24622,6 +25188,10 @@ z.object({
24622
25188
  displayName: z.string().nullable(),
24623
25189
  createdAt: z.string()
24624
25190
  });
25191
+ const delegateFeishuUserSchema = z.object({
25192
+ feishuUserId: z.string().min(1),
25193
+ displayName: z.string().max(200).optional()
25194
+ });
24625
25195
  z.object({
24626
25196
  configId: z.number(),
24627
25197
  platform: z.string(),
@@ -24705,6 +25275,7 @@ z.object({
24705
25275
  type: agentTypeSchema,
24706
25276
  displayName: z.string().nullable(),
24707
25277
  delegateMention: z.string().nullable(),
25278
+ treePath: z.string().nullable(),
24708
25279
  inboxId: z.string(),
24709
25280
  status: z.string(),
24710
25281
  metadata: z.record(z.unknown()),
@@ -24712,6 +25283,16 @@ z.object({
24712
25283
  createdAt: z.string(),
24713
25284
  updatedAt: z.string()
24714
25285
  });
25286
+ const bootstrapTokenRequestSchema = z.object({ name: z.string().max(100).optional() });
25287
+ z.object({
25288
+ exists: z.boolean(),
25289
+ status: z.enum(["active", "suspended"]).nullable()
25290
+ });
25291
+ z.object({
25292
+ repo: z.string(),
25293
+ branch: z.string(),
25294
+ lastSync: z.string().nullable()
25295
+ });
24715
25296
  const createAgentTokenSchema = z.object({
24716
25297
  name: z.string().max(100).optional(),
24717
25298
  expiresAt: z.string().datetime().optional()
@@ -24832,7 +25413,7 @@ const SYSTEM_CONFIG_DEFAULTS = {
24832
25413
  [SYSTEM_CONFIG_KEYS.PRESENCE_CLEANUP_SECONDS]: 60
24833
25414
  };
24834
25415
  //#endregion
24835
- //#region ../server/dist/app-DUdS4lE-.mjs
25416
+ //#region ../server/dist/app-BVTDWxJE.mjs
24836
25417
  var __defProp = Object.defineProperty;
24837
25418
  var __exportAll = (all, no_symbols) => {
24838
25419
  let target = {};
@@ -24850,6 +25431,7 @@ const agents = pgTable("agents", {
24850
25431
  type: text("type").notNull(),
24851
25432
  displayName: text("display_name"),
24852
25433
  delegateMention: text("delegate_mention"),
25434
+ treePath: text("tree_path"),
24853
25435
  inboxId: text("inbox_id").unique().notNull(),
24854
25436
  status: text("status").notNull().default("active"),
24855
25437
  metadata: jsonb("metadata").$type().notNull().default({}),
@@ -24984,6 +25566,11 @@ async function findAgentByExternalUser(db, platform, externalUserId) {
24984
25566
  }).from(adapterAgentMappings).where(and(eq(adapterAgentMappings.platform, platform), eq(adapterAgentMappings.externalUserId, externalUserId))).limit(1);
24985
25567
  return row ?? null;
24986
25568
  }
25569
+ /** Look up the external user ID for an internal agent. */
25570
+ async function findExternalUserByAgent(db, platform, agentId) {
25571
+ const [row] = await db.select({ externalUserId: adapterAgentMappings.externalUserId }).from(adapterAgentMappings).where(and(eq(adapterAgentMappings.platform, platform), eq(adapterAgentMappings.agentId, agentId))).limit(1);
25572
+ return row ?? null;
25573
+ }
24987
25574
  /** Create an agent mapping. */
24988
25575
  async function createAgentMapping(db, data) {
24989
25576
  const [row] = await db.insert(adapterAgentMappings).values({
@@ -25331,6 +25918,7 @@ async function adminAdapterRoutes(app) {
25331
25918
  });
25332
25919
  }
25333
25920
  const GRAPHQL_URL = "https://api.github.com/graphql";
25921
+ const REST_API_URL = "https://api.github.com";
25334
25922
  /** Parse "owner/repo" or "https://github.com/owner/repo" into { owner, name }. */
25335
25923
  function parseRepo(input) {
25336
25924
  const urlMatch = /github\.com\/([^/]+)\/([^/.]+)/.exec(input);
@@ -25344,31 +25932,13 @@ function parseRepo(input) {
25344
25932
  name: parts[1] ?? ""
25345
25933
  };
25346
25934
  }
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`);
25935
+ /** Step 1: Get the tree OID of the members/ directory via GraphQL. */
25936
+ async function fetchMembersTreeOid(owner, name, branch, token) {
25354
25937
  const query = `
25355
25938
  query($owner: String!, $name: String!, $expr: String!) {
25356
25939
  repository(owner: $owner, name: $name) {
25357
25940
  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
- }
25941
+ ... on Tree { oid }
25372
25942
  }
25373
25943
  }
25374
25944
  }
@@ -25391,17 +25961,109 @@ async function fetchMembers(repo, branch, token) {
25391
25961
  if (!res.ok) throw new Error(`GitHub API returned ${res.status}: ${await res.text()}`);
25392
25962
  const json = await res.json();
25393
25963
  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) {
25964
+ return json.data?.repository?.object?.oid ?? null;
25965
+ }
25966
+ /** Step 2: Recursively list all entries under the members/ tree via REST API. */
25967
+ async function fetchRecursiveTree(owner, name, treeSha, token) {
25968
+ const url = `${REST_API_URL}/repos/${owner}/${name}/git/trees/${treeSha}?recursive=1`;
25969
+ const res = await fetch(url, { headers: {
25970
+ Authorization: `Bearer ${token}`,
25971
+ Accept: "application/vnd.github+json"
25972
+ } });
25973
+ if (!res.ok) throw new Error(`GitHub REST API returned ${res.status}: ${await res.text()}`);
25974
+ const json = await res.json();
25975
+ 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.");
25976
+ return json.tree;
25977
+ }
25978
+ /**
25979
+ * Step 3: Batch-fetch NODE.md content for all member directories via GraphQL aliases.
25980
+ * Each alias fetches one NODE.md file by expression.
25981
+ */
25982
+ async function batchFetchNodeMd(owner, name, branch, memberPaths, token) {
25983
+ if (memberPaths.length === 0) return /* @__PURE__ */ new Map();
25984
+ const query = `
25985
+ query($owner: String!, $name: String!) {
25986
+ repository(owner: $owner, name: $name) {
25987
+ ${memberPaths.map((p, i) => {
25988
+ const expr = `${branch}:members/${p}/NODE.md`;
25989
+ return `m${i}: object(expression: ${JSON.stringify(expr)}) { ... on Blob { text } }`;
25990
+ }).join("\n ")}
25991
+ }
25992
+ }
25993
+ `;
25994
+ const res = await fetch(GRAPHQL_URL, {
25995
+ method: "POST",
25996
+ headers: {
25997
+ Authorization: `Bearer ${token}`,
25998
+ "Content-Type": "application/json"
25999
+ },
26000
+ body: JSON.stringify({
26001
+ query,
26002
+ variables: {
26003
+ owner,
26004
+ name
26005
+ }
26006
+ })
26007
+ });
26008
+ if (!res.ok) throw new Error(`GitHub API returned ${res.status}: ${await res.text()}`);
26009
+ const json = await res.json();
26010
+ if (json.errors) throw new Error(`GitHub GraphQL errors: ${json.errors.map((e) => e.message).join(", ")}`);
26011
+ const repo = json.data?.repository ?? {};
26012
+ const result = /* @__PURE__ */ new Map();
26013
+ for (let i = 0; i < memberPaths.length; i++) {
26014
+ const blob = repo[`m${i}`];
26015
+ const path = memberPaths[i];
26016
+ if (blob?.text && path) result.set(path, blob.text);
26017
+ }
26018
+ return result;
26019
+ }
26020
+ /**
26021
+ * Extract member directory paths from a recursive tree listing.
26022
+ * A directory is a member if it contains a NODE.md blob.
26023
+ */
26024
+ function extractMemberDirs(treeEntries) {
26025
+ const nodeMdPaths = /* @__PURE__ */ new Set();
26026
+ for (const entry of treeEntries) if (entry.type === "blob" && entry.path.endsWith("/NODE.md")) nodeMdPaths.add(entry.path);
26027
+ const memberDirs = [];
26028
+ for (const entry of treeEntries) {
25397
26029
  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
- });
26030
+ if (nodeMdPaths.has(`${entry.path}/NODE.md`)) memberDirs.push(entry.path);
25403
26031
  }
25404
- return members;
26032
+ return memberDirs.sort();
26033
+ }
26034
+ /**
26035
+ * Fetch all members from a Context Tree repo via GitHub API.
26036
+ * Uses 3 API calls:
26037
+ * 1. GraphQL: get members/ tree OID
26038
+ * 2. REST: recursive tree listing (scoped to members/ only)
26039
+ * 3. GraphQL: batch-fetch all NODE.md contents via aliases
26040
+ */
26041
+ async function fetchMembers(repo, branch, token) {
26042
+ const { owner, name } = parseRepo(repo);
26043
+ if (!owner || !name) throw new Error(`Invalid repo format: "${repo}" — expected "owner/repo" or a GitHub URL`);
26044
+ const treeOid = await fetchMembersTreeOid(owner, name, branch, token);
26045
+ if (!treeOid) {
26046
+ console.warn("[context-tree-sync] members/ directory not found in repo");
26047
+ return [];
26048
+ }
26049
+ const memberDirs = extractMemberDirs(await fetchRecursiveTree(owner, name, treeOid, token));
26050
+ if (memberDirs.length === 0) {
26051
+ console.warn("[context-tree-sync] No member directories with NODE.md found");
26052
+ return [];
26053
+ }
26054
+ const nameMap = /* @__PURE__ */ new Map();
26055
+ for (const dir of memberDirs) {
26056
+ const dirName = dir.split("/").pop() ?? dir;
26057
+ const existing = nameMap.get(dirName);
26058
+ 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.`);
26059
+ nameMap.set(dirName, dir);
26060
+ }
26061
+ const nodeContents = await batchFetchNodeMd(owner, name, branch, memberDirs, token);
26062
+ return memberDirs.map((dir) => ({
26063
+ name: dir.split("/").pop() ?? dir,
26064
+ treePath: dir,
26065
+ nodeContent: nodeContents.get(dir) ?? null
26066
+ }));
25405
26067
  }
25406
26068
  /** Parse NODE.md frontmatter for agent metadata. */
25407
26069
  function parseNodeMetadata(content) {
@@ -25409,17 +26071,27 @@ function parseNodeMetadata(content) {
25409
26071
  if (!match) return {
25410
26072
  type: "autonomous_agent",
25411
26073
  displayName: null,
25412
- delegateMention: null
26074
+ delegateMention: null,
26075
+ owners: [],
26076
+ github: null
25413
26077
  };
25414
26078
  const frontmatter = match[1] ?? "";
25415
26079
  const getValue = (key) => {
25416
26080
  const lineMatch = new RegExp(`^${key}:\\s*(.+)$`, "m").exec(frontmatter);
25417
26081
  return lineMatch ? lineMatch[1]?.trim().replace(/^["']|["']$/g, "") ?? null : null;
25418
26082
  };
26083
+ const ownersRaw = getValue("owners");
26084
+ let owners = [];
26085
+ if (ownersRaw) {
26086
+ const listMatch = /^\[([^\]]*)\]$/.exec(ownersRaw);
26087
+ if (listMatch?.[1]) owners = listMatch[1].split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
26088
+ }
25419
26089
  return {
25420
26090
  type: getValue("type") ?? "autonomous_agent",
25421
26091
  displayName: getValue("display_name") ?? getValue("title") ?? getValue("name"),
25422
- delegateMention: getValue("delegate_mention")
26092
+ delegateMention: getValue("delegate_mention"),
26093
+ owners,
26094
+ github: getValue("github")
25423
26095
  };
25424
26096
  }
25425
26097
  /** Stored for the /status endpoint */
@@ -25453,33 +26125,45 @@ async function syncFromGitHub(db, repo, branch, githubToken) {
25453
26125
  const meta = member.nodeContent ? parseNodeMetadata(member.nodeContent) : {
25454
26126
  type: "autonomous_agent",
25455
26127
  displayName: null,
25456
- delegateMention: null
26128
+ delegateMention: null,
26129
+ owners: [],
26130
+ github: null
25457
26131
  };
25458
- const existing = await db.execute(sql`SELECT id, status, type, display_name, delegate_mention FROM agents WHERE id = ${member.name}`);
26132
+ const metadataJson = JSON.stringify({
26133
+ owners: meta.owners,
26134
+ github: meta.github
26135
+ });
26136
+ const existing = await db.execute(sql`SELECT id, status, type, display_name, delegate_mention, tree_path, metadata FROM agents WHERE id = ${member.name}`);
25459
26137
  if (existing.length === 0) {
25460
26138
  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}`})
26139
+ INSERT INTO agents (id, type, display_name, delegate_mention, tree_path, status, inbox_id, metadata)
26140
+ VALUES (${member.name}, ${meta.type}, ${meta.displayName}, ${meta.delegateMention}, ${member.treePath}, 'active', ${`inbox_${member.name}`}, ${metadataJson}::jsonb)
25463
26141
  `);
25464
26142
  result.created++;
25465
26143
  } else {
25466
26144
  const agent = existing[0];
25467
- if (agent.status === "suspended") {
26145
+ const existingMeta = agent.metadata ?? {};
26146
+ const mergedMeta = JSON.stringify({
26147
+ ...existingMeta,
26148
+ owners: meta.owners,
26149
+ github: meta.github
26150
+ });
26151
+ if (agent.status === "suspended" || agent.status === "deleted") {
25468
26152
  await db.execute(sql`
25469
- UPDATE agents SET status = 'active', type = ${meta.type}, display_name = ${meta.displayName}, delegate_mention = ${meta.delegateMention}
26153
+ UPDATE agents SET status = 'active', type = ${meta.type}, display_name = ${meta.displayName}, delegate_mention = ${meta.delegateMention}, tree_path = ${member.treePath}, metadata = ${mergedMeta}::jsonb
25470
26154
  WHERE id = ${member.name}
25471
26155
  `);
25472
26156
  result.reactivated++;
25473
- } else if (agent.type !== meta.type || agent.display_name !== meta.displayName || agent.delegate_mention !== meta.delegateMention) {
26157
+ } else if (agent.type !== meta.type || agent.display_name !== meta.displayName || agent.delegate_mention !== meta.delegateMention || agent.tree_path !== member.treePath || JSON.stringify(existingMeta.owners) !== JSON.stringify(meta.owners) || (existingMeta.github ?? null) !== (meta.github ?? null)) {
25474
26158
  await db.execute(sql`
25475
- UPDATE agents SET type = ${meta.type}, display_name = ${meta.displayName}, delegate_mention = ${meta.delegateMention}
26159
+ UPDATE agents SET type = ${meta.type}, display_name = ${meta.displayName}, delegate_mention = ${meta.delegateMention}, tree_path = ${member.treePath}, metadata = ${mergedMeta}::jsonb
25476
26160
  WHERE id = ${member.name}
25477
26161
  `);
25478
26162
  result.updated++;
25479
26163
  } else result.unchanged++;
25480
26164
  }
25481
26165
  } catch (err) {
25482
- console.error(`[context-tree-sync] Failed to sync member "${member.name}":`, err);
26166
+ console.error(`[context-tree-sync] Failed to sync member "${member.name}" (path: members/${member.treePath}):`, err);
25483
26167
  result.errors++;
25484
26168
  }
25485
26169
  try {
@@ -25583,6 +26267,7 @@ async function listAgents(db, limit, cursor) {
25583
26267
  type: agents.type,
25584
26268
  displayName: agents.displayName,
25585
26269
  delegateMention: agents.delegateMention,
26270
+ treePath: agents.treePath,
25586
26271
  inboxId: agents.inboxId,
25587
26272
  status: agents.status,
25588
26273
  metadata: agents.metadata,
@@ -25618,6 +26303,17 @@ async function deleteAgent(db, id) {
25618
26303
  if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
25619
26304
  return agent;
25620
26305
  }
26306
+ /**
26307
+ * Bootstrap a token for an agent using GitHub identity.
26308
+ * Only works when the agent has no active (non-revoked, non-expired) tokens.
26309
+ */
26310
+ async function bootstrapToken(db, agentId, githubUsername, tokenName) {
26311
+ const agent = await getAgent(db, agentId);
26312
+ if (!(Array.isArray(agent.metadata?.owners) ? agent.metadata.owners : []).includes(githubUsername)) throw new ForbiddenError(`GitHub user "${githubUsername}" is not in the owners list for agent "${agentId}"`);
26313
+ const activeTokens = await db.select({ id: agentTokens.id }).from(agentTokens).where(and(eq(agentTokens.agentId, agentId), isNull(agentTokens.revokedAt)));
26314
+ if (activeTokens.length > 0) throw new ConflictError(`Agent "${agentId}" already has ${activeTokens.length} active token(s). Revoke all tokens first to re-bootstrap.`);
26315
+ return createToken(db, agentId, { name: tokenName ?? "bootstrap" });
26316
+ }
25621
26317
  async function createToken(db, agentId, data) {
25622
26318
  await getAgent(db, agentId);
25623
26319
  const raw = `aghub_${randomBytes(32).toString("hex")}`;
@@ -25658,35 +26354,339 @@ async function revokeToken(db, agentId, tokenId) {
25658
26354
  if (!token) throw new NotFoundError("Token not found or already revoked");
25659
26355
  return token;
25660
26356
  }
25661
- /** WS close code: agent already connected from another client. */
25662
- const WS_CLOSE_ALREADY_CONNECTED = 4009;
25663
- /** Track active WS connections per agentId. At most one entry per agent. */
25664
- const activeConnections = /* @__PURE__ */ new Map();
25665
- /** Check if an agent already has an active WS connection. */
25666
- function hasActiveConnection(agentId) {
25667
- const ws = activeConnections.get(agentId);
25668
- return ws !== void 0 && ws.readyState <= 1;
25669
- }
25670
- /** Register a WS connection for an agent. */
25671
- function setConnection(agentId, ws) {
25672
- activeConnections.set(agentId, ws);
25673
- }
25674
- /** Remove a WS connection if it matches the registered one. Returns true if removed. */
25675
- function removeConnection(agentId, ws) {
25676
- if (activeConnections.get(agentId) === ws) {
25677
- activeConnections.delete(agentId);
25678
- return true;
26357
+ async function createChat(db, creatorId, data) {
26358
+ const chatId = randomUUID();
26359
+ const allParticipantIds = new Set([creatorId, ...data.participantIds]);
26360
+ const existingAgents = await db.select({
26361
+ id: agents.id,
26362
+ organizationId: agents.organizationId
26363
+ }).from(agents).where(inArray(agents.id, [...allParticipantIds]));
26364
+ if (existingAgents.length !== allParticipantIds.size) {
26365
+ const found = new Set(existingAgents.map((a) => a.id));
26366
+ throw new BadRequestError(`Agents not found: ${[...allParticipantIds].filter((id) => !found.has(id)).join(", ")}`);
25679
26367
  }
25680
- return false;
25681
- }
25682
- /** Force-disconnect an agent's active WS connection. Returns true if a connection was closed. */
25683
- function forceDisconnect(agentId) {
25684
- const ws = activeConnections.get(agentId);
25685
- if (!ws) return false;
25686
- ws.close(WS_CLOSE_ALREADY_CONNECTED, "Disconnected by admin");
25687
- activeConnections.delete(agentId);
25688
- return true;
25689
- }
26368
+ const creator = existingAgents.find((a) => a.id === creatorId);
26369
+ if (!creator) throw new Error("Unexpected: creator not in existingAgents");
26370
+ const orgId = creator.organizationId;
26371
+ const crossOrg = existingAgents.filter((a) => a.organizationId !== orgId);
26372
+ if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.id).join(", ")}`);
26373
+ return db.transaction(async (tx) => {
26374
+ const [chat] = await tx.insert(chats).values({
26375
+ id: chatId,
26376
+ organizationId: orgId,
26377
+ type: data.type,
26378
+ topic: data.topic ?? null,
26379
+ metadata: data.metadata ?? {}
26380
+ }).returning();
26381
+ const participantRows = [...allParticipantIds].map((agentId) => ({
26382
+ chatId,
26383
+ agentId,
26384
+ role: agentId === creatorId ? "owner" : "member"
26385
+ }));
26386
+ await tx.insert(chatParticipants).values(participantRows);
26387
+ const participants = await tx.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
26388
+ if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
26389
+ return {
26390
+ ...chat,
26391
+ participants
26392
+ };
26393
+ });
26394
+ }
26395
+ async function getChat(db, chatId) {
26396
+ const [chat] = await db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
26397
+ if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
26398
+ return chat;
26399
+ }
26400
+ async function getChatDetail(db, chatId) {
26401
+ const chat = await getChat(db, chatId);
26402
+ const participants = await db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
26403
+ return {
26404
+ ...chat,
26405
+ participants
26406
+ };
26407
+ }
26408
+ async function listChats(db, agentId, limit, cursor) {
26409
+ const chatIds = (await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentId))).map((r) => r.chatId);
26410
+ if (chatIds.length === 0) return {
26411
+ items: [],
26412
+ nextCursor: null
26413
+ };
26414
+ const where = cursor ? and(inArray(chats.id, chatIds), lt(chats.updatedAt, new Date(cursor))) : inArray(chats.id, chatIds);
26415
+ const rows = await db.select().from(chats).where(where).orderBy(desc(chats.updatedAt)).limit(limit + 1);
26416
+ const hasMore = rows.length > limit;
26417
+ const items = hasMore ? rows.slice(0, limit) : rows;
26418
+ const last = items[items.length - 1];
26419
+ return {
26420
+ items,
26421
+ nextCursor: hasMore && last ? last.updatedAt.toISOString() : null
26422
+ };
26423
+ }
26424
+ async function assertParticipant(db, chatId, agentId) {
26425
+ const [row] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
26426
+ if (!row) throw new ForbiddenError("Not a participant of this chat");
26427
+ }
26428
+ async function addParticipant(db, chatId, requesterId, data) {
26429
+ const chat = await getChat(db, chatId);
26430
+ await assertParticipant(db, chatId, requesterId);
26431
+ const [targetAgent] = await db.select({
26432
+ id: agents.id,
26433
+ organizationId: agents.organizationId
26434
+ }).from(agents).where(eq(agents.id, data.agentId)).limit(1);
26435
+ if (!targetAgent) throw new NotFoundError(`Agent "${data.agentId}" not found`);
26436
+ if (targetAgent.organizationId !== chat.organizationId) throw new BadRequestError("Cannot add agent from different organization");
26437
+ const [existing] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, data.agentId))).limit(1);
26438
+ if (existing) throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
26439
+ await db.insert(chatParticipants).values({
26440
+ chatId,
26441
+ agentId: data.agentId,
26442
+ mode: data.mode ?? "full"
26443
+ });
26444
+ return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
26445
+ }
26446
+ async function removeParticipant(db, chatId, requesterId, targetAgentId) {
26447
+ await assertParticipant(db, chatId, requesterId);
26448
+ if (requesterId === targetAgentId) throw new BadRequestError("Cannot remove yourself from a chat");
26449
+ const [removed] = await db.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, targetAgentId))).returning();
26450
+ if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
26451
+ return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
26452
+ }
26453
+ async function findOrCreateDirectChat(db, agentAId, agentBId) {
26454
+ const aChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentAId));
26455
+ const bChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentBId));
26456
+ const bChatIds = new Set(bChats.map((r) => r.chatId));
26457
+ const commonChatIds = aChats.map((r) => r.chatId).filter((id) => bChatIds.has(id));
26458
+ if (commonChatIds.length > 0) {
26459
+ const directChats = await db.select().from(chats).where(and(inArray(chats.id, commonChatIds), eq(chats.type, "direct")));
26460
+ if (directChats.length > 0 && directChats[0]) return directChats[0];
26461
+ }
26462
+ const [agentA] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.id, agentAId)).limit(1);
26463
+ if (!agentA) throw new NotFoundError(`Agent "${agentAId}" not found`);
26464
+ const chatId = randomUUID();
26465
+ return db.transaction(async (tx) => {
26466
+ const [chat] = await tx.insert(chats).values({
26467
+ id: chatId,
26468
+ organizationId: agentA.organizationId,
26469
+ type: "direct"
26470
+ }).returning();
26471
+ await tx.insert(chatParticipants).values([{
26472
+ chatId,
26473
+ agentId: agentAId,
26474
+ role: "member"
26475
+ }, {
26476
+ chatId,
26477
+ agentId: agentBId,
26478
+ role: "member"
26479
+ }]);
26480
+ if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
26481
+ return chat;
26482
+ });
26483
+ }
26484
+ /** WS close code: agent already connected from another client. */
26485
+ const WS_CLOSE_ALREADY_CONNECTED = 4009;
26486
+ /** Track active WS connections per agentId. At most one entry per agent. */
26487
+ const activeConnections = /* @__PURE__ */ new Map();
26488
+ /** Check if an agent already has an active WS connection. */
26489
+ function hasActiveConnection(agentId) {
26490
+ const ws = activeConnections.get(agentId);
26491
+ return ws !== void 0 && ws.readyState <= 1;
26492
+ }
26493
+ /** Register a WS connection for an agent. */
26494
+ function setConnection(agentId, ws) {
26495
+ activeConnections.set(agentId, ws);
26496
+ }
26497
+ /** Remove a WS connection if it matches the registered one. Returns true if removed. */
26498
+ function removeConnection(agentId, ws) {
26499
+ if (activeConnections.get(agentId) === ws) {
26500
+ activeConnections.delete(agentId);
26501
+ return true;
26502
+ }
26503
+ return false;
26504
+ }
26505
+ /** Force-disconnect an agent's active WS connection. Returns true if a connection was closed. */
26506
+ function forceDisconnect(agentId) {
26507
+ const ws = activeConnections.get(agentId);
26508
+ if (!ws) return false;
26509
+ ws.close(WS_CLOSE_ALREADY_CONNECTED, "Disconnected by admin");
26510
+ activeConnections.delete(agentId);
26511
+ return true;
26512
+ }
26513
+ /** Delivery queue (envelope). One entry per recipient created during message fan-out. Uses SKIP LOCKED for concurrent-safe consumption. */
26514
+ const inboxEntries = pgTable("inbox_entries", {
26515
+ id: bigserial("id", { mode: "number" }).primaryKey(),
26516
+ inboxId: text("inbox_id").notNull(),
26517
+ messageId: text("message_id").notNull().references(() => messages.id),
26518
+ chatId: text("chat_id"),
26519
+ status: text("status").notNull().default("pending"),
26520
+ retryCount: integer("retry_count").notNull().default(0),
26521
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
26522
+ deliveredAt: timestamp("delivered_at", { withTimezone: true }),
26523
+ ackedAt: timestamp("acked_at", { withTimezone: true })
26524
+ }, (table) => [unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId), index("idx_inbox_pending").on(table.inboxId, table.createdAt)]);
26525
+ async function sendMessage(db, chatId, senderId, data) {
26526
+ return db.transaction(async (tx) => {
26527
+ const messageId = randomUUID();
26528
+ const [msg] = await tx.insert(messages).values({
26529
+ id: messageId,
26530
+ chatId,
26531
+ senderId,
26532
+ format: data.format,
26533
+ content: data.content,
26534
+ metadata: data.metadata ?? {},
26535
+ replyToInbox: data.replyToInbox ?? null,
26536
+ replyToChat: data.replyToChat ?? null,
26537
+ inReplyTo: data.inReplyTo ?? null
26538
+ }).returning();
26539
+ const entries = (await tx.select({
26540
+ agentId: chatParticipants.agentId,
26541
+ inboxId: agents.inboxId
26542
+ }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.id)).where(eq(chatParticipants.chatId, chatId))).filter((p) => p.agentId !== senderId).map((p) => ({
26543
+ inboxId: p.inboxId,
26544
+ messageId,
26545
+ chatId
26546
+ }));
26547
+ if (entries.length > 0) await tx.insert(inboxEntries).values(entries);
26548
+ const recipients = entries.map((e) => e.inboxId);
26549
+ if (data.inReplyTo) {
26550
+ const [original] = await tx.select({
26551
+ replyToInbox: messages.replyToInbox,
26552
+ replyToChat: messages.replyToChat
26553
+ }).from(messages).where(eq(messages.id, data.inReplyTo)).limit(1);
26554
+ if (original?.replyToInbox && original?.replyToChat) {
26555
+ await tx.insert(inboxEntries).values({
26556
+ inboxId: original.replyToInbox,
26557
+ messageId,
26558
+ chatId: original.replyToChat
26559
+ }).onConflictDoNothing();
26560
+ if (!recipients.includes(original.replyToInbox)) recipients.push(original.replyToInbox);
26561
+ }
26562
+ }
26563
+ await tx.update(chats).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(chats.id, chatId));
26564
+ if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
26565
+ return {
26566
+ message: msg,
26567
+ recipients
26568
+ };
26569
+ });
26570
+ }
26571
+ async function sendToAgent(db, senderId, targetAgentId, data) {
26572
+ const [sender] = await db.select({
26573
+ id: agents.id,
26574
+ organizationId: agents.organizationId
26575
+ }).from(agents).where(eq(agents.id, senderId)).limit(1);
26576
+ if (!sender) throw new NotFoundError(`Agent "${senderId}" not found`);
26577
+ const [target] = await db.select({
26578
+ id: agents.id,
26579
+ organizationId: agents.organizationId
26580
+ }).from(agents).where(eq(agents.id, targetAgentId)).limit(1);
26581
+ if (!target) throw new NotFoundError(`Agent "${targetAgentId}" not found`);
26582
+ return sendMessage(db, (await findOrCreateDirectChat(db, senderId, targetAgentId)).id, senderId, {
26583
+ format: data.format,
26584
+ content: data.content,
26585
+ metadata: data.metadata,
26586
+ replyToInbox: data.replyToInbox,
26587
+ replyToChat: data.replyToChat
26588
+ });
26589
+ }
26590
+ async function editMessage(db, chatId, messageId, senderId, data) {
26591
+ const [msg] = await db.select().from(messages).where(eq(messages.id, messageId)).limit(1);
26592
+ if (!msg) throw new NotFoundError(`Message "${messageId}" not found`);
26593
+ if (msg.chatId !== chatId) throw new NotFoundError(`Message "${messageId}" not found in this chat`);
26594
+ if (msg.senderId !== senderId) throw new ForbiddenError("Only the sender can edit a message");
26595
+ const setClause = {};
26596
+ if (data.format !== void 0) setClause.format = data.format;
26597
+ if (data.content !== void 0) setClause.content = data.content;
26598
+ const meta = msg.metadata ?? {};
26599
+ meta.editedAt = (/* @__PURE__ */ new Date()).toISOString();
26600
+ setClause.metadata = meta;
26601
+ const [updated] = await db.update(messages).set(setClause).where(eq(messages.id, messageId)).returning();
26602
+ if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
26603
+ return updated;
26604
+ }
26605
+ async function listMessages(db, chatId, limit, cursor) {
26606
+ const where = cursor ? and(eq(messages.chatId, chatId), lt(messages.createdAt, new Date(cursor))) : eq(messages.chatId, chatId);
26607
+ const rows = await db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(limit + 1);
26608
+ const hasMore = rows.length > limit;
26609
+ const items = hasMore ? rows.slice(0, limit) : rows;
26610
+ const last = items[items.length - 1];
26611
+ return {
26612
+ items,
26613
+ nextCursor: hasMore && last ? last.createdAt.toISOString() : null
26614
+ };
26615
+ }
26616
+ const INBOX_CHANNEL = "inbox_notifications";
26617
+ const CONFIG_CHANNEL = "config_changes";
26618
+ function createNotifier(listenClient) {
26619
+ const subscriptions = /* @__PURE__ */ new Map();
26620
+ const configChangeHandlers = [];
26621
+ let unlistenInboxFn = null;
26622
+ let unlistenConfigFn = null;
26623
+ function handleNotification(payload) {
26624
+ const sepIdx = payload.indexOf(":");
26625
+ if (sepIdx === -1) return;
26626
+ const inboxId = payload.slice(0, sepIdx);
26627
+ const messageId = payload.slice(sepIdx + 1);
26628
+ const sockets = subscriptions.get(inboxId);
26629
+ if (!sockets) return;
26630
+ const data = JSON.stringify({
26631
+ type: "new_message",
26632
+ inboxId,
26633
+ messageId
26634
+ });
26635
+ for (const ws of sockets) if (ws.readyState === ws.OPEN) ws.send(data);
26636
+ }
26637
+ return {
26638
+ subscribe(inboxId, ws) {
26639
+ let set = subscriptions.get(inboxId);
26640
+ if (!set) {
26641
+ set = /* @__PURE__ */ new Set();
26642
+ subscriptions.set(inboxId, set);
26643
+ }
26644
+ set.add(ws);
26645
+ },
26646
+ unsubscribe(inboxId, ws) {
26647
+ const set = subscriptions.get(inboxId);
26648
+ if (set) {
26649
+ set.delete(ws);
26650
+ if (set.size === 0) subscriptions.delete(inboxId);
26651
+ }
26652
+ },
26653
+ async notify(inboxId, messageId) {
26654
+ try {
26655
+ await listenClient`SELECT pg_notify(${INBOX_CHANNEL}, ${`${inboxId}:${messageId}`})`;
26656
+ } catch {}
26657
+ },
26658
+ async notifyConfigChange(configType) {
26659
+ try {
26660
+ await listenClient`SELECT pg_notify(${CONFIG_CHANNEL}, ${configType})`;
26661
+ } catch {}
26662
+ },
26663
+ onConfigChange(handler) {
26664
+ configChangeHandlers.push(handler);
26665
+ },
26666
+ async start() {
26667
+ unlistenInboxFn = (await listenClient.listen(INBOX_CHANNEL, (payload) => {
26668
+ if (payload) handleNotification(payload);
26669
+ })).unlisten;
26670
+ unlistenConfigFn = (await listenClient.listen(CONFIG_CHANNEL, (payload) => {
26671
+ if (payload) for (const handler of configChangeHandlers) handler(payload);
26672
+ })).unlisten;
26673
+ },
26674
+ async stop() {
26675
+ if (unlistenInboxFn) {
26676
+ await unlistenInboxFn();
26677
+ unlistenInboxFn = null;
26678
+ }
26679
+ if (unlistenConfigFn) {
26680
+ await unlistenConfigFn();
26681
+ unlistenConfigFn = null;
26682
+ }
26683
+ }
26684
+ };
26685
+ }
26686
+ /** Fire-and-forget: notify all recipients that a new message is available. */
26687
+ function notifyRecipients(notifier, recipients, messageId) {
26688
+ for (const inboxId of recipients) notifier.notify(inboxId, messageId).catch(() => {});
26689
+ }
25690
26690
  /** Server instance heartbeat. Used to detect crashed instances and clean up associated agent_presence records. */
25691
26691
  const serverInstances = pgTable("server_instances", {
25692
26692
  instanceId: text("instance_id").primaryKey(),
@@ -25717,6 +26717,10 @@ async function setOffline(db, agentId) {
25717
26717
  lastSeenAt: /* @__PURE__ */ new Date()
25718
26718
  }).where(eq(agentPresence.agentId, agentId));
25719
26719
  }
26720
+ async function getPresence(db, agentId) {
26721
+ const [row] = await db.select().from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
26722
+ return row ?? null;
26723
+ }
25720
26724
  async function getOnlineCount(db) {
25721
26725
  const [result] = await db.select({ count: sql`count(*)::int` }).from(agentPresence).where(eq(agentPresence.status, "online"));
25722
26726
  return result?.count ?? 0;
@@ -25801,6 +26805,53 @@ async function adminAgentRoutes(app) {
25801
26805
  await deleteAgent(app.db, request.params.agentId);
25802
26806
  return reply.status(204).send();
25803
26807
  });
26808
+ app.post("/:agentId/test", async (request, reply) => {
26809
+ const { agentId } = request.params;
26810
+ const [, presence] = await Promise.all([getAgent(app.db, agentId), getPresence(app.db, agentId)]);
26811
+ if (!presence || presence.status !== "online") return reply.status(200).send({
26812
+ status: "offline",
26813
+ message: "Agent is not connected. Start the client first."
26814
+ });
26815
+ const [owner] = await app.db.select({ id: agents.id }).from(agents).where(and(eq(agents.delegateMention, agentId), eq(agents.status, "active"))).limit(1);
26816
+ let senderId = owner?.id ?? null;
26817
+ if (!senderId) {
26818
+ const [other] = await app.db.select({ id: agents.id }).from(agents).where(and(ne(agents.id, agentId), eq(agents.status, "active"))).limit(1);
26819
+ senderId = other?.id ?? null;
26820
+ }
26821
+ if (!senderId) return reply.status(200).send({
26822
+ status: "error",
26823
+ message: "No suitable sender found. Need at least one other active agent."
26824
+ });
26825
+ const chat = await findOrCreateDirectChat(app.db, senderId, agentId);
26826
+ const testContent = `[System Test] Verify your connection. Respond with your identity and role. Time: ${(/* @__PURE__ */ new Date()).toISOString()}`;
26827
+ const result = await sendMessage(app.db, chat.id, senderId, {
26828
+ format: "text",
26829
+ content: testContent
26830
+ });
26831
+ notifyRecipients(app.notifier, result.recipients, result.message.id);
26832
+ const POLL_TIMEOUT = 3e4;
26833
+ const POLL_INTERVAL = 1e3;
26834
+ const threshold = result.message.createdAt;
26835
+ const pollStart = Date.now();
26836
+ while (Date.now() - pollStart < POLL_TIMEOUT) {
26837
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL));
26838
+ 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);
26839
+ if (response) {
26840
+ const content = typeof response.content === "string" ? response.content.slice(0, 500) : JSON.stringify(response.content).slice(0, 500);
26841
+ return reply.status(200).send({
26842
+ status: "success",
26843
+ chatId: chat.id,
26844
+ responseContent: content,
26845
+ responseTime: response.createdAt.getTime() - threshold.getTime()
26846
+ });
26847
+ }
26848
+ }
26849
+ return reply.status(200).send({
26850
+ status: "timeout",
26851
+ chatId: chat.id,
26852
+ message: "Agent is connected but did not respond within 30 seconds."
26853
+ });
26854
+ });
25804
26855
  }
25805
26856
  /** Admin accounts. Passwords are stored as bcrypt hashes. */
25806
26857
  const adminUsers = pgTable("admin_users", {
@@ -26039,8 +27090,10 @@ async function updateAdminUser(db, id, data) {
26039
27090
  return toResponse(row);
26040
27091
  }
26041
27092
  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`);
27093
+ const [target] = await db.select().from(adminUsers).where(eq(adminUsers.id, id)).limit(1);
27094
+ if (!target) throw new NotFoundError(`Admin user "${id}" not found`);
27095
+ if (target.role === "super_admin") throw new ForbiddenError("Cannot delete a super_admin account");
27096
+ await db.delete(adminUsers).where(eq(adminUsers.id, id));
26044
27097
  }
26045
27098
  function serializeDate(d) {
26046
27099
  return d ? d.toISOString() : null;
@@ -26087,133 +27140,6 @@ function requireAgent(request) {
26087
27140
  if (!agent) throw new UnauthorizedError("Agent authentication required");
26088
27141
  return agent;
26089
27142
  }
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
27143
  function serializeChat(chat) {
26218
27144
  return {
26219
27145
  ...chat,
@@ -26264,24 +27190,112 @@ async function agentChatRoutes(app) {
26264
27190
  joinedAt: p.joinedAt.toISOString()
26265
27191
  })));
26266
27192
  });
26267
- app.delete("/:chatId/participants/:agentId", async (request, reply) => {
27193
+ app.delete("/:chatId/participants/:agentId", async (request, reply) => {
27194
+ const identity = requireAgent(request);
27195
+ await removeParticipant(app.db, request.params.chatId, identity.id, request.params.agentId);
27196
+ return reply.status(204).send();
27197
+ });
27198
+ }
27199
+ async function agentContextTreeRoutes(app) {
27200
+ app.get("/", async (_request, reply) => {
27201
+ const { repo, branch } = app.config.contextTree;
27202
+ if (!repo) return reply.status(404).send({ error: "Context Tree not configured" });
27203
+ return reply.send({
27204
+ repo,
27205
+ branch
27206
+ });
27207
+ });
27208
+ }
27209
+ async function agentFeishuBotRoutes(app) {
27210
+ /**
27211
+ * PUT /agent/me/feishu-bot
27212
+ * Self-service: agent binds its own Feishu bot (upsert).
27213
+ */
27214
+ app.put("/me/feishu-bot", async (request, reply) => {
27215
+ const identity = requireAgent(request);
27216
+ const body = selfServiceFeishuBotSchema.parse(request.body);
27217
+ if ((await getAgent(app.db, identity.id)).type === "human") throw new BadRequestError("Human agents cannot bind Feishu bots. Use bind-user instead.");
27218
+ const current = (await listAdapterConfigs(app.db)).find((c) => c.agentId === identity.id && c.platform === "feishu");
27219
+ let config;
27220
+ if (current) config = await updateAdapterConfig(app.db, current.id, {
27221
+ credentials: {
27222
+ app_id: body.appId,
27223
+ app_secret: body.appSecret
27224
+ },
27225
+ status: "active"
27226
+ }, app.config.secrets.encryptionKey);
27227
+ else config = await createAdapterConfig(app.db, {
27228
+ platform: "feishu",
27229
+ agentId: identity.id,
27230
+ credentials: {
27231
+ app_id: body.appId,
27232
+ app_secret: body.appSecret
27233
+ },
27234
+ status: "active"
27235
+ }, app.config.secrets.encryptionKey);
27236
+ app.adapterManager.reload().catch((err) => app.log.error(err, "Adapter reload failed after self-service bind"));
27237
+ app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
27238
+ return reply.status(current ? 200 : 201).send({
27239
+ ...config,
27240
+ createdAt: config.createdAt.toISOString(),
27241
+ updatedAt: config.updatedAt.toISOString()
27242
+ });
27243
+ });
27244
+ /**
27245
+ * DELETE /agent/me/feishu-bot
27246
+ * Self-service: agent unbinds its own Feishu bot.
27247
+ */
27248
+ app.delete("/me/feishu-bot", async (request, reply) => {
27249
+ const identity = requireAgent(request);
27250
+ const current = (await listAdapterConfigs(app.db)).find((c) => c.agentId === identity.id && c.platform === "feishu");
27251
+ if (!current) return reply.status(204).send();
27252
+ await deleteAdapterConfig(app.db, current.id);
27253
+ app.adapterManager.reload().catch((err) => app.log.error(err, "Adapter reload failed after self-service unbind"));
27254
+ app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
27255
+ return reply.status(204).send();
27256
+ });
27257
+ }
27258
+ async function agentFeishuUserRoutes(app) {
27259
+ /**
27260
+ * POST /agent/delegated/:humanAgentId/feishu-user
27261
+ * Assistant binds its owner's Feishu user ID via delegate_mention authorization.
27262
+ */
27263
+ app.post("/:humanAgentId/feishu-user", async (request, reply) => {
27264
+ const identity = requireAgent(request);
27265
+ const { humanAgentId } = request.params;
27266
+ const body = delegateFeishuUserSchema.parse(request.body);
27267
+ const humanAgent = await getAgent(app.db, humanAgentId);
27268
+ if (humanAgent.type !== "human") throw new BadRequestError(`Agent "${humanAgentId}" is not a human agent`);
27269
+ if (humanAgent.delegateMention !== identity.id) throw new ForbiddenError(`Agent "${identity.id}" is not the delegate of "${humanAgentId}". Expected delegate_mention="${identity.id}" but found "${humanAgent.delegateMention ?? "(none)"}".`);
27270
+ const mapping = await createAgentMapping(app.db, {
27271
+ platform: "feishu",
27272
+ externalUserId: body.feishuUserId,
27273
+ agentId: humanAgentId,
27274
+ boundVia: "delegate",
27275
+ displayName: body.displayName
27276
+ });
27277
+ return reply.status(201).send({
27278
+ id: mapping.id,
27279
+ platform: mapping.platform,
27280
+ externalUserId: mapping.externalUserId,
27281
+ agentId: mapping.agentId,
27282
+ boundVia: mapping.boundVia,
27283
+ displayName: mapping.displayName,
27284
+ createdAt: mapping.createdAt.toISOString()
27285
+ });
27286
+ });
27287
+ /**
27288
+ * DELETE /agent/delegated/:humanAgentId/feishu-user
27289
+ * Assistant unbinds its owner's Feishu user ID.
27290
+ */
27291
+ app.delete("/:humanAgentId/feishu-user", async (request, reply) => {
26268
27292
  const identity = requireAgent(request);
26269
- await removeParticipant(app.db, request.params.chatId, identity.id, request.params.agentId);
27293
+ const { humanAgentId } = request.params;
27294
+ if ((await getAgent(app.db, humanAgentId)).delegateMention !== identity.id) throw new ForbiddenError(`Agent "${identity.id}" is not the delegate of "${humanAgentId}"`);
27295
+ await app.db.delete(adapterAgentMappings).where(and(eq(adapterAgentMappings.platform, "feishu"), eq(adapterAgentMappings.agentId, humanAgentId)));
26270
27296
  return reply.status(204).send();
26271
27297
  });
26272
27298
  }
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)]);
26285
27299
  const DEFAULT_INBOX_TIMEOUT_SECONDS = 300;
26286
27300
  const DEFAULT_MAX_RETRY_COUNT = 3;
26287
27301
  async function pollInbox(db, inboxId, limit) {
@@ -26394,171 +27408,6 @@ async function agentMeRoutes(app) {
26394
27408
  };
26395
27409
  });
26396
27410
  }
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
27411
  const editMessageSchema = z.object({
26563
27412
  format: z.string().optional(),
26564
27413
  content: z.unknown()
@@ -26640,6 +27489,62 @@ function agentWsRoutes(notifier, instanceId) {
26640
27489
  });
26641
27490
  };
26642
27491
  }
27492
+ async function bootstrapRoutes(app) {
27493
+ /**
27494
+ * POST /bootstrap/:agentId/token
27495
+ * GitHub identity → Agent token.
27496
+ * Only works when the agent has no active tokens.
27497
+ */
27498
+ app.post("/:agentId/token", async (request, reply) => {
27499
+ const { agentId } = request.params;
27500
+ const githubUser = request.githubUser;
27501
+ if (!githubUser) throw new ForbiddenError("GitHub authentication required");
27502
+ const body = bootstrapTokenRequestSchema.parse(request.body ?? {});
27503
+ const result = await bootstrapToken(app.db, agentId, githubUser.username, body.name);
27504
+ return reply.status(201).send({
27505
+ id: result.id,
27506
+ agentId: result.agentId,
27507
+ name: result.name,
27508
+ token: result.token,
27509
+ expiresAt: result.expiresAt?.toISOString() ?? null,
27510
+ createdAt: result.createdAt.toISOString()
27511
+ });
27512
+ });
27513
+ /**
27514
+ * GET /bootstrap/:agentId/status
27515
+ * Check if an agent exists and its status (for polling after PR merge + sync).
27516
+ */
27517
+ app.get("/:agentId/status", async (request) => {
27518
+ const { agentId } = request.params;
27519
+ const githubUser = request.githubUser;
27520
+ if (!githubUser) throw new ForbiddenError("GitHub authentication required");
27521
+ try {
27522
+ const agent = await getAgent(app.db, agentId);
27523
+ if (!(Array.isArray(agent.metadata?.owners) ? agent.metadata.owners : []).includes(githubUser.username)) throw new ForbiddenError(`GitHub user "${githubUser.username}" is not in the owners list for agent "${agentId}"`);
27524
+ return {
27525
+ exists: true,
27526
+ status: agent.status
27527
+ };
27528
+ } catch (err) {
27529
+ if (err instanceof NotFoundError) return {
27530
+ exists: false,
27531
+ status: null
27532
+ };
27533
+ throw err;
27534
+ }
27535
+ });
27536
+ }
27537
+ async function contextTreeInfoRoutes(app) {
27538
+ /** Public endpoint — returns Context Tree repo metadata for CLI auto-discovery. */
27539
+ app.get("/info", async () => {
27540
+ const { repo, branch } = app.config.contextTree;
27541
+ return {
27542
+ repo,
27543
+ branch,
27544
+ lastSync: null
27545
+ };
27546
+ });
27547
+ }
26643
27548
  async function healthRoutes(app) {
26644
27549
  app.get("/health", async () => {
26645
27550
  try {
@@ -27207,6 +28112,25 @@ function agentAuthHook(db) {
27207
28112
  request.agent = agent;
27208
28113
  };
27209
28114
  }
28115
+ const GITHUB_API_URL = "https://api.github.com/user";
28116
+ /**
28117
+ * Middleware that validates a GitHub token from the `X-GitHub-Token` header.
28118
+ * On success, sets `request.githubUser = { username }`.
28119
+ */
28120
+ function githubAuthHook() {
28121
+ return async (request, _reply) => {
28122
+ const token = request.headers["x-github-token"];
28123
+ if (!token || typeof token !== "string") throw new UnauthorizedError("Missing X-GitHub-Token header");
28124
+ const res = await fetch(GITHUB_API_URL, { headers: {
28125
+ Authorization: `Bearer ${token}`,
28126
+ Accept: "application/vnd.github+json"
28127
+ } });
28128
+ if (!res.ok) throw new UnauthorizedError("Invalid GitHub token");
28129
+ const data = await res.json();
28130
+ if (!data.login) throw new ForbiddenError("Could not determine GitHub username from token");
28131
+ request.githubUser = { username: data.login };
28132
+ };
28133
+ }
27210
28134
  const PROXY_ENV_KEYS = [
27211
28135
  "HTTP_PROXY",
27212
28136
  "HTTPS_PROXY",
@@ -27440,6 +28364,12 @@ function parseEventData(appId, data) {
27440
28364
  };
27441
28365
  }
27442
28366
  async function processInboundMessage(db, event, bot, log, inboxNotifier) {
28367
+ const messageText = extractTextContent(event);
28368
+ const bindMatch = /^\/bind\s+(\S+)/.exec(messageText);
28369
+ if (bindMatch?.[1]) {
28370
+ await handleBindCommand(db, bot, event, bindMatch[1], log);
28371
+ return;
28372
+ }
27443
28373
  const agentMapping = await findAgentByExternalUser(db, "feishu", event.senderId);
27444
28374
  if (!agentMapping) {
27445
28375
  await replyUnknownUser(bot, event, log);
@@ -27482,8 +28412,9 @@ async function processInboundMessage(db, event, bot, log, inboxNotifier) {
27482
28412
  async function replyUnknownUser(bot, event, log) {
27483
28413
  const text = [
27484
28414
  "Your account is not linked to First Tree Hub yet.",
27485
- "Please contact your admin to set up the binding.",
27486
- `Your user ID: ${event.senderId}`
28415
+ "To bind, send: /bind <your-agent-id>",
28416
+ "",
28417
+ "Example: /bind alice"
27487
28418
  ].join("\n");
27488
28419
  try {
27489
28420
  await botApiCall(bot, () => bot.client.im.v1.message.create({
@@ -27501,6 +28432,81 @@ async function replyUnknownUser(bot, event, log) {
27501
28432
  }, "Failed to send unknown-user reply");
27502
28433
  }
27503
28434
  }
28435
+ /** Extract plain text from a Feishu message event. */
28436
+ function extractTextContent(event) {
28437
+ if (typeof event.content === "object" && event.content !== null && "text" in event.content) return (event.content.text ?? "").trim();
28438
+ return "";
28439
+ }
28440
+ /**
28441
+ * Handle `/bind <agentId>` command from Feishu.
28442
+ * Binds the sender's Feishu user ID to the specified human agent.
28443
+ */
28444
+ async function handleBindCommand(db, bot, event, agentId, log) {
28445
+ const reply = async (text) => {
28446
+ try {
28447
+ await botApiCall(bot, () => bot.client.im.v1.message.create({
28448
+ params: { receive_id_type: "chat_id" },
28449
+ data: {
28450
+ receive_id: event.externalChannelId,
28451
+ msg_type: "text",
28452
+ content: JSON.stringify({ text })
28453
+ }
28454
+ }));
28455
+ } catch (err) {
28456
+ log.warn({ err }, "Failed to send /bind reply");
28457
+ }
28458
+ };
28459
+ const existingMapping = await findAgentByExternalUser(db, "feishu", event.senderId);
28460
+ if (existingMapping) {
28461
+ await reply(`You are already bound to agent "${existingMapping.agentId}". Unbind first if you want to rebind.`);
28462
+ return;
28463
+ }
28464
+ const [agent] = await db.select({
28465
+ id: agents.id,
28466
+ type: agents.type,
28467
+ status: agents.status
28468
+ }).from(agents).where(eq(agents.id, agentId)).limit(1);
28469
+ if (!agent) {
28470
+ await reply(`Agent "${agentId}" not found. Check the ID and try again.`);
28471
+ return;
28472
+ }
28473
+ if (agent.status !== "active") {
28474
+ await reply(`Agent "${agentId}" is ${agent.status}. Only active agents can be bound.`);
28475
+ return;
28476
+ }
28477
+ if (agent.type !== "human") {
28478
+ await reply(`Agent "${agentId}" is not a human agent (type: ${agent.type}). Only human agents can bind Feishu users.`);
28479
+ return;
28480
+ }
28481
+ const existingAgentBinding = await findExternalUserByAgent(db, "feishu", agentId);
28482
+ if (existingAgentBinding) {
28483
+ await reply(`Agent "${agentId}" is already bound to Feishu user ${existingAgentBinding.externalUserId}. Unbind first if you want to rebind.`);
28484
+ return;
28485
+ }
28486
+ try {
28487
+ await createAgentMapping(db, {
28488
+ platform: "feishu",
28489
+ externalUserId: event.senderId,
28490
+ agentId,
28491
+ boundVia: "command",
28492
+ displayName: void 0
28493
+ });
28494
+ } catch (err) {
28495
+ log.error({
28496
+ err,
28497
+ agentId,
28498
+ senderId: event.senderId
28499
+ }, "/bind: failed to create mapping");
28500
+ await reply("Binding failed due to an internal error. Please try again or contact your admin.");
28501
+ return;
28502
+ }
28503
+ await reply(`Binding successful! Your Feishu account is now linked to "${agentId}".`);
28504
+ log.info({
28505
+ agentId,
28506
+ senderId: event.senderId,
28507
+ appId: bot.appId
28508
+ }, "/bind: Feishu user bound via command");
28509
+ }
27504
28510
  /**
27505
28511
  * Process outbound messages for all feishu-bound human agents.
27506
28512
  * Consumes pending inbox entries for human agents that have feishu platform bindings,
@@ -27695,6 +28701,7 @@ async function buildApp(config) {
27695
28701
  });
27696
28702
  const agentAuth = agentAuthHook(db);
27697
28703
  const adminAuth = adminAuthHook(db, config.secrets.jwtSecret);
28704
+ const githubAuth = githubAuthHook();
27698
28705
  app.setErrorHandler((error, _request, reply) => {
27699
28706
  if (error instanceof AppError) return reply.status(error.statusCode).send({ error: error.message });
27700
28707
  if (error instanceof ZodError) return reply.status(400).send({
@@ -27709,6 +28716,11 @@ async function buildApp(config) {
27709
28716
  await api.register(healthRoutes);
27710
28717
  await api.register(githubWebhookRoutes, { prefix: "/webhooks" });
27711
28718
  await api.register(adminAuthRoutes, { prefix: "/admin/auth" });
28719
+ await api.register(contextTreeInfoRoutes, { prefix: "/context-tree" });
28720
+ await api.register(async (bootstrapApp) => {
28721
+ bootstrapApp.addHook("onRequest", githubAuth);
28722
+ await bootstrapApp.register(bootstrapRoutes);
28723
+ }, { prefix: "/bootstrap" });
27712
28724
  await api.register(async (adminApp) => {
27713
28725
  adminApp.addHook("onRequest", adminAuth);
27714
28726
  await adminApp.register(adminAgentRoutes);
@@ -27752,6 +28764,9 @@ async function buildApp(config) {
27752
28764
  await agentApp.register(agentMessageRoutes, { prefix: "/chats" });
27753
28765
  await agentApp.register(agentSendToAgentRoutes, { prefix: "/agents" });
27754
28766
  await agentApp.register(agentInboxRoutes, { prefix: "/inbox" });
28767
+ await agentApp.register(agentContextTreeRoutes, { prefix: "/context-tree" });
28768
+ await agentApp.register(agentFeishuBotRoutes);
28769
+ await agentApp.register(agentFeishuUserRoutes, { prefix: "/delegated" });
27755
28770
  await agentApp.register(agentWsRoutes(notifier, config.instanceId), { prefix: "/ws" });
27756
28771
  }, { prefix: "/agent" });
27757
28772
  }, { prefix: "/api/v1" });
@@ -27911,4 +28926,4 @@ function resolveWebDist() {
27911
28926
  } catch {}
27912
28927
  }
27913
28928
  //#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 };
28929
+ export { ClientRuntime as A, registerBuiltinHandlers as B, checkWebSocket as C, ensurePostgres as D, status as E, SdkError as F, hasAdminUser as H, SessionRegistry as I, cleanWorkspaces as L, AgentSlot as M, DEFAULT_WORKSPACE_TTL_MS as N, isDockerAvailable as O, FirstTreeHubSDK as P, getHandlerFactory as R, checkServerReachable as S, blank as T, createAdminUser$1 as V, checkDocker as _, formatCheckReport as a, checkServerConfig as b, onboardContinue as c, runMigrations as d, checkAgentConfigs as f, checkDatabase as g, checkContextTreeRepo as h, promptMissingFields as i, AgentRuntime as j, stopPostgres as k, onboardCreate as l, checkClientConfig as m, isInteractive as n, loadOnboardState as o, checkAgentTokens as p, promptAddAgent as r, onboardCheck as s, startServer as t, saveOnboardState as u, checkGitHubToken as v, printResults as w, checkServerHealth as x, checkNodeVersion as y, loadRuntimeConfig as z };