@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.
- package/dist/bootstrap-B9JsJR3Z.mjs +583 -0
- package/dist/cli/index.mjs +220 -3
- package/dist/{core-CD3xEbyB.mjs → core-D-c9r7D6.mjs} +1956 -941
- package/dist/drizzle/0006_agent_tree_path.sql +1 -0
- package/dist/drizzle/meta/_journal.json +7 -0
- package/dist/feishu-Y4m2zFc3.mjs +51 -0
- package/dist/index.mjs +4 -2
- package/dist/rolldown-runtime-twds-ZHy.mjs +14 -0
- package/dist/web/assets/index-B1dQmYGJ.js +234 -0
- package/dist/web/assets/index-BURu6jt9.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CHZINY3I.js +0 -229
- package/dist/web/assets/index-Drt799Rs.css +0 -1
|
@@ -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
|
|
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
|
-
|
|
23107
|
-
|
|
23108
|
-
|
|
23109
|
-
|
|
23110
|
-
|
|
23111
|
-
|
|
23112
|
-
|
|
23113
|
-
|
|
23114
|
-
|
|
23115
|
-
|
|
23116
|
-
|
|
23117
|
-
|
|
23118
|
-
|
|
23119
|
-
|
|
23120
|
-
|
|
23121
|
-
|
|
23122
|
-
|
|
23123
|
-
|
|
23124
|
-
|
|
23125
|
-
|
|
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
|
-
|
|
23128
|
-
|
|
23129
|
-
|
|
23130
|
-
|
|
23131
|
-
|
|
23132
|
-
|
|
23133
|
-
|
|
23134
|
-
|
|
23135
|
-
|
|
23136
|
-
|
|
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
|
|
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:
|
|
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
|
-
...
|
|
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")
|
|
23232
|
-
|
|
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
|
-
|
|
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(
|
|
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: {
|
|
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
|
-
|
|
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/
|
|
24471
|
-
|
|
24472
|
-
|
|
24473
|
-
|
|
24474
|
-
|
|
24475
|
-
|
|
24476
|
-
|
|
24477
|
-
|
|
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
|
-
|
|
24481
|
-
|
|
24482
|
-
|
|
24483
|
-
|
|
24484
|
-
|
|
24485
|
-
|
|
24486
|
-
|
|
24487
|
-
|
|
24488
|
-
|
|
24489
|
-
|
|
24490
|
-
|
|
24491
|
-
|
|
24492
|
-
|
|
24493
|
-
|
|
24494
|
-
|
|
24495
|
-
|
|
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
|
-
|
|
24504
|
-
|
|
24505
|
-
|
|
24506
|
-
|
|
24507
|
-
|
|
24508
|
-
|
|
24509
|
-
|
|
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
|
-
|
|
24513
|
-
|
|
24514
|
-
|
|
24515
|
-
|
|
24516
|
-
|
|
24517
|
-
|
|
24518
|
-
|
|
24519
|
-
|
|
24520
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
25395
|
-
|
|
25396
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
25662
|
-
const
|
|
25663
|
-
|
|
25664
|
-
const
|
|
25665
|
-
|
|
25666
|
-
|
|
25667
|
-
|
|
25668
|
-
|
|
25669
|
-
|
|
25670
|
-
|
|
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
|
-
|
|
25681
|
-
|
|
25682
|
-
|
|
25683
|
-
|
|
25684
|
-
|
|
25685
|
-
|
|
25686
|
-
|
|
25687
|
-
|
|
25688
|
-
|
|
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 [
|
|
26043
|
-
if (!
|
|
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
|
-
|
|
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
|
-
"
|
|
27486
|
-
|
|
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 {
|
|
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 };
|