@agent-team-foundation/first-tree-hub 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
- import { C as setConfigValue, S as serverConfigSchema, _ as loadAgents, d as DEFAULT_HOME_DIR$1, f as agentConfigSchema, g as initConfig, i as loadCredentials, l as DEFAULT_CONFIG_DIR, m as collectMissingPrompts, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, s as saveAgentConfig, u as DEFAULT_DATA_DIR$1, x as resolveConfigReadonly } from "./bootstrap-DW7aIpmE.mjs";
2
- import { $ as updateOrganizationSchema, A as createOrganizationSchema, B as paginationQuerySchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as isRedactedEnvValue, G as sendToAgentSchema, H as runtimeStateMessageSchema, I as linkTaskChatSchema, J as taskListQuerySchema, K as sessionOutputMessageSchema, L as loginSchema, M as delegateFeishuUserSchema, N as dryRunAgentRuntimeConfigSchema, O as createChatSchema, P as inboxPollQuerySchema, Q as updateMemberSchema, R as messageSourceSchema$1, S as agentTypeSchema$1, T as createAdapterConfigSchema, U as selfServiceFeishuBotSchema, V as refreshTokenSchema, W as sendMessageSchema, X as updateAgentRuntimeConfigSchema, Y as updateAdapterConfigSchema, Z as updateAgentSchema, _ as addParticipantSchema, a as AGENT_SELECTOR_HEADER$1, b as agentBindRequestSchema, c as AGENT_TYPES, d as SYSTEM_CONFIG_DEFAULTS, et as updateSystemConfigSchema, f as TASK_CREATOR_TYPES, g as WS_AUTH_FRAME_TIMEOUT_MS, h as TASK_TERMINAL_STATUSES, i as AGENT_BIND_REJECT_REASONS, j as createTaskSchema, k as createMemberSchema, l as AGENT_VISIBILITY, m as TASK_STATUSES, nt as wsAuthFrameSchema, o as AGENT_SOURCES, p as TASK_HEALTH_SIGNALS, q as sessionStateMessageSchema, s as AGENT_STATUSES, tt as updateTaskStatusSchema, u as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, v as adminCreateTaskSchema, w as connectTokenExchangeSchema, x as agentRuntimeConfigPayloadSchema$1, y as adminUpdateTaskSchema, z as notificationQuerySchema } from "./feishu-BZ8pnMrQ.mjs";
1
+ import { C as setConfigValue, S as serverConfigSchema, _ as loadAgents, d as DEFAULT_HOME_DIR$1, f as agentConfigSchema, g as initConfig, i as loadCredentials, l as DEFAULT_CONFIG_DIR, m as collectMissingPrompts, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, s as saveAgentConfig, u as DEFAULT_DATA_DIR$1, x as resolveConfigReadonly } from "./bootstrap-CRDR6NwE.mjs";
2
+ import { $ as updateAgentSchema, A as createOrganizationSchema, B as paginationQuerySchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as isRedactedEnvValue, G as sendToAgentSchema, H as runtimeStateMessageSchema, I as linkTaskChatSchema, J as sessionEventSchema$1, K as sessionCompletionMessageSchema, L as loginSchema, M as delegateFeishuUserSchema, N as dryRunAgentRuntimeConfigSchema, O as createChatSchema, P as inboxPollQuerySchema, Q as updateAgentRuntimeConfigSchema, R as messageSourceSchema$1, S as agentTypeSchema$1, T as createAdapterConfigSchema, U as selfServiceFeishuBotSchema, V as refreshTokenSchema, W as sendMessageSchema, X as taskListQuerySchema, Y as sessionStateMessageSchema, Z as updateAdapterConfigSchema, _ as addParticipantSchema, a as AGENT_SELECTOR_HEADER$1, b as agentBindRequestSchema, c as AGENT_TYPES, d as SYSTEM_CONFIG_DEFAULTS, et as updateMemberSchema, f as TASK_CREATOR_TYPES, g as WS_AUTH_FRAME_TIMEOUT_MS, h as TASK_TERMINAL_STATUSES, i as AGENT_BIND_REJECT_REASONS, it as wsAuthFrameSchema, j as createTaskSchema, k as createMemberSchema, l as AGENT_VISIBILITY, m as TASK_STATUSES, nt as updateSystemConfigSchema, o as AGENT_SOURCES, p as TASK_HEALTH_SIGNALS, q as sessionEventMessageSchema, rt as updateTaskStatusSchema, s as AGENT_STATUSES, tt as updateOrganizationSchema, u as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, v as adminCreateTaskSchema, w as connectTokenExchangeSchema, x as agentRuntimeConfigPayloadSchema$1, y as adminUpdateTaskSchema, z as notificationQuerySchema } from "./feishu-CJ08ntOD.mjs";
3
3
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { ZodError, z } from "zod";
@@ -11,7 +11,7 @@ import WebSocket from "ws";
11
11
  import { query } from "@anthropic-ai/claude-agent-sdk";
12
12
  import { execFileSync, execSync, spawn } from "node:child_process";
13
13
  import bcrypt from "bcrypt";
14
- import { and, asc, count, desc, eq, gt, inArray, isNotNull, lt, ne, or, sql } from "drizzle-orm";
14
+ import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
15
15
  import { drizzle } from "drizzle-orm/postgres-js";
16
16
  import postgres from "postgres";
17
17
  import { fileURLToPath } from "node:url";
@@ -22,7 +22,7 @@ import rateLimit from "@fastify/rate-limit";
22
22
  import fastifyStatic from "@fastify/static";
23
23
  import websocket from "@fastify/websocket";
24
24
  import Fastify from "fastify";
25
- import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique } from "drizzle-orm/pg-core";
25
+ import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
26
26
  import { SignJWT, jwtVerify } from "jose";
27
27
  import { Client, EventDispatcher, LoggerLevel, WSClient } from "@larksuiteoapi/node-sdk";
28
28
  //#region ../client/dist/index.mjs
@@ -172,7 +172,7 @@ z.object({
172
172
  visibility: agentVisibilitySchema.optional(),
173
173
  metadata: z.record(z.string(), z.unknown()).optional(),
174
174
  managerId: z.string().nullable().optional(),
175
- clientId: z.string().optional()
175
+ clientId: z.string().min(1).max(100).nullable().optional()
176
176
  });
177
177
  z.object({
178
178
  uuid: z.string(),
@@ -559,17 +559,60 @@ z.object({
559
559
  createdAt: z.string(),
560
560
  updatedAt: z.string()
561
561
  });
562
+ const pulseBucketSchema = z.object({
563
+ workingCount: z.number().int().nonnegative(),
564
+ errorMask: z.boolean()
565
+ });
566
+ z.object({
567
+ type: z.literal("pulse:tick"),
568
+ organizationId: z.string(),
569
+ agents: z.record(z.string(), z.array(pulseBucketSchema).length(32))
570
+ });
571
+ const sessionEventKind = z.enum(["tool_call", "error"]);
572
+ const toolCallEventPayload = z.object({
573
+ toolUseId: z.string(),
574
+ name: z.string(),
575
+ args: z.unknown(),
576
+ status: z.enum([
577
+ "pending",
578
+ "ok",
579
+ "error"
580
+ ]),
581
+ durationMs: z.number().int().nonnegative().optional(),
582
+ resultPreview: z.string().max(400).optional()
583
+ });
584
+ const errorEventPayload = z.object({
585
+ source: z.enum([
586
+ "sdk",
587
+ "runtime",
588
+ "tool"
589
+ ]),
590
+ message: z.string().max(2e3)
591
+ });
592
+ const sessionEventSchema = z.discriminatedUnion("kind", [z.object({
593
+ kind: z.literal("tool_call"),
594
+ payload: toolCallEventPayload
595
+ }), z.object({
596
+ kind: z.literal("error"),
597
+ payload: errorEventPayload
598
+ })]);
562
599
  z.object({
563
600
  id: z.string(),
564
601
  agentId: z.string(),
565
602
  chatId: z.string(),
566
- content: z.string(),
567
- updatedAt: z.string()
603
+ seq: z.number().int().positive(),
604
+ kind: sessionEventKind,
605
+ payload: z.union([toolCallEventPayload, errorEventPayload]),
606
+ createdAt: z.string()
568
607
  });
569
608
  z.object({
570
609
  agentId: z.string(),
571
610
  chatId: z.string(),
572
- content: z.string().max(5e4)
611
+ event: sessionEventSchema
612
+ });
613
+ z.object({
614
+ agentId: z.string(),
615
+ chatId: z.string()
573
616
  });
574
617
  const orgStatsSchema = z.object({
575
618
  organizationId: z.string(),
@@ -941,13 +984,21 @@ var ClientConnection = class extends EventEmitter {
941
984
  runtimeState
942
985
  }));
943
986
  }
944
- reportSessionOutput(agentId, chatId, content) {
987
+ reportSessionEvent(agentId, chatId, event) {
945
988
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
946
989
  this.ws.send(JSON.stringify({
947
- type: "session:output",
990
+ type: "session:event",
948
991
  agentId,
949
992
  chatId,
950
- content
993
+ event
994
+ }));
995
+ }
996
+ reportSessionCompletion(agentId, chatId) {
997
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
998
+ this.ws.send(JSON.stringify({
999
+ type: "session:completion",
1000
+ agentId,
1001
+ chatId
951
1002
  }));
952
1003
  }
953
1004
  async disconnect() {
@@ -1277,6 +1328,10 @@ defineConfig({
1277
1328
  default: "http://localhost:8000"
1278
1329
  }
1279
1330
  }) },
1331
+ client: { id: field(z.string().regex(/^client_[a-f0-9]{8}$/), {
1332
+ auto: "client-id",
1333
+ env: "FIRST_TREE_HUB_CLIENT_ID"
1334
+ }) },
1280
1335
  logLevel: field(z.enum([
1281
1336
  "debug",
1282
1337
  "info",
@@ -1534,6 +1589,95 @@ function cleanWorkspaces(workspaceRoot, activeChatIds, ttlMs = DEFAULT_WORKSPACE
1534
1589
  return removed;
1535
1590
  }
1536
1591
  const MAX_RETRIES = 2;
1592
+ const TOOL_RESULT_PREVIEW_LIMIT = 400;
1593
+ function extractContentBlocks(message) {
1594
+ if (!message || typeof message !== "object") return [];
1595
+ const inner = message.message;
1596
+ if (!inner || typeof inner !== "object") return [];
1597
+ const content = inner.content;
1598
+ return Array.isArray(content) ? content : [];
1599
+ }
1600
+ function isToolUseBlock(block) {
1601
+ if (!block || typeof block !== "object") return false;
1602
+ const b = block;
1603
+ return b.type === "tool_use" && typeof b.id === "string" && typeof b.name === "string";
1604
+ }
1605
+ function isToolResultBlock(block) {
1606
+ if (!block || typeof block !== "object") return false;
1607
+ const b = block;
1608
+ return b.type === "tool_result" && typeof b.tool_use_id === "string";
1609
+ }
1610
+ function isResultMessage(message) {
1611
+ if (!message || typeof message !== "object") return false;
1612
+ const m = message;
1613
+ return m.type === "result" && typeof m.subtype === "string";
1614
+ }
1615
+ function extractToolResultText(content) {
1616
+ if (typeof content === "string") return content;
1617
+ if (!Array.isArray(content)) return "";
1618
+ const parts = [];
1619
+ for (const part of content) {
1620
+ if (!part || typeof part !== "object") continue;
1621
+ const p = part;
1622
+ if (p.type === "text" && typeof p.text === "string") parts.push(p.text);
1623
+ }
1624
+ return parts.join("\n");
1625
+ }
1626
+ function createToolCallProcessor(emit) {
1627
+ const pending = /* @__PURE__ */ new Map();
1628
+ function pairResult(block) {
1629
+ const entry = pending.get(block.tool_use_id);
1630
+ if (!entry) return;
1631
+ const status = block.is_error === true ? "error" : "ok";
1632
+ const durationMs = Date.now() - entry.startedAt;
1633
+ const previewRaw = extractToolResultText(block.content);
1634
+ const resultPreview = previewRaw.length > 0 ? previewRaw.slice(0, TOOL_RESULT_PREVIEW_LIMIT) : void 0;
1635
+ emit({
1636
+ kind: "tool_call",
1637
+ payload: {
1638
+ toolUseId: entry.toolUseId,
1639
+ name: entry.name,
1640
+ args: entry.args,
1641
+ status,
1642
+ durationMs,
1643
+ ...resultPreview !== void 0 ? { resultPreview } : {}
1644
+ }
1645
+ });
1646
+ pending.delete(block.tool_use_id);
1647
+ }
1648
+ return {
1649
+ onMessage(message) {
1650
+ if (!message || typeof message !== "object") return;
1651
+ const type = message.type;
1652
+ if (type === "assistant") for (const block of extractContentBlocks(message)) {
1653
+ if (!isToolUseBlock(block)) continue;
1654
+ pending.set(block.id, {
1655
+ toolUseId: block.id,
1656
+ name: block.name,
1657
+ args: block.input,
1658
+ startedAt: Date.now()
1659
+ });
1660
+ }
1661
+ else if (type === "user") {
1662
+ for (const block of extractContentBlocks(message)) if (isToolResultBlock(block)) pairResult(block);
1663
+ }
1664
+ },
1665
+ flush() {
1666
+ if (pending.size === 0) return;
1667
+ for (const entry of pending.values()) emit({
1668
+ kind: "tool_call",
1669
+ payload: {
1670
+ toolUseId: entry.toolUseId,
1671
+ name: entry.name,
1672
+ args: entry.args,
1673
+ status: "pending",
1674
+ durationMs: Date.now() - entry.startedAt
1675
+ }
1676
+ });
1677
+ pending.clear();
1678
+ }
1679
+ };
1680
+ }
1537
1681
  /**
1538
1682
  * Map a payload's MCP server list to the SDK's record type. Handles all three
1539
1683
  * transports (stdio/http/sse) defined in the M1 schema.
@@ -1721,57 +1865,84 @@ const createClaudeCodeHandler = (config) => {
1721
1865
  return true;
1722
1866
  }
1723
1867
  async function consumeOutput(sessionCtx) {
1724
- while (true) {
1725
- if (!currentQuery) return;
1726
- try {
1727
- sessionCtx.setRuntimeState("working");
1728
- for await (const message of currentQuery) {
1729
- sessionCtx.touch();
1730
- if (message.type === "result") {
1731
- const result = message;
1732
- if (result.subtype === "success") {
1733
- retryCount = 0;
1734
- if (result.result && sessionCtx.chatId) {
1735
- sessionCtx.appendOutput(result.result);
1736
- sessionCtx.sdk.sendMessage(sessionCtx.chatId, {
1737
- format: "text",
1738
- content: result.result
1739
- }).then(() => sessionCtx.log("Result forwarded to chat")).catch((err) => sessionCtx.log(`Failed to forward result: ${err instanceof Error ? err.message : String(err)}`));
1868
+ const toolCallProcessor = createToolCallProcessor((event) => sessionCtx.emitEvent(event));
1869
+ try {
1870
+ while (true) {
1871
+ if (!currentQuery) return;
1872
+ try {
1873
+ sessionCtx.setRuntimeState("working");
1874
+ for await (const message of currentQuery) {
1875
+ sessionCtx.touch();
1876
+ toolCallProcessor.onMessage(message);
1877
+ if (isResultMessage(message)) {
1878
+ if (message.subtype === "success") {
1879
+ retryCount = 0;
1880
+ if (message.result && sessionCtx.chatId) {
1881
+ const resultText = message.result;
1882
+ sessionCtx.sdk.sendMessage(sessionCtx.chatId, {
1883
+ format: "text",
1884
+ content: resultText
1885
+ }).then(() => {
1886
+ sessionCtx.log("Result forwarded to chat");
1887
+ sessionCtx.reportSessionCompletion();
1888
+ }).catch((err) => {
1889
+ const reason = err instanceof Error ? err.message : String(err);
1890
+ sessionCtx.log(`Failed to forward result: ${reason}`);
1891
+ const message = `Result forward failed: ${reason}\n---\n${resultText.slice(0, 1500)}`.slice(0, 2e3);
1892
+ sessionCtx.emitEvent({
1893
+ kind: "error",
1894
+ payload: {
1895
+ source: "runtime",
1896
+ message
1897
+ }
1898
+ });
1899
+ });
1900
+ }
1901
+ } else {
1902
+ const errors = message.errors ? message.errors.join("; ") : message.subtype;
1903
+ const errorLog = `Query result error: ${errors} (subtype=${message.subtype}, turns=${message.num_turns ?? "?"}, duration=${message.duration_ms ?? "?"}ms)`;
1904
+ sessionCtx.log(errorLog);
1905
+ sessionCtx.emitEvent({
1906
+ kind: "error",
1907
+ payload: {
1908
+ source: "sdk",
1909
+ message: errors
1910
+ }
1911
+ });
1740
1912
  }
1741
- } else {
1742
- const errorLog = `Query result error: ${result.errors ? result.errors.join("; ") : result.subtype} (subtype=${result.subtype}, turns=${result.num_turns ?? "?"}, duration=${result.duration_ms ?? "?"}ms)`;
1743
- sessionCtx.log(errorLog);
1744
- sessionCtx.appendOutput(`[ERROR] ${errorLog}`);
1913
+ sessionCtx.setRuntimeState("idle");
1745
1914
  }
1746
- sessionCtx.setRuntimeState("idle");
1747
1915
  }
1748
- }
1749
- sessionCtx.setRuntimeState("idle");
1750
- return;
1751
- } catch (err) {
1752
- const errMsg = err instanceof Error ? err.message : String(err);
1753
- sessionCtx.log(`Query error: ${errMsg}`);
1754
- if (err instanceof Error) {
1755
- if (err.cause) sessionCtx.log(` cause: ${err.cause instanceof Error ? err.cause.message : String(err.cause)}`);
1756
- if ("exitCode" in err) sessionCtx.log(` exitCode: ${err.exitCode}`);
1757
- if ("stderr" in err) sessionCtx.log(` stderr: ${err.stderr}`);
1758
- if ("code" in err) sessionCtx.log(` code: ${err.code}`);
1759
- if (err.stack) sessionCtx.log(` stack: ${err.stack.split("\n").slice(1, 4).join(" | ")}`);
1760
- }
1761
- if (retryCount >= MAX_RETRIES || !claudeSessionId) {
1762
- sessionCtx.log("Exhausted retries, session will be suspended");
1763
- sessionCtx.setRuntimeState("error");
1764
- return;
1765
- }
1766
- retryCount++;
1767
- sessionCtx.log(`Attempting auto-resume (retry ${retryCount}/${MAX_RETRIES})`);
1768
- try {
1769
- respawnQuery(claudeSessionId, sessionCtx);
1770
- } catch (resumeErr) {
1771
- sessionCtx.log(`Auto-resume failed: ${resumeErr instanceof Error ? resumeErr.message : String(resumeErr)}`);
1916
+ sessionCtx.setRuntimeState("idle");
1772
1917
  return;
1918
+ } catch (err) {
1919
+ const errMsg = err instanceof Error ? err.message : String(err);
1920
+ sessionCtx.log(`Query error: ${errMsg}`);
1921
+ if (err instanceof Error) {
1922
+ if (err.cause) sessionCtx.log(` cause: ${err.cause instanceof Error ? err.cause.message : String(err.cause)}`);
1923
+ if ("exitCode" in err) sessionCtx.log(` exitCode: ${err.exitCode}`);
1924
+ if ("stderr" in err) sessionCtx.log(` stderr: ${err.stderr}`);
1925
+ if ("code" in err) sessionCtx.log(` code: ${err.code}`);
1926
+ if (err.stack) sessionCtx.log(` stack: ${err.stack.split("\n").slice(1, 4).join(" | ")}`);
1927
+ }
1928
+ if (retryCount >= MAX_RETRIES || !claudeSessionId) {
1929
+ sessionCtx.log("Exhausted retries, session will be suspended");
1930
+ sessionCtx.setRuntimeState("error");
1931
+ return;
1932
+ }
1933
+ toolCallProcessor.flush();
1934
+ retryCount++;
1935
+ sessionCtx.log(`Attempting auto-resume (retry ${retryCount}/${MAX_RETRIES})`);
1936
+ try {
1937
+ respawnQuery(claudeSessionId, sessionCtx);
1938
+ } catch (resumeErr) {
1939
+ sessionCtx.log(`Auto-resume failed: ${resumeErr instanceof Error ? resumeErr.message : String(resumeErr)}`);
1940
+ return;
1941
+ }
1773
1942
  }
1774
1943
  }
1944
+ } finally {
1945
+ toolCallProcessor.flush();
1775
1946
  }
1776
1947
  }
1777
1948
  const contextTreePath = config.contextTreePath ?? null;
@@ -2701,8 +2872,11 @@ var SessionManager = class {
2701
2872
  setRuntimeState: (state) => {
2702
2873
  this.setSessionRuntimeState(chatId, state);
2703
2874
  },
2704
- appendOutput: (content) => {
2705
- this.config.onSessionOutput?.(chatId, content);
2875
+ emitEvent: (event) => {
2876
+ this.config.onSessionEvent?.(chatId, event);
2877
+ },
2878
+ reportSessionCompletion: () => {
2879
+ this.config.onSessionCompletion?.(chatId);
2706
2880
  }
2707
2881
  };
2708
2882
  }
@@ -2846,7 +3020,8 @@ var AgentSlot = class {
2846
3020
  agentConfigCache: this.agentConfigCache,
2847
3021
  onStateChange: (chatId, state) => this.reportSessionState(chatId, state),
2848
3022
  onRuntimeStateChange: (state) => this.reportRuntimeState(state),
2849
- onSessionOutput: (chatId, content) => this.reportSessionOutput(chatId, content)
3023
+ onSessionEvent: (chatId, event) => this.reportSessionEvent(chatId, event),
3024
+ onSessionCompletion: (chatId) => this.reportSessionCompletion(chatId)
2850
3025
  });
2851
3026
  const onCommand = (cmd) => {
2852
3027
  if (cmd.agentId === this.config.agentId && this.sessionManager) this.sessionManager.handleCommand(cmd.chatId, cmd.type).catch((err) => {
@@ -2880,8 +3055,11 @@ var AgentSlot = class {
2880
3055
  reportRuntimeState(state) {
2881
3056
  this.clientConnection.reportRuntimeState(this.config.agentId, state);
2882
3057
  }
2883
- reportSessionOutput(chatId, content) {
2884
- this.clientConnection.reportSessionOutput(this.config.agentId, chatId, content);
3058
+ reportSessionEvent(chatId, event) {
3059
+ this.clientConnection.reportSessionEvent(this.config.agentId, chatId, event);
3060
+ }
3061
+ reportSessionCompletion(chatId) {
3062
+ this.clientConnection.reportSessionCompletion(this.config.agentId, chatId);
2885
3063
  }
2886
3064
  fullStateSync() {
2887
3065
  if (!this.sessionManager) return;
@@ -3036,10 +3214,11 @@ var ClientRuntime = class {
3036
3214
  agentNames = /* @__PURE__ */ new Set();
3037
3215
  watcher = null;
3038
3216
  debounceTimer = null;
3039
- constructor(serverUrl) {
3217
+ constructor(serverUrl, clientId) {
3040
3218
  this.serverUrl = serverUrl;
3041
3219
  this.connection = new ClientConnection({
3042
3220
  serverUrl,
3221
+ clientId,
3043
3222
  getAccessToken: () => ensureFreshAccessToken()
3044
3223
  });
3045
3224
  registerBuiltinHandlers();
@@ -3655,7 +3834,7 @@ async function onboardCheck(args) {
3655
3834
  key: "connect",
3656
3835
  label: "Signed in",
3657
3836
  status: "missing_required",
3658
- hint: "Run `first-tree-hub connect <server-url>` first"
3837
+ hint: "Run `first-tree-hub client connect <server-url>` first"
3659
3838
  });
3660
3839
  try {
3661
3840
  const serverUrl = resolveServerUrl(args.server);
@@ -3713,11 +3892,17 @@ async function onboardCheck(args) {
3713
3892
  status: "missing_required",
3714
3893
  hint: "Provide via --type"
3715
3894
  });
3716
- if (args.type && args.type !== "human" && !args.clientId) items.push({
3895
+ if (args.type && args.type !== "human") if (args.clientId) items.push({
3717
3896
  key: "client",
3718
3897
  label: "Target client",
3719
- status: "missing_required",
3720
- hint: "Non-human agents must pin a client via --client-id <id>"
3898
+ status: "ok",
3899
+ value: args.clientId
3900
+ });
3901
+ else items.push({
3902
+ key: "client",
3903
+ label: "Target client",
3904
+ status: "ok",
3905
+ value: "(unbound — claimed on first WS connect)"
3721
3906
  });
3722
3907
  return items;
3723
3908
  }
@@ -3788,7 +3973,7 @@ async function onboardCreate(args) {
3788
3973
  }
3789
3974
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
3790
3975
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
3791
- const { bindFeishuBot } = await import("./feishu-BZ8pnMrQ.mjs").then((n) => n.r);
3976
+ const { bindFeishuBot } = await import("./feishu-CJ08ntOD.mjs").then((n) => n.r);
3792
3977
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
3793
3978
  if (!targetAgentUuid) process.stderr.write(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
3794
3979
  else {
@@ -3929,7 +4114,7 @@ function setNestedByDot(obj, dotPath, value) {
3929
4114
  if (lastKey !== void 0) current[lastKey] = value;
3930
4115
  }
3931
4116
  //#endregion
3932
- //#region ../server/dist/app-C7MR8S4r.mjs
4117
+ //#region ../server/dist/app-nJ9jSQtv.mjs
3933
4118
  var __defProp = Object.defineProperty;
3934
4119
  var __exportAll = (all, no_symbols) => {
3935
4120
  let target = {};
@@ -4852,18 +5037,20 @@ function defaultVisibility(type) {
4852
5037
  /**
4853
5038
  * Resolve + validate the client that will own the new agent.
4854
5039
  *
4855
- * Rule (unified-user-token M1):
4856
- * - Non-human agents MUST pin a client at creation time. The pinned client
4857
- * must belong to the manager's user (Rule R-RUN upstream).
4858
- * - Human agents represent the member themselves and have no runtime, so a
4859
- * missing `clientId` is allowed and the column stays NULL.
5040
+ * Rule (unified-user-token, post-first-bind relaxation):
5041
+ * - Human agents represent the member themselves and have no runtime; a
5042
+ * missing `clientId` is required and the column stays NULL.
5043
+ * - Non-human agents MAY omit `clientId` at creation; the row stays NULL
5044
+ * and is claimed on the first WS bind (see `api/agent/ws-client.ts`).
5045
+ * - When a non-human agent IS created with a `clientId`, the pinned client
5046
+ * must already be owned by the manager's user (Rule R-RUN).
4860
5047
  */
4861
5048
  async function resolveAgentClient(db, data) {
4862
5049
  if (data.type === "human") {
4863
5050
  if (data.clientId) throw new BadRequestError("Human agents cannot be pinned to a client");
4864
5051
  return null;
4865
5052
  }
4866
- if (!data.clientId) throw new BadRequestError("clientId is required — every non-human agent must be pinned to a client at creation time. Run `first-tree-hub connect` on the target machine first.");
5053
+ if (!data.clientId) return null;
4867
5054
  const [manager] = await db.select({ userId: members.userId }).from(members).where(eq(members.id, data.managerId)).limit(1);
4868
5055
  if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
4869
5056
  const [client] = await db.select({
@@ -4871,7 +5058,7 @@ async function resolveAgentClient(db, data) {
4871
5058
  userId: clients.userId
4872
5059
  }).from(clients).where(eq(clients.id, data.clientId)).limit(1);
4873
5060
  if (!client) throw new BadRequestError(`Client "${data.clientId}" not found`);
4874
- if (!client.userId) throw new BadRequestError(`Client "${data.clientId}" has not been claimed by a user yet. Have the operator run \`first-tree-hub connect\` on that machine before pinning an agent to it.`);
5061
+ if (!client.userId) throw new BadRequestError(`Client "${data.clientId}" has not been claimed by a user yet. Have the operator run \`first-tree-hub client connect\` on that machine before pinning an agent to it.`);
4875
5062
  if (client.userId !== manager.userId) throw new ForbiddenError(`Client "${data.clientId}" is not owned by the manager's user — pick a client belonging to that user.`);
4876
5063
  return client.id;
4877
5064
  }
@@ -4973,8 +5160,11 @@ async function listAgentsForMember(db, scope, limit, cursor, type) {
4973
5160
  };
4974
5161
  }
4975
5162
  async function updateAgent(db, uuid, data) {
4976
- if (data.clientId !== void 0) throw new BadRequestError("clientId is immutable in this milestone — delete and re-create the agent on the target client to move it");
4977
5163
  const agent = await getAgent(db, uuid);
5164
+ if (data.clientId !== void 0) {
5165
+ if (data.clientId === null) throw new BadRequestError("clientId cannot be cleared — once bound, an agent stays bound to its client");
5166
+ if (agent.clientId !== null && agent.clientId !== data.clientId) throw new BadRequestError("clientId is immutable once set — delete and re-create the agent on the target client to move it");
5167
+ }
4978
5168
  const updates = { updatedAt: /* @__PURE__ */ new Date() };
4979
5169
  if (data.type !== void 0) updates.type = data.type;
4980
5170
  if (data.displayName !== void 0) updates.displayName = data.displayName;
@@ -4991,6 +5181,14 @@ async function updateAgent(db, uuid, data) {
4991
5181
  if (manager.organizationId !== agent.organizationId) throw new BadRequestError("Manager must belong to the same organization as the agent");
4992
5182
  updates.managerId = data.managerId;
4993
5183
  }
5184
+ if (data.clientId !== void 0 && data.clientId !== null && agent.clientId === null) {
5185
+ const resolvedClientId = await resolveAgentClient(db, {
5186
+ clientId: data.clientId,
5187
+ managerId: updates.managerId ?? agent.managerId,
5188
+ type: agent.type
5189
+ });
5190
+ if (resolvedClientId !== null) updates.clientId = resolvedClientId;
5191
+ }
4994
5192
  const [updated] = await db.update(agents).set(updates).where(eq(agents.uuid, agent.uuid)).returning();
4995
5193
  if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
4996
5194
  return updated;
@@ -5354,14 +5552,20 @@ async function unbindAgent(db, agentId) {
5354
5552
  ...runtimeFieldsReset(now)
5355
5553
  }).where(eq(agentPresence.agentId, agentId));
5356
5554
  }
5357
- /** Set runtime state directly from client-reported value. */
5358
- async function setRuntimeState(db, agentId, runtimeState) {
5555
+ /** Set runtime state directly from client-reported value.
5556
+ *
5557
+ * When an org-scoped notifier is provided, emit a PG NOTIFY on the
5558
+ * `runtime_state_changes` channel so the pulse aggregator (and any future
5559
+ * admin-side consumers) can observe the transition. Fire-and-forget to match
5560
+ * notifier semantics elsewhere in this module. */
5561
+ async function setRuntimeState(db, agentId, runtimeState, options) {
5359
5562
  const now = /* @__PURE__ */ new Date();
5360
5563
  await db.update(agentPresence).set({
5361
5564
  runtimeState,
5362
5565
  runtimeUpdatedAt: now,
5363
5566
  lastSeenAt: now
5364
5567
  }).where(eq(agentPresence.agentId, agentId));
5568
+ if (options?.notifier && options.organizationId) options.notifier.notifyRuntimeStateChange(agentId, runtimeState, options.organizationId).catch(() => {});
5365
5569
  }
5366
5570
  /** Touch agent last_seen_at on heartbeat (per-agent liveness). */
5367
5571
  async function touchAgent(db, agentId) {
@@ -5487,9 +5691,7 @@ async function getClient(db, clientId) {
5487
5691
  return row ?? null;
5488
5692
  }
5489
5693
  async function listClients(db, userId) {
5490
- const conditions = [eq(clients.status, "connected")];
5491
- if (userId) conditions.push(eq(clients.userId, userId));
5492
- const rows = await db.select().from(clients).where(conditions.length === 1 ? conditions[0] : and(...conditions));
5694
+ const rows = await db.select().from(clients).where(eq(clients.userId, userId));
5493
5695
  const counts = await db.select({
5494
5696
  clientId: agents.clientId,
5495
5697
  count: sql`count(*)::int`
@@ -5754,13 +5956,16 @@ async function listMessages(db, chatId, limit, cursor) {
5754
5956
  const INBOX_CHANNEL = "inbox_notifications";
5755
5957
  const CONFIG_CHANNEL = "config_changes";
5756
5958
  const SESSION_STATE_CHANNEL = "session_state_changes";
5959
+ const RUNTIME_STATE_CHANNEL = "runtime_state_changes";
5757
5960
  function createNotifier(listenClient) {
5758
5961
  const subscriptions = /* @__PURE__ */ new Map();
5759
5962
  const configChangeHandlers = [];
5760
5963
  const sessionStateChangeHandlers = [];
5964
+ const runtimeStateChangeHandlers = [];
5761
5965
  let unlistenInboxFn = null;
5762
5966
  let unlistenConfigFn = null;
5763
5967
  let unlistenSessionStateFn = null;
5968
+ let unlistenRuntimeStateFn = null;
5764
5969
  function handleNotification(payload) {
5765
5970
  const sepIdx = payload.indexOf(":");
5766
5971
  if (sepIdx === -1) return;
@@ -5801,9 +6006,14 @@ function createNotifier(listenClient) {
5801
6006
  await listenClient`SELECT pg_notify(${CONFIG_CHANNEL}, ${configType})`;
5802
6007
  } catch {}
5803
6008
  },
5804
- async notifySessionStateChange(agentId, chatId, state) {
6009
+ async notifySessionStateChange(agentId, chatId, state, organizationId) {
6010
+ try {
6011
+ await listenClient`SELECT pg_notify(${SESSION_STATE_CHANNEL}, ${`${agentId}:${chatId}:${state}:${organizationId}`})`;
6012
+ } catch {}
6013
+ },
6014
+ async notifyRuntimeStateChange(agentId, state, organizationId) {
5805
6015
  try {
5806
- await listenClient`SELECT pg_notify(${SESSION_STATE_CHANNEL}, ${`${agentId}:${chatId}:${state}`})`;
6016
+ await listenClient`SELECT pg_notify(${RUNTIME_STATE_CHANNEL}, ${`${agentId}:${state}:${organizationId}`})`;
5807
6017
  } catch {}
5808
6018
  },
5809
6019
  onConfigChange(handler) {
@@ -5812,6 +6022,9 @@ function createNotifier(listenClient) {
5812
6022
  onSessionStateChange(handler) {
5813
6023
  sessionStateChangeHandlers.push(handler);
5814
6024
  },
6025
+ onRuntimeStateChange(handler) {
6026
+ runtimeStateChangeHandlers.push(handler);
6027
+ },
5815
6028
  async start() {
5816
6029
  unlistenInboxFn = (await listenClient.listen(INBOX_CHANNEL, (payload) => {
5817
6030
  if (payload) handleNotification(payload);
@@ -5823,14 +6036,33 @@ function createNotifier(listenClient) {
5823
6036
  if (payload) {
5824
6037
  const firstSep = payload.indexOf(":");
5825
6038
  const secondSep = payload.indexOf(":", firstSep + 1);
5826
- if (firstSep > 0 && secondSep > firstSep) {
6039
+ const thirdSep = payload.indexOf(":", secondSep + 1);
6040
+ if (firstSep > 0 && secondSep > firstSep && thirdSep > secondSep) {
5827
6041
  const agentId = payload.slice(0, firstSep);
5828
6042
  const chatId = payload.slice(firstSep + 1, secondSep);
5829
- const state = payload.slice(secondSep + 1);
6043
+ const state = payload.slice(secondSep + 1, thirdSep);
6044
+ const organizationId = payload.slice(thirdSep + 1);
5830
6045
  for (const handler of sessionStateChangeHandlers) handler({
5831
6046
  agentId,
5832
6047
  chatId,
5833
- state
6048
+ state,
6049
+ organizationId
6050
+ });
6051
+ }
6052
+ }
6053
+ })).unlisten;
6054
+ unlistenRuntimeStateFn = (await listenClient.listen(RUNTIME_STATE_CHANNEL, (payload) => {
6055
+ if (payload) {
6056
+ const firstSep = payload.indexOf(":");
6057
+ const secondSep = payload.indexOf(":", firstSep + 1);
6058
+ if (firstSep > 0 && secondSep > firstSep) {
6059
+ const agentId = payload.slice(0, firstSep);
6060
+ const state = payload.slice(firstSep + 1, secondSep);
6061
+ const organizationId = payload.slice(secondSep + 1);
6062
+ for (const handler of runtimeStateChangeHandlers) handler({
6063
+ agentId,
6064
+ state,
6065
+ organizationId
5834
6066
  });
5835
6067
  }
5836
6068
  }
@@ -5849,6 +6081,10 @@ function createNotifier(listenClient) {
5849
6081
  await unlistenSessionStateFn();
5850
6082
  unlistenSessionStateFn = null;
5851
6083
  }
6084
+ if (unlistenRuntimeStateFn) {
6085
+ await unlistenRuntimeStateFn();
6086
+ unlistenRuntimeStateFn = null;
6087
+ }
5852
6088
  }
5853
6089
  };
5854
6090
  }
@@ -5982,7 +6218,7 @@ async function adminAgentRoutes(app) {
5982
6218
  };
5983
6219
  if (health === "disconnected") return reply.status(200).send({
5984
6220
  status: "offline",
5985
- message: "Agent is not connected. Start the client with: first-tree-hub connect <server-url>",
6221
+ message: "Agent is not connected. Start the client with: first-tree-hub client connect <server-url>",
5986
6222
  connection
5987
6223
  });
5988
6224
  if (health === "stale") return reply.status(200).send({
@@ -6280,8 +6516,8 @@ const agentChatSessions = pgTable("agent_chat_sessions", {
6280
6516
  state: text("state").notNull(),
6281
6517
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
6282
6518
  }, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
6283
- /** Upsert a session state and update materialized aggregates on agent_presence. */
6284
- async function upsertSessionState(db, agentId, chatId, state, notifier) {
6519
+ /** Upsert a session state, refresh materialized aggregates on agent_presence, and emit org-scoped NOTIFY. */
6520
+ async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier) {
6285
6521
  const now = /* @__PURE__ */ new Date();
6286
6522
  await db.transaction(async (tx) => {
6287
6523
  await tx.insert(agentChatSessions).values({
@@ -6308,7 +6544,7 @@ async function upsertSessionState(db, agentId, chatId, state, notifier) {
6308
6544
  lastSeenAt: now
6309
6545
  }).where(eq(agentPresence.agentId, agentId));
6310
6546
  });
6311
- if (notifier) notifier.notifySessionStateChange(agentId, chatId, state).catch(() => {});
6547
+ if (notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
6312
6548
  }
6313
6549
  async function resetActivity(db, agentId) {
6314
6550
  const now = /* @__PURE__ */ new Date();
@@ -6357,7 +6593,8 @@ async function listAgentsWithRuntime(db, scope) {
6357
6593
  runtimeState: agentPresence.runtimeState,
6358
6594
  activeSessions: agentPresence.activeSessions,
6359
6595
  totalSessions: agentPresence.totalSessions,
6360
- runtimeUpdatedAt: agentPresence.runtimeUpdatedAt
6596
+ runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
6597
+ type: agents.type
6361
6598
  }).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope)));
6362
6599
  }
6363
6600
  /**
@@ -6446,7 +6683,8 @@ async function adminActivityRoutes(app) {
6446
6683
  runtimeState: a.runtimeState,
6447
6684
  activeSessions: a.activeSessions,
6448
6685
  totalSessions: a.totalSessions,
6449
- runtimeUpdatedAt: a.runtimeUpdatedAt?.toISOString() ?? null
6686
+ runtimeUpdatedAt: a.runtimeUpdatedAt?.toISOString() ?? null,
6687
+ type: "type" in a ? a.type : null
6450
6688
  }))
6451
6689
  };
6452
6690
  });
@@ -6475,6 +6713,16 @@ const notifications = pgTable("notifications", {
6475
6713
  index("idx_notifications_agent").on(table.agentId),
6476
6714
  index("idx_notifications_org_read").on(table.organizationId, table.read)
6477
6715
  ]);
6716
+ let registered = null;
6717
+ function registerAdminBroadcaster(fn) {
6718
+ registered = fn;
6719
+ }
6720
+ function broadcastToAdmins(payload) {
6721
+ if (!registered) return;
6722
+ try {
6723
+ registered(payload);
6724
+ } catch {}
6725
+ }
6478
6726
  /** Runtime system configuration (key-value JSONB). Dynamically modifiable via Admin API; controls inbox timeout, retry count, etc. */
6479
6727
  const systemConfigs = pgTable("system_configs", {
6480
6728
  key: text("key").primaryKey(),
@@ -6507,15 +6755,6 @@ async function updateConfigs(db, updates) {
6507
6755
  });
6508
6756
  return getAllConfigs(db);
6509
6757
  }
6510
- /**
6511
- * Push channels: Admin WS, Webhook, Feishu.
6512
- * Set at app startup via `setAdminWsBroadcast` and reloaded from system_configs.
6513
- */
6514
- let adminWsBroadcast = null;
6515
- /** Register the admin WS broadcast function (called once at app startup). */
6516
- function setAdminWsBroadcast(fn) {
6517
- adminWsBroadcast = fn;
6518
- }
6519
6758
  /** Create a notification, persist it, and fire-and-forget push to all channels. */
6520
6759
  async function createNotification(db, data) {
6521
6760
  const id = uuidv7();
@@ -6599,13 +6838,11 @@ async function notifyAgentEvent(db, agentId, type, severity, message, chatId) {
6599
6838
  } catch {}
6600
6839
  }
6601
6840
  function pushToAdminWs(notification) {
6602
- if (!adminWsBroadcast) return;
6603
- try {
6604
- adminWsBroadcast({
6605
- type: "notification",
6606
- data: notification
6607
- });
6608
- } catch {}
6841
+ broadcastToAdmins({
6842
+ type: "notification",
6843
+ organizationId: notification.organizationId,
6844
+ data: notification
6845
+ });
6609
6846
  }
6610
6847
  async function pushToWebhook(db, notification) {
6611
6848
  const webhookUrl = await getConfig(db, "notification_webhook_url");
@@ -6807,46 +7044,97 @@ async function filterSessionsByParticipant(db, sessions, participantAgentId) {
6807
7044
  return sessions.filter((s) => allowedChatIds.has(s.chatId));
6808
7045
  }
6809
7046
  /**
6810
- * Session outputsaggregated text output from agent sessions.
6811
- * One row per (agent, chat) session. Content is appended as the agent works.
6812
- * Cleaned up when the session is evicted.
7047
+ * Session eventsstructured event stream per (agent, chat) session.
7048
+ * `kind` is 'tool_call' | 'error'; the payload shape is enforced by the
7049
+ * service layer via Zod (no FK / CHECK on this table per project rule).
7050
+ *
7051
+ * `seq` is monotonic per (agent_id, chat_id). The single-writer invariant
7052
+ * in the client-side session-manager guarantees ordering; the service wraps
7053
+ * the insert in a MAX(seq)+1 retry loop to recover from restart-overlap
7054
+ * windows.
7055
+ *
7056
+ * Cleanup: rows are dropped when the session is evicted or terminated —
7057
+ * see sessionEventService.clearEvents.
6813
7058
  */
6814
- const sessionOutputs = pgTable("session_outputs", {
7059
+ const sessionEvents = pgTable("session_events", {
6815
7060
  id: text("id").primaryKey(),
6816
7061
  agentId: text("agent_id").notNull(),
6817
7062
  chatId: text("chat_id").notNull(),
6818
- content: text("content").notNull().default(""),
6819
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
6820
- }, (table) => [unique("uq_session_outputs_agent_chat").on(table.agentId, table.chatId), index("idx_session_outputs_agent_chat").on(table.agentId, table.chatId)]);
6821
- /** Append text content to a session's output buffer. Upserts atomically via ON CONFLICT. */
6822
- async function appendOutput(db, agentId, chatId, content) {
6823
- const now = /* @__PURE__ */ new Date();
6824
- await db.insert(sessionOutputs).values({
6825
- id: uuidv7(),
6826
- agentId,
6827
- chatId,
6828
- content,
6829
- updatedAt: now
6830
- }).onConflictDoUpdate({
6831
- target: [sessionOutputs.agentId, sessionOutputs.chatId],
6832
- set: {
6833
- content: sql`${sessionOutputs.content} || ${content}`,
6834
- updatedAt: now
6835
- }
6836
- });
7063
+ seq: integer("seq").notNull(),
7064
+ kind: text("kind").notNull(),
7065
+ payload: jsonb("payload").notNull(),
7066
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
7067
+ }, (table) => [uniqueIndex("uq_session_events_chat_seq").on(table.agentId, table.chatId, table.seq), index("idx_session_events_chat_created").on(table.agentId, table.chatId, table.createdAt.desc())]);
7068
+ const DEFAULT_LIMIT = 200;
7069
+ const MAX_LIMIT = 1e3;
7070
+ const MAX_SEQ_RETRIES = 3;
7071
+ function rowToEvent(row) {
7072
+ return {
7073
+ id: row.id,
7074
+ agentId: row.agentId,
7075
+ chatId: row.chatId,
7076
+ seq: row.seq,
7077
+ kind: row.kind,
7078
+ payload: row.payload,
7079
+ createdAt: row.createdAt.toISOString()
7080
+ };
6837
7081
  }
6838
- /** Get session output for a specific (agent, chat) pair. Returns null if no output. */
6839
- async function getOutput(db, agentId, chatId) {
6840
- const [row] = await db.select({
6841
- content: sessionOutputs.content,
6842
- updatedAt: sessionOutputs.updatedAt
6843
- }).from(sessionOutputs).where(and(eq(sessionOutputs.agentId, agentId), eq(sessionOutputs.chatId, chatId))).limit(1);
6844
- if (!row) return null;
7082
+ /** Append one event; throws after MAX_SEQ_RETRIES on persistent seq contention. */
7083
+ async function appendEvent(db, agentId, chatId, event) {
7084
+ const validated = sessionEventSchema$1.parse(event);
7085
+ for (let attempt = 0; attempt < MAX_SEQ_RETRIES; attempt++) {
7086
+ const id = uuidv7();
7087
+ const payloadJson = JSON.stringify(validated.payload);
7088
+ const row = (await db.execute(sql`
7089
+ INSERT INTO session_events (id, agent_id, chat_id, seq, kind, payload)
7090
+ SELECT ${id}, ${agentId}, ${chatId},
7091
+ COALESCE(MAX(seq), 0) + 1, ${validated.kind}, ${payloadJson}::jsonb
7092
+ FROM session_events
7093
+ WHERE agent_id = ${agentId} AND chat_id = ${chatId}
7094
+ ON CONFLICT (agent_id, chat_id, seq) DO NOTHING
7095
+ RETURNING id, agent_id, chat_id, seq, kind, payload, created_at
7096
+ `))[0];
7097
+ if (row) return rowToEvent({
7098
+ id: row.id,
7099
+ agentId: row.agent_id,
7100
+ chatId: row.chat_id,
7101
+ seq: row.seq,
7102
+ kind: row.kind,
7103
+ payload: row.payload,
7104
+ createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at)
7105
+ });
7106
+ }
7107
+ throw new Error(`session_events seq contention on ${agentId}/${chatId}`);
7108
+ }
7109
+ /**
7110
+ * List events for a session in `seq asc` order with cursor pagination.
7111
+ * `cursor` is the last seen `seq`; pass it as-is on the next page.
7112
+ */
7113
+ async function listEvents(db, agentId, chatId, options) {
7114
+ const limit = Math.min(Math.max(options?.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
7115
+ const conditions = [eq(sessionEvents.agentId, agentId), eq(sessionEvents.chatId, chatId)];
7116
+ if (options?.cursor !== void 0) conditions.push(gt(sessionEvents.seq, options.cursor));
7117
+ const rows = await db.select({
7118
+ id: sessionEvents.id,
7119
+ agentId: sessionEvents.agentId,
7120
+ chatId: sessionEvents.chatId,
7121
+ seq: sessionEvents.seq,
7122
+ kind: sessionEvents.kind,
7123
+ payload: sessionEvents.payload,
7124
+ createdAt: sessionEvents.createdAt
7125
+ }).from(sessionEvents).where(and(...conditions)).orderBy(asc(sessionEvents.seq)).limit(limit + 1);
7126
+ const hasMore = rows.length > limit;
7127
+ const items = (hasMore ? rows.slice(0, limit) : rows).map(rowToEvent);
7128
+ const last = items[items.length - 1];
6845
7129
  return {
6846
- content: row.content,
6847
- updatedAt: row.updatedAt.toISOString()
7130
+ items,
7131
+ nextCursor: hasMore && last ? last.seq : null
6848
7132
  };
6849
7133
  }
7134
+ /** Delete all events for a session — called on eviction / termination. */
7135
+ async function clearEvents(db, agentId, chatId) {
7136
+ await db.delete(sessionEvents).where(and(eq(sessionEvents.agentId, agentId), eq(sessionEvents.chatId, chatId)));
7137
+ }
6850
7138
  const sessionFilterSchema = z.object({
6851
7139
  state: z.enum([
6852
7140
  "active",
@@ -6892,16 +7180,22 @@ async function adminSessionRoutes(app) {
6892
7180
  });
6893
7181
  /** GET /admin/sessions/agents/:agentId/:chatId — single session detail */
6894
7182
  app.get("/agents/:agentId/:chatId", async (request) => {
6895
- await assertAgentVisible(app.db, memberScope(request), request.params.agentId);
7183
+ const scope = memberScope(request);
7184
+ await assertAgentVisible(app.db, scope, request.params.agentId);
7185
+ await assertChatAccess(app.db, scope, request.params.chatId);
6896
7186
  return getSession(app.db, request.params.agentId, request.params.chatId);
6897
7187
  });
6898
- /** GET /admin/sessions/agents/:agentId/:chatId/output — session output text */
6899
- app.get("/agents/:agentId/:chatId/output", async (request) => {
6900
- await assertAgentVisible(app.db, memberScope(request), request.params.agentId);
6901
- return await getOutput(app.db, request.params.agentId, request.params.chatId) ?? {
6902
- content: "",
6903
- updatedAt: null
6904
- };
7188
+ /** GET /admin/sessions/agents/:agentId/:chatId/events — session event stream, paged by `seq` */
7189
+ app.get("/agents/:agentId/:chatId/events", async (request) => {
7190
+ const scope = memberScope(request);
7191
+ await assertAgentVisible(app.db, scope, request.params.agentId);
7192
+ await assertChatAccess(app.db, scope, request.params.chatId);
7193
+ const limit = request.query.limit !== void 0 ? Number.parseInt(request.query.limit, 10) : void 0;
7194
+ const cursor = request.query.cursor !== void 0 ? Number.parseInt(request.query.cursor, 10) : void 0;
7195
+ return listEvents(app.db, request.params.agentId, request.params.chatId, {
7196
+ limit: Number.isFinite(limit) ? limit : void 0,
7197
+ cursor: Number.isFinite(cursor) ? cursor : void 0
7198
+ });
6905
7199
  });
6906
7200
  /** POST /admin/sessions/agents/:agentId/:chatId/suspend — suspend a session */
6907
7201
  app.post("/agents/:agentId/:chatId/suspend", async (request, reply) => {
@@ -7508,30 +7802,40 @@ async function adminTaskRoutes(app) {
7508
7802
  return getTaskHealth(app.db, request.params.taskId);
7509
7803
  });
7510
7804
  }
7511
- /**
7512
- * Admin WebSocket: real-time push channel for Dashboard, scoped by organization.
7513
- *
7514
- * Protocol:
7515
- * 1. Client connects with JWT token via query param `?token=<jwt>`
7516
- * 2. Server validates JWT, extracts organizationId, and registers the connection
7517
- * 3. Server pushes notifications and session state changes filtered by org
7518
- * 4. No client→server messages expected (read-only channel)
7519
- */
7805
+ async function loadVisibleAgentIds(db, organizationId, memberId) {
7806
+ const rows = await db.select({ id: agents.uuid }).from(agents).where(and(eq(agents.organizationId, organizationId), ne(agents.status, AGENT_STATUSES.DELETED), or(eq(agents.visibility, AGENT_VISIBILITY.ORGANIZATION), eq(agents.managerId, memberId))));
7807
+ return new Set(rows.map((r) => r.id));
7808
+ }
7809
+ function filterPulseAgents(agentsMap, visible) {
7810
+ const out = {};
7811
+ for (const [agentId, buckets] of Object.entries(agentsMap)) if (visible.has(agentId)) out[agentId] = buckets;
7812
+ return out;
7813
+ }
7520
7814
  function adminWsRoutes(notifier, jwtSecret) {
7521
7815
  const adminSockets = /* @__PURE__ */ new Map();
7522
7816
  const secret = new TextEncoder().encode(jwtSecret);
7523
- setAdminWsBroadcast((payload) => {
7817
+ function broadcastOrgScoped(payload) {
7524
7818
  const orgId = payload.organizationId;
7525
- const data = JSON.stringify(payload);
7526
- for (const [ws, meta] of adminSockets) if (ws.readyState === 1 && (!orgId || meta.organizationId === orgId)) ws.send(data);
7527
- });
7819
+ if (typeof orgId !== "string" || orgId.length === 0) return;
7820
+ const isPulseTick = payload.type === "pulse:tick" && typeof payload.agents === "object" && payload.agents !== null;
7821
+ const sharedData = isPulseTick ? null : JSON.stringify(payload);
7822
+ for (const [ws, meta] of adminSockets) {
7823
+ if (ws.readyState !== 1 || meta.organizationId !== orgId) continue;
7824
+ if (isPulseTick) {
7825
+ const filtered = filterPulseAgents(payload.agents, meta.visibleAgentIds);
7826
+ ws.send(JSON.stringify({
7827
+ ...payload,
7828
+ agents: filtered
7829
+ }));
7830
+ } else ws.send(sharedData);
7831
+ }
7832
+ }
7833
+ registerAdminBroadcaster(broadcastOrgScoped);
7528
7834
  notifier.onSessionStateChange((payload) => {
7529
- const data = JSON.stringify({
7835
+ broadcastOrgScoped({
7530
7836
  type: "session:state",
7531
7837
  ...payload
7532
7838
  });
7533
- const orgId = payload.organizationId;
7534
- for (const [ws, meta] of adminSockets) if (ws.readyState === 1 && (!orgId || meta.organizationId === orgId)) ws.send(data);
7535
7839
  });
7536
7840
  return async (app) => {
7537
7841
  app.get("/admin", { websocket: true }, async (socket, request) => {
@@ -7545,9 +7849,10 @@ function adminWsRoutes(notifier, jwtSecret) {
7545
7849
  return;
7546
7850
  }
7547
7851
  let organizationId;
7852
+ let memberId;
7548
7853
  try {
7549
7854
  const { payload } = await jwtVerify(token, secret);
7550
- if (payload.type !== "access" || !payload.sub || !payload.organizationId) {
7855
+ if (payload.type !== "access" || !payload.sub || typeof payload.organizationId !== "string" || typeof payload.memberId !== "string") {
7551
7856
  socket.send(JSON.stringify({
7552
7857
  type: "error",
7553
7858
  message: "Invalid token type"
@@ -7556,6 +7861,7 @@ function adminWsRoutes(notifier, jwtSecret) {
7556
7861
  return;
7557
7862
  }
7558
7863
  organizationId = payload.organizationId;
7864
+ memberId = payload.memberId;
7559
7865
  } catch {
7560
7866
  socket.send(JSON.stringify({
7561
7867
  type: "error",
@@ -7564,7 +7870,12 @@ function adminWsRoutes(notifier, jwtSecret) {
7564
7870
  socket.close(4001, "Auth failed");
7565
7871
  return;
7566
7872
  }
7567
- adminSockets.set(socket, { organizationId });
7873
+ const visibleAgentIds = await loadVisibleAgentIds(app.db, organizationId, memberId);
7874
+ adminSockets.set(socket, {
7875
+ organizationId,
7876
+ memberId,
7877
+ visibleAgentIds
7878
+ });
7568
7879
  socket.send(JSON.stringify({ type: "admin:connected" }));
7569
7880
  socket.on("close", () => {
7570
7881
  adminSockets.delete(socket);
@@ -8048,7 +8359,7 @@ const wsMessageSchema = z.object({
8048
8359
  * Failure ⇒ server sends `auth:rejected` and closes (code 4401).
8049
8360
  * 2. `client:register` — bind the client_id to the authenticated user.
8050
8361
  * 3. `agent:bind` — run Rule R-RUN (no token); populate presence.
8051
- * 4. `session:state` / `runtime:state` / `session:output` / `heartbeat`.
8362
+ * 4. `session:state` / `runtime:state` / `session:event` / `session:completion` / `heartbeat`.
8052
8363
  * 5. `agent:unbind` — stop multiplexing for a specific agent.
8053
8364
  *
8054
8365
  * When the JWT is about to expire the server sends `auth:expired` so the
@@ -8083,6 +8394,15 @@ function clientWsRoutes(notifier, instanceId) {
8083
8394
  let clientId = null;
8084
8395
  let authExpiryTimer = null;
8085
8396
  const boundAgents = /* @__PURE__ */ new Map();
8397
+ const sessionOpQueues = /* @__PURE__ */ new Map();
8398
+ function chainSessionOp(agentId, chatId, op) {
8399
+ const key = `${agentId}:${chatId}`;
8400
+ const next = (sessionOpQueues.get(key) ?? Promise.resolve()).then(op, op);
8401
+ sessionOpQueues.set(key, next.finally(() => {
8402
+ if (sessionOpQueues.get(key) === next) sessionOpQueues.delete(key);
8403
+ }));
8404
+ return next;
8405
+ }
8086
8406
  const authTimeout = setTimeout(() => {
8087
8407
  if (!session) {
8088
8408
  try {
@@ -8226,8 +8546,9 @@ function clientWsRoutes(notifier, instanceId) {
8226
8546
  inboxId: agents.inboxId,
8227
8547
  status: agents.status,
8228
8548
  clientId: agents.clientId,
8229
- clientUserId: clients.userId
8230
- }).from(agents).leftJoin(clients, eq(agents.clientId, clients.id)).where(and(eq(agents.uuid, bindRequest.agentId))).limit(1);
8549
+ clientUserId: clients.userId,
8550
+ managerUserId: members.userId
8551
+ }).from(agents).leftJoin(clients, eq(agents.clientId, clients.id)).leftJoin(members, eq(agents.managerId, members.id)).where(and(eq(agents.uuid, bindRequest.agentId))).limit(1);
8231
8552
  if (!agent) {
8232
8553
  sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.UNKNOWN_AGENT);
8233
8554
  return;
@@ -8240,11 +8561,22 @@ function clientWsRoutes(notifier, instanceId) {
8240
8561
  sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.AGENT_SUSPENDED);
8241
8562
  return;
8242
8563
  }
8243
- if (!agent.clientId || agent.clientId !== clientId) {
8564
+ if (agent.clientId === null) {
8565
+ if (!agent.managerUserId || agent.managerUserId !== session.userId) {
8566
+ sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.NOT_OWNED);
8567
+ return;
8568
+ }
8569
+ if ((await app.db.update(agents).set({
8570
+ clientId,
8571
+ updatedAt: /* @__PURE__ */ new Date()
8572
+ }).where(and(eq(agents.uuid, agent.id), isNull(agents.clientId))).returning({ uuid: agents.uuid })).length === 0) {
8573
+ sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.WRONG_CLIENT);
8574
+ return;
8575
+ }
8576
+ } else if (agent.clientId !== clientId) {
8244
8577
  sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.WRONG_CLIENT);
8245
8578
  return;
8246
- }
8247
- if (!agent.clientUserId || agent.clientUserId !== session.userId) {
8579
+ } else if (!agent.clientUserId || agent.clientUserId !== session.userId) {
8248
8580
  sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.NOT_OWNED);
8249
8581
  return;
8250
8582
  }
@@ -8295,7 +8627,8 @@ function clientWsRoutes(notifier, instanceId) {
8295
8627
  return;
8296
8628
  }
8297
8629
  const payload = sessionStateMessageSchema.parse(msg);
8298
- await upsertSessionState(app.db, agentId, payload.chatId, payload.state, notifier);
8630
+ if (payload.state === "evicted") chainSessionOp(agentId, payload.chatId, () => clearEvents(app.db, agentId, payload.chatId).catch(() => {}));
8631
+ await upsertSessionState(app.db, agentId, payload.chatId, payload.state, session.organizationId, notifier);
8299
8632
  } else if (type === "runtime:state") {
8300
8633
  const agentId = parsed.data.agentId;
8301
8634
  if (!agentId || !boundAgents.has(agentId)) {
@@ -8306,10 +8639,33 @@ function clientWsRoutes(notifier, instanceId) {
8306
8639
  return;
8307
8640
  }
8308
8641
  const payload = runtimeStateMessageSchema.parse(msg);
8309
- await setRuntimeState(app.db, agentId, payload.runtimeState);
8642
+ await setRuntimeState(app.db, agentId, payload.runtimeState, {
8643
+ organizationId: session.organizationId,
8644
+ notifier
8645
+ });
8310
8646
  if (payload.runtimeState === "error" && shouldNotify(agentId, "agent_error")) notifyAgentEvent(app.db, agentId, "agent_error", "high", `Agent ${agentId} entered error state`).catch(() => {});
8311
8647
  else if (payload.runtimeState === "blocked" && shouldNotify(agentId, "agent_blocked")) notifyAgentEvent(app.db, agentId, "agent_blocked", "medium", `Agent ${agentId} is blocked`).catch(() => {});
8312
- } else if (type === "session:output") {
8648
+ } else if (type === "session:event") {
8649
+ const agentId = parsed.data.agentId;
8650
+ if (!agentId || !boundAgents.has(agentId)) {
8651
+ socket.send(JSON.stringify({
8652
+ type: "error",
8653
+ message: "Agent not bound"
8654
+ }));
8655
+ return;
8656
+ }
8657
+ const payload = sessionEventMessageSchema.parse(msg);
8658
+ chainSessionOp(agentId, payload.chatId, async () => {
8659
+ try {
8660
+ await appendEvent(app.db, agentId, payload.chatId, payload.event);
8661
+ } catch (err) {
8662
+ socket.send(JSON.stringify({
8663
+ type: "error",
8664
+ message: `Failed to persist session event: ${err instanceof Error ? err.message : String(err)}`
8665
+ }));
8666
+ }
8667
+ });
8668
+ } else if (type === "session:completion") {
8313
8669
  const agentId = parsed.data.agentId;
8314
8670
  if (!agentId || !boundAgents.has(agentId)) {
8315
8671
  socket.send(JSON.stringify({
@@ -8318,8 +8674,7 @@ function clientWsRoutes(notifier, instanceId) {
8318
8674
  }));
8319
8675
  return;
8320
8676
  }
8321
- const payload = sessionOutputMessageSchema.parse(msg);
8322
- appendOutput(app.db, agentId, payload.chatId, payload.content).catch(() => {});
8677
+ const payload = sessionCompletionMessageSchema.parse(msg);
8323
8678
  if (shouldNotify(agentId, `session_completed:${payload.chatId}`)) notifyAgentEvent(app.db, agentId, "session_completed", "low", `Agent ${agentId} completed a task`, payload.chatId).catch(() => {});
8324
8679
  } else if (type === "heartbeat") {
8325
8680
  if (clientId) {
@@ -8601,7 +8956,7 @@ async function meRoutes(app) {
8601
8956
  return {
8602
8957
  token,
8603
8958
  expiresIn,
8604
- command: `first-tree-hub connect ${`${request.headers["x-forwarded-proto"] ?? request.protocol}://${request.headers["x-forwarded-host"] ?? request.headers.host ?? request.hostname}`} --token ${token}`
8959
+ command: `first-tree-hub client connect ${`${request.headers["x-forwarded-proto"] ?? request.protocol}://${request.headers["x-forwarded-host"] ?? request.headers.host ?? request.hostname}`} --token ${token}`
8605
8960
  };
8606
8961
  });
8607
8962
  }
@@ -9231,7 +9586,7 @@ var schema_exports = /* @__PURE__ */ __exportAll({
9231
9586
  notifications: () => notifications,
9232
9587
  organizations: () => organizations,
9233
9588
  serverInstances: () => serverInstances,
9234
- sessionOutputs: () => sessionOutputs,
9589
+ sessionEvents: () => sessionEvents,
9235
9590
  systemConfigs: () => systemConfigs,
9236
9591
  taskChats: () => taskChats,
9237
9592
  tasks: () => tasks,
@@ -10319,6 +10674,90 @@ async function nackEntry(db, entryId) {
10319
10674
  WHERE id = ${entryId}
10320
10675
  `);
10321
10676
  }
10677
+ const DEFAULT_INTERVAL_MS = 5e3;
10678
+ const DEFAULT_BUCKET_COUNT = 32;
10679
+ function createPulseAggregator(options) {
10680
+ const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
10681
+ const bucketCount = options.bucketCount ?? DEFAULT_BUCKET_COUNT;
10682
+ const state = /* @__PURE__ */ new Map();
10683
+ let currentIdx = 0;
10684
+ let running = false;
10685
+ let intervalHandle = null;
10686
+ function emptyBucket() {
10687
+ return {
10688
+ workingCount: 0,
10689
+ errorMask: false
10690
+ };
10691
+ }
10692
+ function initBuckets() {
10693
+ return Array.from({ length: bucketCount }, emptyBucket);
10694
+ }
10695
+ function ensureAgent(organizationId, agentId) {
10696
+ let orgMap = state.get(organizationId);
10697
+ if (!orgMap) {
10698
+ orgMap = /* @__PURE__ */ new Map();
10699
+ state.set(organizationId, orgMap);
10700
+ }
10701
+ let buckets = orgMap.get(agentId);
10702
+ if (!buckets) {
10703
+ buckets = initBuckets();
10704
+ orgMap.set(agentId, buckets);
10705
+ }
10706
+ return buckets;
10707
+ }
10708
+ const ingest = (payload) => {
10709
+ if (!running) return;
10710
+ if (!payload.organizationId || !payload.agentId) return;
10711
+ const bucket = ensureAgent(payload.organizationId, payload.agentId)[currentIdx];
10712
+ if (!bucket) return;
10713
+ if (payload.state === "working") bucket.workingCount += 1;
10714
+ else if (payload.state === "error") bucket.errorMask = true;
10715
+ };
10716
+ function snapshotBuckets(buckets) {
10717
+ const out = [];
10718
+ for (let i = 0; i < bucketCount; i++) {
10719
+ const src = buckets[(currentIdx + 1 + i) % bucketCount] ?? emptyBucket();
10720
+ out.push({
10721
+ workingCount: src.workingCount,
10722
+ errorMask: src.errorMask
10723
+ });
10724
+ }
10725
+ return out;
10726
+ }
10727
+ function broadcastTick() {
10728
+ for (const [organizationId, orgMap] of state) {
10729
+ const agents = {};
10730
+ for (const [agentId, buckets] of orgMap) agents[agentId] = snapshotBuckets(buckets);
10731
+ options.broadcast({
10732
+ type: "pulse:tick",
10733
+ organizationId,
10734
+ agents
10735
+ });
10736
+ }
10737
+ }
10738
+ function advance() {
10739
+ broadcastTick();
10740
+ currentIdx = (currentIdx + 1) % bucketCount;
10741
+ for (const orgMap of state.values()) for (const buckets of orgMap.values()) buckets[currentIdx] = emptyBucket();
10742
+ }
10743
+ return {
10744
+ start() {
10745
+ if (running) return;
10746
+ running = true;
10747
+ options.notifier.onRuntimeStateChange(ingest);
10748
+ intervalHandle = setInterval(advance, intervalMs);
10749
+ },
10750
+ stop() {
10751
+ if (!running) return;
10752
+ running = false;
10753
+ if (intervalHandle) {
10754
+ clearInterval(intervalHandle);
10755
+ intervalHandle = null;
10756
+ }
10757
+ },
10758
+ ingest
10759
+ };
10760
+ }
10322
10761
  async function buildApp(config) {
10323
10762
  const app = Fastify({ logger: config.logger ?? true });
10324
10763
  const db = connectDatabase(config.database.url);
@@ -10408,7 +10847,7 @@ async function buildApp(config) {
10408
10847
  await api.register(async (adminApp) => {
10409
10848
  adminApp.addHook("onRequest", memberAuth);
10410
10849
  await adminApp.register(adminClientRoutes);
10411
- }, { prefix: "/admin/clients" });
10850
+ }, { prefix: "/clients" });
10412
10851
  await api.register(async (adminApp) => {
10413
10852
  adminApp.addHook("onRequest", memberAuth);
10414
10853
  await adminApp.register(adminActivityRoutes);
@@ -10477,6 +10916,10 @@ async function buildApp(config) {
10477
10916
  const contextTreeDir = join(DEFAULT_DATA_DIR$1, "context-tree");
10478
10917
  const kaelRuntime = config.kael?.endpoint ? createKaelRuntime(db, config.secrets.encryptionKey, config.kael.endpoint, config.kael.apiKey, config.kael.hubPublicUrl, app.log, contextTreeDir) : void 0;
10479
10918
  const backgroundTasks = createBackgroundTasks(app, config.instanceId, adapterManager, kaelRuntime);
10919
+ const pulseAggregator = createPulseAggregator({
10920
+ notifier,
10921
+ broadcast: broadcastToAdmins
10922
+ });
10480
10923
  notifier.onConfigChange((configType) => {
10481
10924
  if (configType === "adapter_configs") {
10482
10925
  adapterManager.reload().catch((err) => app.log.error(err, "Adapter hot-reload failed (PG NOTIFY)"));
@@ -10487,10 +10930,12 @@ async function buildApp(config) {
10487
10930
  await ensureDefaultOrganization(db);
10488
10931
  await notifier.start();
10489
10932
  backgroundTasks.start();
10933
+ pulseAggregator.start();
10490
10934
  await adapterManager.reload();
10491
10935
  await kaelRuntime?.reload();
10492
10936
  });
10493
10937
  app.addHook("onClose", async () => {
10938
+ pulseAggregator.stop();
10494
10939
  backgroundTasks.stop();
10495
10940
  adapterManager.shutdown();
10496
10941
  kaelRuntime?.shutdown();