@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.
- package/dist/cli/index.mjs +2 -2
- package/dist/{core-C05B8FzH.mjs → core-BXS5ppsG.mjs} +635 -234
- package/dist/{feishu-CJ08ntOD.mjs → feishu-D9JkMZnU.mjs} +14 -1
- package/dist/index.mjs +2 -2
- package/dist/web/assets/{index-30C-bada.js → index-KhUJU9Uf.js} +22 -22
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
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
|
|
1985
|
-
if (!
|
|
2456
|
+
const entry = ownedWorktrees.pop();
|
|
2457
|
+
if (!entry) continue;
|
|
1986
2458
|
try {
|
|
1987
|
-
await gitMirrorManager.removeWorktree(
|
|
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-
|
|
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-
|
|
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({
|