@agent-team-foundation/first-tree-hub 0.7.1 → 0.8.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,9 +1,9 @@
1
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";
2
+ import { $ as updateAgentRuntimeConfigSchema, A as createMemberSchema, B as notificationQuerySchema, C as agentTypeSchema$1, D as createAdapterMappingSchema, E as createAdapterConfigSchema, F as inboxPollQuerySchema, G as sendMessageSchema, H as refreshTokenSchema, I as isRedactedEnvValue, J as sessionEventMessageSchema, K as sendToAgentSchema, L as linkTaskChatSchema, M as createTaskSchema, N as delegateFeishuUserSchema, O as createAgentSchema, P as dryRunAgentRuntimeConfigSchema, Q as updateAdapterConfigSchema, R as loginSchema, S as agentRuntimeConfigPayloadSchema$1, T as connectTokenExchangeSchema, U as runtimeStateMessageSchema, V as paginationQuerySchema, W as selfServiceFeishuBotSchema, X as sessionStateMessageSchema, Y as sessionEventSchema$1, Z as taskListQuerySchema, _ as addParticipantSchema, a as AGENT_SELECTOR_HEADER$1, at as wsAuthFrameSchema, b as agentBindRequestSchema, c as AGENT_TYPES, d as SYSTEM_CONFIG_DEFAULTS, et as updateAgentSchema, 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 updateTaskStatusSchema, j as createOrganizationSchema, k as createChatSchema, l as AGENT_VISIBILITY, m as TASK_STATUSES, nt as updateOrganizationSchema, o as AGENT_SOURCES, p as TASK_HEALTH_SIGNALS, q as sessionCompletionMessageSchema, rt as updateSystemConfigSchema, s as AGENT_STATUSES, tt as updateMemberSchema, u as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, v as adminCreateTaskSchema, w as clientRegisterSchema, x as agentPinnedMessageSchema$1, y as adminUpdateTaskSchema, z as messageSourceSchema$1 } from "./feishu-D9JkMZnU.mjs";
3
3
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
4
4
  import { dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { ZodError, z } from "zod";
6
- import "yaml";
6
+ import { stringify } from "yaml";
7
7
  import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
8
8
  import { homedir, hostname, platform, userInfo } from "node:os";
9
9
  import { EventEmitter } from "node:events";
@@ -198,6 +198,19 @@ z.object({
198
198
  branch: z.string().nullable()
199
199
  });
200
200
  /**
201
+ * Server → client WebSocket frame announcing that an agent has just been
202
+ * pinned to the connected client (either created with `clientId` or bound via
203
+ * PATCH NULL → ID). The client can auto-register a local config from this so
204
+ * the operator doesn't have to run `first-tree-hub agent add` manually.
205
+ */
206
+ const agentPinnedMessageSchema = z.object({
207
+ type: z.literal("agent:pinned"),
208
+ agentId: z.string(),
209
+ name: z.string().nullable(),
210
+ displayName: z.string().nullable(),
211
+ agentType: agentTypeSchema
212
+ });
213
+ /**
201
214
  * Agent runtime configuration — M1 (Claude Code only).
202
215
  *
203
216
  * Defines the 5 user-tunable field groups that the Hub centrally manages
@@ -1165,6 +1178,11 @@ var ClientConnection = class extends EventEmitter {
1165
1178
  this.emit("agent:unbound", agentId);
1166
1179
  return;
1167
1180
  }
1181
+ if (type === "agent:pinned") {
1182
+ const parsed = agentPinnedMessageSchema.safeParse(msg);
1183
+ if (parsed.success) this.emit("agent:pinned", parsed.data);
1184
+ return;
1185
+ }
1168
1186
  if (type === "agent:force_disconnect") {
1169
1187
  const agentId = msg.agentId;
1170
1188
  if (agentId && this.boundAgents.has(agentId)) {
@@ -1493,6 +1511,451 @@ echo "long message body" | first-tree-hub agent send <agentId>
1493
1511
  For content with quotes, \`$\`, backticks, or newlines, prefer stdin to avoid shell escaping issues.
1494
1512
  `;
1495
1513
  }
1514
+ const DEFAULT_CLONE_TIMEOUT_MS = 300 * 1e3;
1515
+ const FETCH_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
1516
+ const SESSION_BRANCH_PREFIX = "hub-session";
1517
+ function hashUrl(url) {
1518
+ return createHash("sha256").update(url).digest("hex").slice(0, 32);
1519
+ }
1520
+ function shortHash(input) {
1521
+ return createHash("sha256").update(input).digest("hex").slice(0, 8);
1522
+ }
1523
+ function deriveSessionBranchName(sessionKey, url) {
1524
+ return `${SESSION_BRANCH_PREFIX}-${shortHash(sessionKey)}-${shortHash(url)}`;
1525
+ }
1526
+ /**
1527
+ * A value is SHA-like when it's a 7–40 character hex string. Used to decide
1528
+ * whether `ref` should be resolved via the remote namespace (branch name) or
1529
+ * used as-is (commit hash).
1530
+ */
1531
+ function looksLikeCommitSha(ref) {
1532
+ return /^[0-9a-f]{7,40}$/i.test(ref);
1533
+ }
1534
+ function createGitMirrorManager(opts) {
1535
+ const mirrorsRoot = join(opts.dataDir, "git-mirrors");
1536
+ const cloneTimeoutMs = opts.cloneTimeoutMs ?? Number(process.env.FIRST_TREE_HUB_GIT_CLONE_TIMEOUT_MS ?? DEFAULT_CLONE_TIMEOUT_MS);
1537
+ const log = opts.log ?? (() => {});
1538
+ const urlLocks = /* @__PURE__ */ new Map();
1539
+ function withUrlLock(url, op) {
1540
+ const key = hashUrl(url);
1541
+ const next = (urlLocks.get(key) ?? Promise.resolve()).then(op, op);
1542
+ urlLocks.set(key, next);
1543
+ next.then(() => {
1544
+ if (urlLocks.get(key) === next) urlLocks.delete(key);
1545
+ }, () => {
1546
+ if (urlLocks.get(key) === next) urlLocks.delete(key);
1547
+ });
1548
+ return next;
1549
+ }
1550
+ function mirrorDir(url) {
1551
+ return join(mirrorsRoot, hashUrl(url));
1552
+ }
1553
+ async function git(args, cwd, timeoutMs, env) {
1554
+ const start = Date.now();
1555
+ return await new Promise((resolveExec, rejectExec) => {
1556
+ const proc = spawn("git", args, {
1557
+ cwd: cwd ?? void 0,
1558
+ env: env ?? process.env,
1559
+ stdio: [
1560
+ "ignore",
1561
+ "pipe",
1562
+ "pipe"
1563
+ ]
1564
+ });
1565
+ let stdout = "";
1566
+ let stderr = "";
1567
+ proc.stdout.on("data", (d) => {
1568
+ stdout += String(d);
1569
+ });
1570
+ proc.stderr.on("data", (d) => {
1571
+ stderr += String(d);
1572
+ });
1573
+ const timer = setTimeout(() => {
1574
+ proc.kill("SIGKILL");
1575
+ rejectExec(new GitMirrorTimeoutError(`git ${args.join(" ")} timed out after ${timeoutMs}ms`));
1576
+ }, timeoutMs);
1577
+ proc.on("error", (err) => {
1578
+ clearTimeout(timer);
1579
+ rejectExec(err);
1580
+ });
1581
+ proc.on("close", (code) => {
1582
+ clearTimeout(timer);
1583
+ const elapsedMs = Date.now() - start;
1584
+ if (code === 0) resolveExec({
1585
+ stdout,
1586
+ stderr,
1587
+ elapsedMs
1588
+ });
1589
+ else rejectExec(new GitMirrorError(`git ${args.join(" ")} exited with code ${code}: ${stderr.slice(0, 1024)}`));
1590
+ });
1591
+ });
1592
+ }
1593
+ async function gitOk(args, cwd, timeoutMs) {
1594
+ try {
1595
+ await git(args, cwd, timeoutMs);
1596
+ return true;
1597
+ } catch {
1598
+ return false;
1599
+ }
1600
+ }
1601
+ /**
1602
+ * Bring the mirror's config to the invariant expected by this module:
1603
+ * fetch refspec = `+refs/heads/*:refs/remotes/origin/*`, `remote.origin.mirror`
1604
+ * absent, `refs/remotes/origin/HEAD` resolvable.
1605
+ *
1606
+ * Called from `ensureMirror` on every invocation — both the fresh-clone path
1607
+ * (ensures our own bootstrap wrote the right values) and the pre-existing
1608
+ * mirror path (repairs drift from the legacy `--mirror` config).
1609
+ */
1610
+ async function assertMirrorConfig(mirrorPath, url) {
1611
+ let migrated = false;
1612
+ let currentFetch = "";
1613
+ try {
1614
+ const { stdout } = await git([
1615
+ "config",
1616
+ "--get-all",
1617
+ "remote.origin.fetch"
1618
+ ], mirrorPath, 1e4);
1619
+ currentFetch = stdout.trim();
1620
+ } catch {
1621
+ currentFetch = "";
1622
+ }
1623
+ if (currentFetch !== FETCH_REFSPEC) {
1624
+ await git([
1625
+ "config",
1626
+ "--replace-all",
1627
+ "remote.origin.fetch",
1628
+ FETCH_REFSPEC
1629
+ ], mirrorPath, 1e4);
1630
+ migrated = true;
1631
+ }
1632
+ if (await gitOk([
1633
+ "config",
1634
+ "--get",
1635
+ "remote.origin.mirror"
1636
+ ], mirrorPath, 1e4)) {
1637
+ await git([
1638
+ "config",
1639
+ "--unset-all",
1640
+ "remote.origin.mirror"
1641
+ ], mirrorPath, 1e4);
1642
+ migrated = true;
1643
+ }
1644
+ try {
1645
+ const { stdout } = await git([
1646
+ "config",
1647
+ "--get",
1648
+ "remote.origin.url"
1649
+ ], mirrorPath, 1e4);
1650
+ if (stdout.trim() !== url) {
1651
+ await git([
1652
+ "config",
1653
+ "--replace-all",
1654
+ "remote.origin.url",
1655
+ url
1656
+ ], mirrorPath, 1e4);
1657
+ migrated = true;
1658
+ }
1659
+ } catch {
1660
+ await git([
1661
+ "remote",
1662
+ "add",
1663
+ "origin",
1664
+ url
1665
+ ], mirrorPath, 1e4);
1666
+ migrated = true;
1667
+ }
1668
+ if (migrated) {
1669
+ await git([
1670
+ "fetch",
1671
+ "--prune",
1672
+ "origin"
1673
+ ], mirrorPath, cloneTimeoutMs);
1674
+ await gitOk([
1675
+ "remote",
1676
+ "set-head",
1677
+ "origin",
1678
+ "--auto"
1679
+ ], mirrorPath, 3e4);
1680
+ log("mirrorConfigMigrated", { gitUrl: url });
1681
+ }
1682
+ return { migrated };
1683
+ }
1684
+ /**
1685
+ * Bootstrap a fresh mirror at `mirrorPath`. Uses `git init --bare` +
1686
+ * manual remote setup rather than `git clone --mirror` / `git clone --bare`,
1687
+ * so we never transiently have the mirror configured to force-write
1688
+ * `refs/heads/*` on fetch.
1689
+ */
1690
+ async function bootstrapMirror(mirrorPath, url) {
1691
+ mkdirSync(dirname(mirrorPath), { recursive: true });
1692
+ await git([
1693
+ "init",
1694
+ "--bare",
1695
+ mirrorPath
1696
+ ], null, cloneTimeoutMs);
1697
+ await git([
1698
+ "remote",
1699
+ "add",
1700
+ "origin",
1701
+ url
1702
+ ], mirrorPath, 1e4);
1703
+ await git([
1704
+ "config",
1705
+ "--replace-all",
1706
+ "remote.origin.fetch",
1707
+ FETCH_REFSPEC
1708
+ ], mirrorPath, 1e4);
1709
+ await git([
1710
+ "fetch",
1711
+ "--prune",
1712
+ "origin"
1713
+ ], mirrorPath, cloneTimeoutMs);
1714
+ await gitOk([
1715
+ "remote",
1716
+ "set-head",
1717
+ "origin",
1718
+ "--auto"
1719
+ ], mirrorPath, 3e4);
1720
+ }
1721
+ async function branchExists(mirrorPath, branchName) {
1722
+ return await gitOk([
1723
+ "rev-parse",
1724
+ "--verify",
1725
+ "--quiet",
1726
+ `refs/heads/${branchName}`
1727
+ ], mirrorPath, 1e4);
1728
+ }
1729
+ /**
1730
+ * Resolve the commit-ish to base a new session branch on.
1731
+ *
1732
+ * - explicit SHA → use as-is
1733
+ * - explicit branch name → prefer `refs/remotes/origin/<ref>`, fall back to
1734
+ * a literal SHA resolution in case the caller handed us a short commit
1735
+ * - `ref` absent → `refs/remotes/origin/HEAD`
1736
+ */
1737
+ async function resolveBase(mirrorPath, ref) {
1738
+ if (!ref) {
1739
+ if (await gitOk([
1740
+ "rev-parse",
1741
+ "--verify",
1742
+ "--quiet",
1743
+ "refs/remotes/origin/HEAD"
1744
+ ], mirrorPath, 1e4)) return "refs/remotes/origin/HEAD";
1745
+ throw new GitMirrorError("Cannot resolve default branch: refs/remotes/origin/HEAD is missing. Re-run with an explicit `ref`.");
1746
+ }
1747
+ if (looksLikeCommitSha(ref)) {
1748
+ if (await gitOk([
1749
+ "cat-file",
1750
+ "-e",
1751
+ ref
1752
+ ], mirrorPath, 1e4)) return ref;
1753
+ }
1754
+ const remoteRef = `refs/remotes/origin/${ref}`;
1755
+ if (await gitOk([
1756
+ "rev-parse",
1757
+ "--verify",
1758
+ "--quiet",
1759
+ remoteRef
1760
+ ], mirrorPath, 1e4)) return remoteRef;
1761
+ return ref;
1762
+ }
1763
+ return {
1764
+ get mirrorsRoot() {
1765
+ return mirrorsRoot;
1766
+ },
1767
+ ensureMirror(url) {
1768
+ return withUrlLock(url, async () => {
1769
+ mkdirSync(mirrorsRoot, { recursive: true });
1770
+ const path = mirrorDir(url);
1771
+ if (existsSync(join(path, "HEAD"))) {
1772
+ const { migrated } = await assertMirrorConfig(path, url);
1773
+ if (migrated) {}
1774
+ return {
1775
+ mirrorPath: path,
1776
+ elapsedMs: 0,
1777
+ cloned: false
1778
+ };
1779
+ }
1780
+ const start = Date.now();
1781
+ try {
1782
+ await bootstrapMirror(path, url);
1783
+ const elapsedMs = Date.now() - start;
1784
+ log("ensureMirror", {
1785
+ gitUrl: url,
1786
+ elapsedMs,
1787
+ cloned: true
1788
+ });
1789
+ return {
1790
+ mirrorPath: path,
1791
+ elapsedMs,
1792
+ cloned: true
1793
+ };
1794
+ } catch (err) {
1795
+ if (err instanceof GitMirrorTimeoutError) log("mirrorCloneTimeout", {
1796
+ gitUrl: url,
1797
+ timeoutMs: cloneTimeoutMs,
1798
+ elapsedMs: cloneTimeoutMs
1799
+ });
1800
+ if (existsSync(path)) rmSync(path, {
1801
+ recursive: true,
1802
+ force: true
1803
+ });
1804
+ throw err;
1805
+ }
1806
+ });
1807
+ },
1808
+ fetchMirror(url) {
1809
+ return withUrlLock(url, async () => {
1810
+ const path = mirrorDir(url);
1811
+ if (!existsSync(join(path, "HEAD"))) throw new GitMirrorError(`Cannot fetch — no mirror exists for "${url}"`);
1812
+ try {
1813
+ const { elapsedMs } = await git([
1814
+ "fetch",
1815
+ "--prune",
1816
+ "origin"
1817
+ ], path, cloneTimeoutMs);
1818
+ return { elapsedMs };
1819
+ } catch (err) {
1820
+ log("mirrorFetchFailed", {
1821
+ gitUrl: url,
1822
+ errorCode: err instanceof GitMirrorError ? "git-failed" : "unknown",
1823
+ stderr: err instanceof Error ? err.message.slice(0, 1024) : String(err).slice(0, 1024)
1824
+ });
1825
+ throw err;
1826
+ }
1827
+ });
1828
+ },
1829
+ createWorktree({ url, ref, targetPath, sessionKey }) {
1830
+ return withUrlLock(url, async () => {
1831
+ const mirror = mirrorDir(url);
1832
+ if (!existsSync(join(mirror, "HEAD"))) throw new GitMirrorError(`Cannot create worktree — no mirror exists for "${url}"`);
1833
+ const absTarget = resolve(targetPath);
1834
+ const branchName = deriveSessionBranchName(sessionKey, url);
1835
+ if (existsSync(absTarget) && !isHubManagedWorktree(absTarget)) {
1836
+ log("worktreeCreateConflict", {
1837
+ gitUrl: url,
1838
+ targetPath: absTarget,
1839
+ occupantKind: classifyOccupant(absTarget)
1840
+ });
1841
+ throw new GitMirrorWorktreeConflictError(`Worktree target "${absTarget}" is already occupied by ${classifyOccupant(absTarget)} — aborting (D13)`);
1842
+ }
1843
+ const pathExists = existsSync(absTarget);
1844
+ const hasBranch = await branchExists(mirror, branchName);
1845
+ mkdirSync(dirname(absTarget), { recursive: true });
1846
+ if (pathExists && hasBranch) {} else if (pathExists && !hasBranch) throw new GitMirrorError(`Worktree directory "${absTarget}" exists as a Hub worktree but the expected session branch "${branchName}" is missing in the mirror — manual cleanup required`);
1847
+ else if (!pathExists && hasBranch) await git([
1848
+ "worktree",
1849
+ "add",
1850
+ absTarget,
1851
+ branchName
1852
+ ], mirror, cloneTimeoutMs);
1853
+ else await git([
1854
+ "worktree",
1855
+ "add",
1856
+ "-b",
1857
+ branchName,
1858
+ absTarget,
1859
+ await resolveBase(mirror, ref)
1860
+ ], mirror, cloneTimeoutMs);
1861
+ return {
1862
+ worktreePath: absTarget,
1863
+ headCommit: (await git(["rev-parse", "HEAD"], absTarget, 3e4)).stdout.trim(),
1864
+ branchName
1865
+ };
1866
+ });
1867
+ },
1868
+ removeWorktree({ url, path, branchName }) {
1869
+ return withUrlLock(url, async () => {
1870
+ const absTarget = resolve(path);
1871
+ const mirror = mirrorDir(url);
1872
+ if (!isBareRepo(mirror)) {
1873
+ if (existsSync(absTarget)) rmSync(absTarget, {
1874
+ recursive: true,
1875
+ force: true
1876
+ });
1877
+ return;
1878
+ }
1879
+ if (existsSync(absTarget)) await gitOk([
1880
+ "worktree",
1881
+ "remove",
1882
+ "--force",
1883
+ absTarget
1884
+ ], mirror, 3e4);
1885
+ else await gitOk(["worktree", "prune"], mirror, 3e4);
1886
+ if (existsSync(absTarget)) rmSync(absTarget, {
1887
+ recursive: true,
1888
+ force: true
1889
+ });
1890
+ if (await branchExists(mirror, branchName)) await gitOk([
1891
+ "branch",
1892
+ "-D",
1893
+ branchName
1894
+ ], mirror, 1e4);
1895
+ });
1896
+ },
1897
+ async gcMirrors(stillReferencedUrls) {
1898
+ if (!existsSync(mirrorsRoot)) return { removed: [] };
1899
+ const wantedHashes = new Set([...stillReferencedUrls].map(hashUrl));
1900
+ const removed = [];
1901
+ for (const entry of readdirSync(mirrorsRoot)) {
1902
+ if (wantedHashes.has(entry)) continue;
1903
+ const path = join(mirrorsRoot, entry);
1904
+ if (!isBareRepo(path)) continue;
1905
+ rmSync(path, {
1906
+ recursive: true,
1907
+ force: true
1908
+ });
1909
+ removed.push(entry);
1910
+ }
1911
+ return { removed };
1912
+ }
1913
+ };
1914
+ }
1915
+ function isBareRepo(p) {
1916
+ return existsSync(join(p, "HEAD")) && existsSync(join(p, "objects"));
1917
+ }
1918
+ function isHubManagedWorktree(p) {
1919
+ const gitMarker = join(p, ".git");
1920
+ if (!existsSync(gitMarker)) return false;
1921
+ try {
1922
+ return statSync(gitMarker).isFile();
1923
+ } catch {
1924
+ return false;
1925
+ }
1926
+ }
1927
+ function classifyOccupant(p) {
1928
+ try {
1929
+ const stat = statSync(p);
1930
+ if (stat.isSymbolicLink()) return "symlink";
1931
+ if (stat.isDirectory()) {
1932
+ if (existsSync(join(p, ".git"))) return "git-repo";
1933
+ return "directory";
1934
+ }
1935
+ if (stat.isFile()) return "file";
1936
+ return "other";
1937
+ } catch {
1938
+ return "unknown";
1939
+ }
1940
+ }
1941
+ var GitMirrorError = class extends Error {
1942
+ constructor(message) {
1943
+ super(message);
1944
+ this.name = "GitMirrorError";
1945
+ }
1946
+ };
1947
+ var GitMirrorTimeoutError = class extends GitMirrorError {
1948
+ constructor(message) {
1949
+ super(message);
1950
+ this.name = "GitMirrorTimeoutError";
1951
+ }
1952
+ };
1953
+ var GitMirrorWorktreeConflictError = class extends GitMirrorError {
1954
+ constructor(message) {
1955
+ super(message);
1956
+ this.name = "GitMirrorWorktreeConflictError";
1957
+ }
1958
+ };
1496
1959
  /**
1497
1960
  * InputController — push-based async iterable bridge.
1498
1961
  *
@@ -1741,7 +2204,7 @@ const createClaudeCodeHandler = (config) => {
1741
2204
  let appliedConfigVersion = 0;
1742
2205
  let appliedModel = "";
1743
2206
  let appliedPayload = null;
1744
- /** Worktree paths materialised for this session — removed on shutdown. */
2207
+ /** Worktrees materialised for this session — each entry removed on shutdown. */
1745
2208
  const ownedWorktrees = [];
1746
2209
  function toSDKUserMessage(message, sessionId) {
1747
2210
  const rawContent = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
@@ -1965,28 +2428,37 @@ const createClaudeCodeHandler = (config) => {
1965
2428
  await gitMirrorManager.fetchMirror(repo.url);
1966
2429
  if (existsSync(targetPath) && isHubWorktreeMarker(targetPath)) {
1967
2430
  sessionCtx.log(`Git: reusing existing worktree at ${localPath}`);
1968
- ownedWorktrees.push(targetPath);
2431
+ ownedWorktrees.push({
2432
+ url: repo.url,
2433
+ path: targetPath,
2434
+ branchName: deriveSessionBranchName(sessionCtx.chatId, repo.url)
2435
+ });
1969
2436
  continue;
1970
2437
  }
1971
- const { headCommit } = await gitMirrorManager.createWorktree({
2438
+ const { headCommit, branchName } = await gitMirrorManager.createWorktree({
1972
2439
  url: repo.url,
1973
2440
  ref: repo.ref,
1974
- targetPath
2441
+ targetPath,
2442
+ sessionKey: sessionCtx.chatId
2443
+ });
2444
+ ownedWorktrees.push({
2445
+ url: repo.url,
2446
+ path: targetPath,
2447
+ branchName
1975
2448
  });
1976
- ownedWorktrees.push(targetPath);
1977
- sessionCtx.log(`Git: worktree at ${localPath} @ ${headCommit.slice(0, 7)}`);
2449
+ sessionCtx.log(`Git: worktree at ${localPath} @ ${headCommit.slice(0, 7)} on ${branchName}`);
1978
2450
  }
1979
2451
  }
1980
2452
  /** Tear down all worktrees this session owns; best-effort. */
1981
2453
  async function cleanupGitWorktrees(sessionCtx) {
1982
2454
  if (!gitMirrorManager) return;
1983
2455
  while (ownedWorktrees.length > 0) {
1984
- const path = ownedWorktrees.pop();
1985
- if (!path) continue;
2456
+ const entry = ownedWorktrees.pop();
2457
+ if (!entry) continue;
1986
2458
  try {
1987
- await gitMirrorManager.removeWorktree(path);
2459
+ await gitMirrorManager.removeWorktree(entry);
1988
2460
  } catch (err) {
1989
- sessionCtx.log(`Git: removeWorktree(${path}) failed — ${err instanceof Error ? err.message : String(err)}`);
2461
+ sessionCtx.log(`Git: removeWorktree(${entry.path}) failed — ${err instanceof Error ? err.message : String(err)}`);
1990
2462
  }
1991
2463
  }
1992
2464
  }
@@ -2200,226 +2672,6 @@ function createAgentConfigCache(opts) {
2200
2672
  }
2201
2673
  };
2202
2674
  }
2203
- const DEFAULT_CLONE_TIMEOUT_MS = 300 * 1e3;
2204
- function hashUrl(url) {
2205
- return createHash("sha256").update(url).digest("hex").slice(0, 32);
2206
- }
2207
- function createGitMirrorManager(opts) {
2208
- const mirrorsRoot = join(opts.dataDir, "git-mirrors");
2209
- const cloneTimeoutMs = opts.cloneTimeoutMs ?? Number(process.env.FIRST_TREE_HUB_GIT_CLONE_TIMEOUT_MS ?? DEFAULT_CLONE_TIMEOUT_MS);
2210
- const log = opts.log ?? (() => {});
2211
- function mirrorDir(url) {
2212
- return join(mirrorsRoot, hashUrl(url));
2213
- }
2214
- async function git(args, cwd, timeoutMs, env) {
2215
- const start = Date.now();
2216
- return await new Promise((resolveExec, rejectExec) => {
2217
- const proc = spawn("git", args, {
2218
- cwd: cwd ?? void 0,
2219
- env: env ?? process.env,
2220
- stdio: [
2221
- "ignore",
2222
- "pipe",
2223
- "pipe"
2224
- ]
2225
- });
2226
- let stdout = "";
2227
- let stderr = "";
2228
- proc.stdout.on("data", (d) => {
2229
- stdout += String(d);
2230
- });
2231
- proc.stderr.on("data", (d) => {
2232
- stderr += String(d);
2233
- });
2234
- const timer = setTimeout(() => {
2235
- proc.kill("SIGKILL");
2236
- rejectExec(new GitMirrorTimeoutError(`git ${args.join(" ")} timed out after ${timeoutMs}ms`));
2237
- }, timeoutMs);
2238
- proc.on("error", (err) => {
2239
- clearTimeout(timer);
2240
- rejectExec(err);
2241
- });
2242
- proc.on("close", (code) => {
2243
- clearTimeout(timer);
2244
- const elapsedMs = Date.now() - start;
2245
- if (code === 0) resolveExec({
2246
- stdout,
2247
- stderr,
2248
- elapsedMs
2249
- });
2250
- else rejectExec(new GitMirrorError(`git ${args.join(" ")} exited with code ${code}: ${stderr.slice(0, 1024)}`));
2251
- });
2252
- });
2253
- }
2254
- return {
2255
- get mirrorsRoot() {
2256
- return mirrorsRoot;
2257
- },
2258
- async ensureMirror(url) {
2259
- mkdirSync(mirrorsRoot, { recursive: true });
2260
- const path = mirrorDir(url);
2261
- if (existsSync(join(path, "HEAD"))) return {
2262
- mirrorPath: path,
2263
- elapsedMs: 0,
2264
- cloned: false
2265
- };
2266
- try {
2267
- const { elapsedMs } = await git([
2268
- "clone",
2269
- "--mirror",
2270
- url,
2271
- path
2272
- ], null, cloneTimeoutMs);
2273
- log("ensureMirror", {
2274
- gitUrl: url,
2275
- elapsedMs,
2276
- cloned: true
2277
- });
2278
- return {
2279
- mirrorPath: path,
2280
- elapsedMs,
2281
- cloned: true
2282
- };
2283
- } catch (err) {
2284
- if (err instanceof GitMirrorTimeoutError) log("mirrorCloneTimeout", {
2285
- gitUrl: url,
2286
- timeoutMs: cloneTimeoutMs,
2287
- elapsedMs: cloneTimeoutMs
2288
- });
2289
- if (existsSync(path)) rmSync(path, {
2290
- recursive: true,
2291
- force: true
2292
- });
2293
- throw err;
2294
- }
2295
- },
2296
- async fetchMirror(url) {
2297
- const path = mirrorDir(url);
2298
- if (!existsSync(join(path, "HEAD"))) throw new GitMirrorError(`Cannot fetch — no mirror exists for "${url}"`);
2299
- try {
2300
- const { elapsedMs } = await git(["fetch", "--prune"], path, cloneTimeoutMs);
2301
- return { elapsedMs };
2302
- } catch (err) {
2303
- log("mirrorFetchFailed", {
2304
- gitUrl: url,
2305
- errorCode: err instanceof GitMirrorError ? "git-failed" : "unknown",
2306
- stderr: err instanceof Error ? err.message.slice(0, 1024) : String(err).slice(0, 1024)
2307
- });
2308
- throw err;
2309
- }
2310
- },
2311
- async createWorktree({ url, ref, targetPath }) {
2312
- const mirror = mirrorDir(url);
2313
- if (!existsSync(join(mirror, "HEAD"))) throw new GitMirrorError(`Cannot create worktree — no mirror exists for "${url}"`);
2314
- const absTarget = resolve(targetPath);
2315
- if (existsSync(absTarget) && !isHubManagedWorktree(absTarget)) {
2316
- log("worktreeCreateConflict", {
2317
- gitUrl: url,
2318
- targetPath: absTarget,
2319
- occupantKind: classifyOccupant(absTarget)
2320
- });
2321
- throw new GitMirrorWorktreeConflictError(`Worktree target "${absTarget}" is already occupied by ${classifyOccupant(absTarget)} — aborting (D13)`);
2322
- }
2323
- mkdirSync(dirname(absTarget), { recursive: true });
2324
- const args = [
2325
- "worktree",
2326
- "add",
2327
- "--detach",
2328
- absTarget
2329
- ];
2330
- if (ref) args.push(ref);
2331
- await git(args, mirror, cloneTimeoutMs);
2332
- return {
2333
- worktreePath: absTarget,
2334
- headCommit: (await git(["rev-parse", "HEAD"], absTarget, 3e4)).stdout.trim()
2335
- };
2336
- },
2337
- async removeWorktree(path) {
2338
- const absTarget = resolve(path);
2339
- if (!existsSync(absTarget)) return;
2340
- if (!existsSync(mirrorsRoot)) return;
2341
- let removed = false;
2342
- for (const entry of readdirSync(mirrorsRoot)) {
2343
- const mirror = join(mirrorsRoot, entry);
2344
- if (!isBareRepo(mirror)) continue;
2345
- try {
2346
- await git([
2347
- "worktree",
2348
- "remove",
2349
- "--force",
2350
- absTarget
2351
- ], mirror, 3e4);
2352
- removed = true;
2353
- break;
2354
- } catch {}
2355
- }
2356
- if (!removed && existsSync(absTarget)) rmSync(absTarget, {
2357
- recursive: true,
2358
- force: true
2359
- });
2360
- },
2361
- async gcMirrors(stillReferencedUrls) {
2362
- if (!existsSync(mirrorsRoot)) return { removed: [] };
2363
- const wantedHashes = new Set([...stillReferencedUrls].map(hashUrl));
2364
- const removed = [];
2365
- for (const entry of readdirSync(mirrorsRoot)) {
2366
- if (wantedHashes.has(entry)) continue;
2367
- const path = join(mirrorsRoot, entry);
2368
- if (!isBareRepo(path)) continue;
2369
- rmSync(path, {
2370
- recursive: true,
2371
- force: true
2372
- });
2373
- removed.push(entry);
2374
- }
2375
- return { removed };
2376
- }
2377
- };
2378
- }
2379
- function isBareRepo(p) {
2380
- return existsSync(join(p, "HEAD")) && existsSync(join(p, "objects"));
2381
- }
2382
- function isHubManagedWorktree(p) {
2383
- const gitMarker = join(p, ".git");
2384
- if (!existsSync(gitMarker)) return false;
2385
- try {
2386
- return statSync(gitMarker).isFile();
2387
- } catch {
2388
- return false;
2389
- }
2390
- }
2391
- function classifyOccupant(p) {
2392
- try {
2393
- const stat = statSync(p);
2394
- if (stat.isSymbolicLink()) return "symlink";
2395
- if (stat.isDirectory()) {
2396
- if (existsSync(join(p, ".git"))) return "git-repo";
2397
- return "directory";
2398
- }
2399
- if (stat.isFile()) return "file";
2400
- return "other";
2401
- } catch {
2402
- return "unknown";
2403
- }
2404
- }
2405
- var GitMirrorError = class extends Error {
2406
- constructor(message) {
2407
- super(message);
2408
- this.name = "GitMirrorError";
2409
- }
2410
- };
2411
- var GitMirrorTimeoutError = class extends GitMirrorError {
2412
- constructor(message) {
2413
- super(message);
2414
- this.name = "GitMirrorTimeoutError";
2415
- }
2416
- };
2417
- var GitMirrorWorktreeConflictError = class extends GitMirrorError {
2418
- constructor(message) {
2419
- super(message);
2420
- this.name = "GitMirrorWorktreeConflictError";
2421
- }
2422
- };
2423
2675
  /**
2424
2676
  * Deduplicator — bounded set of recently seen IDs.
2425
2677
  *
@@ -3211,8 +3463,15 @@ var ClientRuntime = class {
3211
3463
  connection;
3212
3464
  agents = [];
3213
3465
  agentNames = /* @__PURE__ */ new Set();
3466
+ agentIds = /* @__PURE__ */ new Set();
3214
3467
  watcher = null;
3215
3468
  debounceTimer = null;
3469
+ /**
3470
+ * Directory we write auto-registered agent configs into (same path that
3471
+ * `first-tree-hub agent add` uses). Set by `watchAgentsDir` so the
3472
+ * `agent:pinned` handler knows where to materialise new configs.
3473
+ */
3474
+ agentsDir = null;
3216
3475
  constructor(serverUrl, clientId) {
3217
3476
  this.serverUrl = serverUrl;
3218
3477
  this.connection = new ClientConnection({
@@ -3224,6 +3483,9 @@ var ClientRuntime = class {
3224
3483
  this.connection.on("auth:expired", () => {
3225
3484
  process.stderr.write(" ⚠️ Access token expired — reconnecting after refresh...\n");
3226
3485
  });
3486
+ this.connection.on("agent:pinned", (message) => {
3487
+ this.handleAgentPinned(message);
3488
+ });
3227
3489
  }
3228
3490
  addAgent(name, config) {
3229
3491
  if (this.agentNames.has(name)) return;
@@ -3246,6 +3508,7 @@ var ClientRuntime = class {
3246
3508
  slot
3247
3509
  });
3248
3510
  this.agentNames.add(name);
3511
+ this.agentIds.add(config.agentId);
3249
3512
  }
3250
3513
  async start() {
3251
3514
  await this.connection.connect();
@@ -3268,6 +3531,7 @@ var ClientRuntime = class {
3268
3531
  process.stderr.write(`\n ${connected} agent(s) running. Press Ctrl+C to stop.\n`);
3269
3532
  }
3270
3533
  watchAgentsDir(agentsDir) {
3534
+ this.agentsDir = agentsDir;
3271
3535
  if (this.watcher) return;
3272
3536
  if (!existsSync(agentsDir)) return;
3273
3537
  this.watcher = watch(agentsDir, { recursive: true }, () => {
@@ -3301,12 +3565,68 @@ var ClientRuntime = class {
3301
3565
  });
3302
3566
  for (const [name, config] of all) {
3303
3567
  if (this.agentNames.has(name)) continue;
3568
+ if (this.agentIds.has(config.agentId)) continue;
3304
3569
  process.stderr.write(`\n New agent detected: ${name}\n`);
3305
3570
  this.addAgent(name, config);
3306
3571
  this.startAgent(name);
3307
3572
  }
3308
3573
  } catch {}
3309
3574
  }
3575
+ /**
3576
+ * React to an `agent:pinned` server push by writing the local config file
3577
+ * (same shape `first-tree-hub agent add` produces) and scheduling the new
3578
+ * slot — so the operator doesn't have to run `agent add` manually after
3579
+ * creating an agent from the admin UI or API.
3580
+ */
3581
+ handleAgentPinned(message) {
3582
+ if (this.agentIds.has(message.agentId)) return;
3583
+ if (!this.agentsDir) {
3584
+ process.stderr.write(` \u26A0\uFE0F Agent pinned (${message.agentId}) but no agents dir set — cannot auto-register.\n`);
3585
+ return;
3586
+ }
3587
+ const localName = this.pickLocalName(message);
3588
+ const agentDir = join(this.agentsDir, localName);
3589
+ try {
3590
+ mkdirSync(agentDir, {
3591
+ recursive: true,
3592
+ mode: 448
3593
+ });
3594
+ const yaml = stringify({
3595
+ agentId: message.agentId,
3596
+ runtime: "claude-code"
3597
+ });
3598
+ writeFileSync(join(agentDir, "agent.yaml"), yaml, { mode: 384 });
3599
+ process.stderr.write(` \u2713 Auto-added agent "${localName}" (${message.agentId}) from server push.\n`);
3600
+ } catch (err) {
3601
+ const msg = err instanceof Error ? err.message : String(err);
3602
+ process.stderr.write(` \u2717 Failed to auto-add agent "${localName}": ${msg}\n`);
3603
+ return;
3604
+ }
3605
+ this.scanForNewAgents(this.agentsDir);
3606
+ }
3607
+ /**
3608
+ * Choose the directory name under `agents/<name>/agent.yaml` for an agent
3609
+ * pushed by the server. Prefer the server-side `name` when set and not
3610
+ * already claimed; otherwise fall back to a UUID-derived name with a numeric
3611
+ * suffix on collision.
3612
+ *
3613
+ * UUID v7 packs the unix-ms timestamp in the high bits, so two agents
3614
+ * created in the same millisecond share the first 8 hex chars. Take 16 chars
3615
+ * (the full ms-timestamp segment plus the random tail) to make accidental
3616
+ * collisions astronomically unlikely, and re-check `agentNames` so even an
3617
+ * adversarial collision falls through to a `-2`, `-3`, … suffix.
3618
+ */
3619
+ pickLocalName(message) {
3620
+ const preferred = message.name;
3621
+ if (preferred && !this.agentNames.has(preferred)) return preferred;
3622
+ const base = `agent-${message.agentId.replace(/[^a-z0-9]/gi, "").slice(0, 16).toLowerCase()}`;
3623
+ if (!this.agentNames.has(base)) return base;
3624
+ for (let suffix = 2; suffix < 1e3; suffix++) {
3625
+ const candidate = `${base}-${suffix}`;
3626
+ if (!this.agentNames.has(candidate)) return candidate;
3627
+ }
3628
+ return `agent-${message.agentId.replace(/[^a-z0-9]/gi, "").toLowerCase()}`;
3629
+ }
3310
3630
  startAgent(name) {
3311
3631
  const entry = this.agents.find((a) => a.name === name);
3312
3632
  if (!entry) return;
@@ -3972,7 +4292,7 @@ async function onboardCreate(args) {
3972
4292
  }
3973
4293
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
3974
4294
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
3975
- const { bindFeishuBot } = await import("./feishu-CJ08ntOD.mjs").then((n) => n.r);
4295
+ const { bindFeishuBot } = await import("./feishu-D9JkMZnU.mjs").then((n) => n.r);
3976
4296
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
3977
4297
  if (!targetAgentUuid) process.stderr.write(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
3978
4298
  else {
@@ -4113,7 +4433,7 @@ function setNestedByDot(obj, dotPath, value) {
4113
4433
  if (lastKey !== void 0) current[lastKey] = value;
4114
4434
  }
4115
4435
  //#endregion
4116
- //#region ../server/dist/app-CWKBBGod.mjs
4436
+ //#region ../server/dist/app-TMhTLXuz.mjs
4117
4437
  var __defProp = Object.defineProperty;
4118
4438
  var __exportAll = (all, no_symbols) => {
4119
4439
  let target = {};
@@ -5713,6 +6033,23 @@ async function getClient(db, clientId) {
5713
6033
  const [row] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
5714
6034
  return row ?? null;
5715
6035
  }
6036
+ /**
6037
+ * List the active agents currently pinned to a client. Used by the WS
6038
+ * registration handshake to backfill `agent:pinned` notifications missed while
6039
+ * the client was offline — without it, an admin who pinned an agent during a
6040
+ * client outage would still need a manual `first-tree-hub agent add`.
6041
+ *
6042
+ * Excludes soft-deleted agents (status = "deleted"). Human agents are
6043
+ * naturally excluded by the `clientId` filter — they never carry a clientId.
6044
+ */
6045
+ async function listActiveAgentsPinnedToClient(db, clientId) {
6046
+ return db.select({
6047
+ uuid: agents.uuid,
6048
+ name: agents.name,
6049
+ displayName: agents.displayName,
6050
+ type: agents.type
6051
+ }).from(agents).where(and(eq(agents.clientId, clientId), ne(agents.status, "deleted")));
6052
+ }
5716
6053
  async function listClients(db, userId) {
5717
6054
  const rows = await db.select().from(clients).where(eq(clients.userId, userId));
5718
6055
  const counts = await db.select({
@@ -5850,6 +6187,13 @@ function removeClientConnection(clientId, ws) {
5850
6187
  clientConnections.delete(clientId);
5851
6188
  return agentIds;
5852
6189
  }
6190
+ /** Send a message to a client's WebSocket. Returns true if delivered. */
6191
+ function sendToClient(clientId, message) {
6192
+ const entry = clientConnections.get(clientId);
6193
+ if (!entry || entry.ws.readyState !== 1) return false;
6194
+ entry.ws.send(JSON.stringify(message));
6195
+ return true;
6196
+ }
5853
6197
  /** Send a message to a specific agent via its client's WebSocket. Returns true if delivered. */
5854
6198
  function sendToAgent$1(agentId, message) {
5855
6199
  const clientId = agentToClient.get(agentId);
@@ -6116,6 +6460,34 @@ function notifyRecipients(notifier, recipients, messageId) {
6116
6460
  for (const inboxId of recipients) notifier.notify(inboxId, messageId).catch(() => {});
6117
6461
  }
6118
6462
  async function adminAgentRoutes(app) {
6463
+ /**
6464
+ * Push an `agent:pinned` frame to the connected client so it can auto-register
6465
+ * the agent locally without the operator running `first-tree-hub agent add`.
6466
+ *
6467
+ * Best-effort: if the client is not currently connected to this server
6468
+ * instance, the notification is silently dropped here — the client picks the
6469
+ * pinning up on its next `client:register` handshake via the backfill path
6470
+ * in `api/agent/ws-client.ts`.
6471
+ */
6472
+ function notifyClientAgentPinned(agent) {
6473
+ if (!agent.clientId) return;
6474
+ const parsed = agentPinnedMessageSchema$1.safeParse({
6475
+ type: "agent:pinned",
6476
+ agentId: agent.uuid,
6477
+ name: agent.name,
6478
+ displayName: agent.displayName,
6479
+ agentType: agent.type
6480
+ });
6481
+ if (!parsed.success) {
6482
+ app.log.warn({
6483
+ err: parsed.error.flatten(),
6484
+ agentId: agent.uuid,
6485
+ clientId: agent.clientId
6486
+ }, "agent:pinned frame failed schema validation — not sending");
6487
+ return;
6488
+ }
6489
+ sendToClient(agent.clientId, parsed.data);
6490
+ }
6119
6491
  const listAgentsFilterSchema = z.object({ type: agentTypeSchema$1.optional() });
6120
6492
  app.get("/", async (request) => {
6121
6493
  const query = paginationQuerySchema.parse(request.query);
@@ -6146,6 +6518,7 @@ async function adminAgentRoutes(app) {
6146
6518
  source: body.source ?? "admin-api",
6147
6519
  managerId
6148
6520
  });
6521
+ notifyClientAgentPinned(agent);
6149
6522
  return reply.status(201).send({
6150
6523
  ...agent,
6151
6524
  createdAt: agent.createdAt.toISOString(),
@@ -6158,7 +6531,9 @@ async function adminAgentRoutes(app) {
6158
6531
  const body = updateAgentSchema.parse(request.body);
6159
6532
  const member = requireMember(request);
6160
6533
  if (body.managerId !== void 0 && member.role !== "admin") throw new ForbiddenError("Only admins can reassign an agent's manager");
6534
+ const before = body.clientId !== void 0 ? await getAgent(app.db, request.params.uuid) : null;
6161
6535
  const agent = await updateAgent(app.db, request.params.uuid, body);
6536
+ if (before && before.clientId === null && agent.clientId !== null) notifyClientAgentPinned(agent);
6162
6537
  return {
6163
6538
  ...agent,
6164
6539
  createdAt: agent.createdAt.toISOString(),
@@ -8551,6 +8926,32 @@ function clientWsRoutes(notifier, instanceId) {
8551
8926
  type: "client:registered",
8552
8927
  clientId: data.clientId
8553
8928
  }));
8929
+ try {
8930
+ const pinned = await listActiveAgentsPinnedToClient(app.db, data.clientId);
8931
+ for (const agent of pinned) {
8932
+ const parsed = agentPinnedMessageSchema$1.safeParse({
8933
+ type: "agent:pinned",
8934
+ agentId: agent.uuid,
8935
+ name: agent.name,
8936
+ displayName: agent.displayName,
8937
+ agentType: agent.type
8938
+ });
8939
+ if (!parsed.success) {
8940
+ app.log.warn({
8941
+ err: parsed.error.flatten(),
8942
+ agentId: agent.uuid,
8943
+ clientId: data.clientId
8944
+ }, "agent:pinned backfill frame failed schema validation — skipping");
8945
+ continue;
8946
+ }
8947
+ socket.send(JSON.stringify(parsed.data));
8948
+ }
8949
+ } catch (err) {
8950
+ app.log.error({
8951
+ err,
8952
+ clientId: data.clientId
8953
+ }, "agent:pinned backfill on client:register failed — client may need manual `agent add`");
8954
+ }
8554
8955
  } else if (type === "agent:bind") {
8555
8956
  if (!clientId) {
8556
8957
  socket.send(JSON.stringify({