@openape/ape-agent 2.10.0 → 2.11.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/bridge.mjs CHANGED
@@ -33,15 +33,15 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
33
33
  ));
34
34
 
35
35
  // ../../packages/apes/dist/chunk-OBF7IMQ2.js
36
- import { homedir as homedir3 } from "os";
37
- import { join as join3 } from "path";
38
- var CONFIG_DIR, AUTH_FILE, CONFIG_FILE;
36
+ import { homedir as homedir4 } from "os";
37
+ import { join as join4 } from "path";
38
+ var CONFIG_DIR2, AUTH_FILE, CONFIG_FILE;
39
39
  var init_chunk_OBF7IMQ2 = __esm({
40
40
  "../../packages/apes/dist/chunk-OBF7IMQ2.js"() {
41
41
  "use strict";
42
- CONFIG_DIR = join3(homedir3(), ".config", "apes");
43
- AUTH_FILE = join3(CONFIG_DIR, "auth.json");
44
- CONFIG_FILE = join3(CONFIG_DIR, "config.toml");
42
+ CONFIG_DIR2 = join4(homedir4(), ".config", "apes");
43
+ AUTH_FILE = join4(CONFIG_DIR2, "auth.json");
44
+ CONFIG_FILE = join4(CONFIG_DIR2, "config.toml");
45
45
  }
46
46
  });
47
47
 
@@ -117,9 +117,9 @@ function requirePicocolors() {
117
117
  hasRequiredPicocolors = 1;
118
118
  let p = process || {}, argv2 = p.argv || [], env2 = p.env || {};
119
119
  let isColorSupported2 = !(!!env2.NO_COLOR || argv2.includes("--no-color")) && (!!env2.FORCE_COLOR || argv2.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env2.TERM !== "dumb" || !!env2.CI);
120
- let formatter = (open, close, replace = open) => (input) => {
121
- let string = "" + input, index = string.indexOf(close, open.length);
122
- return ~index ? open + replaceClose2(string, close, replace, index) + close : open + string + close;
120
+ let formatter = (open2, close, replace = open2) => (input) => {
121
+ let string = "" + input, index = string.indexOf(close, open2.length);
122
+ return ~index ? open2 + replaceClose2(string, close, replace, index) + close : open2 + string + close;
123
123
  };
124
124
  let replaceClose2 = (string, close, replace, index) => {
125
125
  let result = "", cursor = 0;
@@ -1074,9 +1074,9 @@ var require_shell_quote = __commonJS({
1074
1074
  });
1075
1075
 
1076
1076
  // src/bridge.ts
1077
- import { existsSync as existsSync6, readFileSync as readFileSync8 } from "fs";
1078
- import { homedir as homedir9 } from "os";
1079
- import { join as join8 } from "path";
1077
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "fs";
1078
+ import { homedir as homedir10 } from "os";
1079
+ import { dirname as dirname4, join as join10 } from "path";
1080
1080
  import process3 from "process";
1081
1081
 
1082
1082
  // ../../packages/cli-auth/dist/index.js
@@ -1095,22 +1095,33 @@ import { Buffer as Buffer3 } from "buffer";
1095
1095
  import { createPrivateKey } from "crypto";
1096
1096
  import { ofetch as ofetch4 } from "ofetch";
1097
1097
  import { ofetch as ofetch5 } from "ofetch";
1098
- function getConfigDir() {
1098
+ function getConfigDir(authHome) {
1099
+ if (authHome) return join(authHome, ".config", "apes");
1099
1100
  const override = process.env.OPENAPE_CLI_AUTH_HOME;
1100
1101
  if (override) return override;
1101
1102
  return join(homedir(), ".config", "apes");
1102
1103
  }
1103
- function getAuthFile() {
1104
- return join(getConfigDir(), "auth.json");
1104
+ function getAuthFile(authHome) {
1105
+ return join(getConfigDir(authHome), "auth.json");
1106
+ }
1107
+ function getSpTokensDir() {
1108
+ return join(getConfigDir(), "sp-tokens");
1109
+ }
1110
+ function ensureConfigDir(authHome) {
1111
+ const dir = getConfigDir(authHome);
1112
+ if (!existsSync(dir)) {
1113
+ mkdirSync(dir, { recursive: true, mode: 448 });
1114
+ }
1105
1115
  }
1106
- function ensureConfigDir() {
1107
- const dir = getConfigDir();
1116
+ function ensureSpTokensDir() {
1117
+ ensureConfigDir();
1118
+ const dir = getSpTokensDir();
1108
1119
  if (!existsSync(dir)) {
1109
1120
  mkdirSync(dir, { recursive: true, mode: 448 });
1110
1121
  }
1111
1122
  }
1112
- function loadIdpAuth() {
1113
- const file = getAuthFile();
1123
+ function loadIdpAuth(authHome) {
1124
+ const file = getAuthFile(authHome);
1114
1125
  if (!existsSync(file)) return null;
1115
1126
  try {
1116
1127
  const raw = readFileSync(file, "utf-8");
@@ -1120,9 +1131,9 @@ function loadIdpAuth() {
1120
1131
  return null;
1121
1132
  }
1122
1133
  }
1123
- function saveIdpAuth(auth) {
1124
- ensureConfigDir();
1125
- const file = getAuthFile();
1134
+ function saveIdpAuth(auth, authHome) {
1135
+ ensureConfigDir(authHome);
1136
+ const file = getAuthFile(authHome);
1126
1137
  let extra = {};
1127
1138
  if (existsSync(file)) {
1128
1139
  try {
@@ -1142,6 +1153,27 @@ function saveIdpAuth(auth) {
1142
1153
  const merged = { ...extra, ...auth };
1143
1154
  writeFileSync(file, JSON.stringify(merged, null, 2), { mode: 384 });
1144
1155
  }
1156
+ function audToFilename(aud) {
1157
+ return aud.replace(/[^\w.-]/g, "_");
1158
+ }
1159
+ function spTokenPath(aud) {
1160
+ return join(getSpTokensDir(), `${audToFilename(aud)}.json`);
1161
+ }
1162
+ function loadSpToken(aud) {
1163
+ const path = spTokenPath(aud);
1164
+ if (!existsSync(path)) return null;
1165
+ try {
1166
+ const raw = readFileSync(path, "utf-8");
1167
+ if (!raw.trim()) return null;
1168
+ return JSON.parse(raw);
1169
+ } catch {
1170
+ return null;
1171
+ }
1172
+ }
1173
+ function saveSpToken(token) {
1174
+ ensureSpTokensDir();
1175
+ writeFileSync(spTokenPath(token.aud), JSON.stringify(token, null, 2), { mode: 384 });
1176
+ }
1145
1177
  var AuthError = class extends Error {
1146
1178
  status;
1147
1179
  hint;
@@ -1163,6 +1195,39 @@ var NotLoggedInError = class extends AuthError {
1163
1195
  this.name = "NotLoggedInError";
1164
1196
  }
1165
1197
  };
1198
+ async function exchangeForSpToken(idpAuth, request, now = Math.floor(Date.now() / 1e3)) {
1199
+ const url = `${request.endpoint.replace(/\/$/, "")}/api/cli/exchange`;
1200
+ let response;
1201
+ try {
1202
+ response = await ofetch(url, {
1203
+ method: "POST",
1204
+ body: {
1205
+ subject_token: idpAuth.access_token,
1206
+ ...request.scopes ? { scopes: request.scopes } : {}
1207
+ }
1208
+ });
1209
+ } catch (err) {
1210
+ const status = err.status ?? err.statusCode ?? 0;
1211
+ const data = err.data;
1212
+ const title = data?.title ?? `Token exchange failed (HTTP ${status})`;
1213
+ const hint = status === 401 ? `IdP token rejected at ${url}. Try \`apes login\` again \u2014 token may be expired or audience-mismatched.` : data?.detail;
1214
+ throw new AuthError(status, title, hint);
1215
+ }
1216
+ if (!response.access_token) {
1217
+ throw new AuthError(0, `Exchange response from ${url} missing access_token`);
1218
+ }
1219
+ const expiresAt = response.expires_at ?? (response.expires_in ? now + response.expires_in : now + 3600);
1220
+ const token = {
1221
+ endpoint: request.endpoint,
1222
+ aud: response.aud ?? request.aud,
1223
+ access_token: response.access_token,
1224
+ expires_at: expiresAt,
1225
+ ...request.scopes ? { scopes: request.scopes } : {},
1226
+ issued_from_idp_iat: now
1227
+ };
1228
+ saveSpToken(token);
1229
+ return token;
1230
+ }
1166
1231
  var OPENSSH_MAGIC = "openssh-key-v1\0";
1167
1232
  function loadEd25519PrivateKey(pem) {
1168
1233
  if (pem.includes("BEGIN OPENSSH PRIVATE KEY")) {
@@ -1318,8 +1383,8 @@ async function getTokenEndpoint(idp) {
1318
1383
  }
1319
1384
  return `${idp}/token`;
1320
1385
  }
1321
- async function ensureFreshIdpAuth(now = Math.floor(Date.now() / 1e3)) {
1322
- const auth = loadIdpAuth();
1386
+ async function ensureFreshIdpAuth(now = Math.floor(Date.now() / 1e3), authHome) {
1387
+ const auth = loadIdpAuth(authHome);
1323
1388
  if (!auth) {
1324
1389
  throw new NotLoggedInError();
1325
1390
  }
@@ -1329,7 +1394,7 @@ async function ensureFreshIdpAuth(now = Math.floor(Date.now() / 1e3)) {
1329
1394
  if (!auth.refresh_token) {
1330
1395
  const refreshed = await refreshAgentToken(auth, now);
1331
1396
  if (refreshed) {
1332
- saveIdpAuth(refreshed);
1397
+ saveIdpAuth(refreshed, authHome);
1333
1398
  return refreshed;
1334
1399
  }
1335
1400
  throw new NotLoggedInError(
@@ -1351,7 +1416,7 @@ async function ensureFreshIdpAuth(now = Math.floor(Date.now() / 1e3)) {
1351
1416
  } catch (err) {
1352
1417
  const status = err.status ?? err.statusCode ?? 0;
1353
1418
  if (status === 400 || status === 401) {
1354
- saveIdpAuth({ ...auth, refresh_token: void 0 });
1419
+ saveIdpAuth({ ...auth, refresh_token: void 0 }, authHome);
1355
1420
  throw new NotLoggedInError(
1356
1421
  `Refresh token rejected by ${auth.idp}. Run \`apes login\` again.`
1357
1422
  );
@@ -1371,9 +1436,26 @@ async function ensureFreshIdpAuth(now = Math.floor(Date.now() / 1e3)) {
1371
1436
  refresh_token: response.refresh_token ?? auth.refresh_token,
1372
1437
  expires_at: now + (response.expires_in ?? 3600)
1373
1438
  };
1374
- saveIdpAuth(next);
1439
+ saveIdpAuth(next, authHome);
1375
1440
  return next;
1376
1441
  }
1442
+ var SP_TOKEN_SKEW_SECONDS = 60;
1443
+ async function getAuthorizedBearer(opts) {
1444
+ const now = Math.floor(Date.now() / 1e3);
1445
+ if (!opts.forceRefresh) {
1446
+ const cached = loadSpToken(opts.aud);
1447
+ if (cached && cached.expires_at > now + SP_TOKEN_SKEW_SECONDS) {
1448
+ return `Bearer ${cached.access_token}`;
1449
+ }
1450
+ }
1451
+ const idpAuth = await ensureFreshIdpAuth(now);
1452
+ const sp = await exchangeForSpToken(idpAuth, {
1453
+ endpoint: opts.endpoint,
1454
+ aud: opts.aud,
1455
+ ...opts.scopes ? { scopes: opts.scopes } : {}
1456
+ }, now);
1457
+ return `Bearer ${sp.access_token}`;
1458
+ }
1377
1459
 
1378
1460
  // ../../packages/prompt-injection-detector/dist/index.js
1379
1461
  var DEFAULT_THRESHOLD = 0.7;
@@ -1439,152 +1521,69 @@ function createHeuristicDetector() {
1439
1521
  import { decodeJwt } from "jose";
1440
1522
  import WebSocket from "ws";
1441
1523
 
1442
- // src/troop-chat-api.ts
1443
- import { ofetch as ofetch6 } from "ofetch";
1444
- var MAX_BODY = 64 * 1024;
1445
- var SYNTHETIC_THREAD_ID = "main";
1446
- function asHistory(msg, agentEmail, ownerEmail) {
1447
- return {
1448
- id: msg.id,
1449
- roomId: msg.chatId,
1450
- threadId: SYNTHETIC_THREAD_ID,
1451
- senderEmail: msg.role === "agent" ? agentEmail : ownerEmail,
1452
- senderAct: msg.role,
1453
- body: msg.body,
1454
- replyTo: msg.replyTo,
1455
- createdAt: msg.createdAt
1456
- };
1457
- }
1458
- function asPosted(msg) {
1459
- return {
1460
- id: msg.id,
1461
- roomId: msg.chatId,
1462
- threadId: SYNTHETIC_THREAD_ID,
1463
- body: msg.body,
1464
- createdAt: msg.createdAt
1465
- };
1466
- }
1467
- var TroopChatApi = class {
1468
- constructor(endpoint, bearer) {
1469
- this.endpoint = endpoint;
1470
- this.bearer = bearer;
1471
- }
1472
- endpoint;
1473
- bearer;
1474
- bootstrap = null;
1475
- /** Resolve + cache the agent's chat row (lazy fetch on first use). */
1476
- async getBootstrap() {
1477
- if (this.bootstrap) return this.bootstrap;
1478
- this.bootstrap = await ofetch6(`${this.endpoint}/api/agents/me/chat`, {
1479
- method: "GET",
1480
- headers: { Authorization: await this.bearer() }
1481
- });
1482
- return this.bootstrap;
1483
- }
1484
- /** chat.id + (lazy-fetched) ownerEmail for the bridge's frame-translation path. */
1485
- async getChatContext() {
1486
- const b2 = await this.getBootstrap();
1487
- return { chatId: b2.chat.id, ownerEmail: b2.chat.ownerEmail, agentEmail: b2.chat.agentEmail };
1488
- }
1489
- async postMessage(roomId, body, opts = {}) {
1490
- void roomId;
1491
- void opts.threadId;
1492
- const payload = {
1493
- body: body.length > MAX_BODY ? `${body.slice(0, MAX_BODY - 1)}\u2026` : body
1494
- };
1495
- if (opts.replyTo) payload.reply_to = opts.replyTo;
1496
- if (opts.streaming) payload.streaming = true;
1497
- const msg = await ofetch6(`${this.endpoint}/api/agents/me/chat/messages`, {
1498
- method: "POST",
1499
- headers: { Authorization: await this.bearer() },
1500
- body: payload
1501
- });
1502
- return asPosted(msg);
1503
- }
1504
- async listMessages(roomId, threadId, limit = 50) {
1505
- void roomId;
1506
- void threadId;
1507
- void limit;
1508
- const fresh = await ofetch6(`${this.endpoint}/api/agents/me/chat`, {
1509
- method: "GET",
1510
- headers: { Authorization: await this.bearer() }
1511
- });
1512
- this.bootstrap = fresh;
1513
- return fresh.messages.map((m2) => asHistory(m2, fresh.chat.agentEmail, fresh.chat.ownerEmail));
1514
- }
1515
- async patchMessage(messageId, opts = {}) {
1516
- const payload = {};
1517
- if (opts.body !== void 0) {
1518
- payload.body = opts.body.length > MAX_BODY ? `${opts.body.slice(0, MAX_BODY - 1)}\u2026` : opts.body;
1519
- }
1520
- if (opts.streaming !== void 0) payload.streaming = opts.streaming;
1521
- if (opts.streamingStatus !== void 0) payload.streaming_status = opts.streamingStatus;
1522
- if (Object.keys(payload).length === 0) return;
1523
- await ofetch6(`${this.endpoint}/api/agents/me/chat/messages/${encodeURIComponent(messageId)}`, {
1524
- method: "PATCH",
1525
- headers: { Authorization: await this.bearer() },
1526
- body: payload
1527
- });
1528
- }
1529
- /**
1530
- * Troop's chat doesn't have contacts — synthesize a single
1531
- * always-connected entry pointing at the owner so the bridge's
1532
- * initial-contact + allowlist flows are no-ops.
1533
- */
1534
- async listContacts() {
1535
- const b2 = await this.getBootstrap();
1536
- return [{
1537
- peerEmail: b2.chat.ownerEmail,
1538
- myStatus: "accepted",
1539
- theirStatus: "accepted",
1540
- connected: true,
1541
- roomId: b2.chat.id
1542
- }];
1543
- }
1544
- async requestContact(peerEmail) {
1545
- void peerEmail;
1546
- return (await this.listContacts())[0];
1547
- }
1548
- async acceptContact(peerEmail) {
1549
- void peerEmail;
1550
- return (await this.listContacts())[0];
1551
- }
1552
- /**
1553
- * Troop has no threads — return a synthetic one. The bridge's
1554
- * cron-runner falls back to the main thread on createThread
1555
- * failure already, so a stable "main" stand-in is the right shape.
1556
- */
1557
- async createThread(roomId, name) {
1558
- void roomId;
1559
- return { id: SYNTHETIC_THREAD_ID, name: name.slice(0, 100) };
1560
- }
1561
- };
1562
-
1563
- // src/cron-runner.ts
1564
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
1565
- import { homedir as homedir6 } from "os";
1566
- import { join as join4 } from "path";
1567
-
1568
- // ../../packages/apes/dist/chunk-BA2V3BBO.js
1569
- init_chunk_OBF7IMQ2();
1570
-
1571
- // ../../node_modules/.pnpm/citty@0.2.2/node_modules/citty/dist/index.mjs
1572
- import { parseArgs as parseArgs$1 } from "util";
1573
- function defineCommand(def) {
1574
- return def;
1575
- }
1576
-
1577
- // ../../packages/shapes/dist/index.js
1578
- import { createHash } from "crypto";
1579
- import { existsSync as existsSync22, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
1580
- import { homedir as homedir22 } from "os";
1581
- import { basename, join as join22 } from "path";
1524
+ // ../../packages/apes/dist/chunk-3LH4FT4R.js
1525
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync, watch, writeFileSync as writeFileSync2 } from "fs";
1526
+ import { homedir as homedir3 } from "os";
1527
+ import { dirname, join as join3 } from "path";
1582
1528
 
1583
1529
  // ../../packages/core/dist/index.js
1584
1530
  import * as jose from "jose";
1531
+ import {
1532
+ createCipheriv,
1533
+ createDecipheriv,
1534
+ createPrivateKey as createPrivateKey2,
1535
+ createPublicKey,
1536
+ diffieHellman,
1537
+ generateKeyPairSync,
1538
+ hkdfSync,
1539
+ randomBytes
1540
+ } from "crypto";
1585
1541
  import { lookup } from "dns/promises";
1586
1542
  import { isIP } from "net";
1587
1543
  var HKDF_INFO = new TextEncoder().encode("openape-sealed-box-v1");
1544
+ var IV_LEN = 12;
1545
+ var TAG_LEN = 16;
1546
+ var RAW_KEY_LEN = 32;
1547
+ function b64u(data) {
1548
+ return Buffer.from(data).toString("base64url");
1549
+ }
1550
+ function unb64u(s2) {
1551
+ return Buffer.from(s2, "base64url");
1552
+ }
1553
+ function rawPub(key) {
1554
+ const pub = key.type === "private" ? createPublicKey(key) : key;
1555
+ const jwk = pub.export({ format: "jwk" });
1556
+ return unb64u(jwk.x);
1557
+ }
1558
+ function ephPublicFromRaw(raw) {
1559
+ return createPublicKey({
1560
+ key: { kty: "OKP", crv: "X25519", x: b64u(raw) },
1561
+ format: "jwk"
1562
+ });
1563
+ }
1564
+ function deriveKey(shared, ephPubRaw, recipPubRaw) {
1565
+ const salt = Buffer.concat([ephPubRaw, recipPubRaw]);
1566
+ return Buffer.from(hkdfSync("sha256", shared, salt, HKDF_INFO, 32));
1567
+ }
1568
+ function open(box2, recipientPrivateKey) {
1569
+ if (box2.v !== 1) throw new Error(`unsupported sealed-box version: ${box2.v}`);
1570
+ const epkRaw = unb64u(box2.epk);
1571
+ if (epkRaw.length !== RAW_KEY_LEN) throw new Error("invalid ephemeral public key length");
1572
+ const iv = unb64u(box2.iv);
1573
+ if (iv.length !== IV_LEN) throw new Error("invalid IV length");
1574
+ const tag = unb64u(box2.tag);
1575
+ if (tag.length !== TAG_LEN) throw new Error("invalid auth tag length");
1576
+ const recipPriv = createPrivateKey2({ key: unb64u(recipientPrivateKey), format: "der", type: "pkcs8" });
1577
+ const ephPub = ephPublicFromRaw(epkRaw);
1578
+ const shared = diffieHellman({ privateKey: recipPriv, publicKey: ephPub });
1579
+ const key = deriveKey(shared, epkRaw, rawPub(recipPriv));
1580
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
1581
+ decipher.setAuthTag(tag);
1582
+ return Buffer.concat([decipher.update(unb64u(box2.ct)), decipher.final()]);
1583
+ }
1584
+ function openString(box2, recipientPrivateKey) {
1585
+ return Buffer.from(open(box2, recipientPrivateKey)).toString("utf8");
1586
+ }
1588
1587
  function isBlockedAddress(ip) {
1589
1588
  const fam = isIP(ip);
1590
1589
  if (fam === 4) {
@@ -1639,6 +1638,100 @@ async function assertPublicUrl(rawUrl, opts = {}) {
1639
1638
  return url;
1640
1639
  }
1641
1640
 
1641
+ // ../../packages/apes/dist/chunk-3LH4FT4R.js
1642
+ var CONFIG_DIR = join3(homedir3(), ".config", "openape");
1643
+ var SECRETS_DIR = join3(CONFIG_DIR, "secrets.d");
1644
+ var X25519_KEY_PATH = join3(CONFIG_DIR, "agent-x25519.key");
1645
+ var X25519_PUBKEY_PATH = `${X25519_KEY_PATH}.pub`;
1646
+ function envNameFromFile(file) {
1647
+ if (!file.endsWith(".blob")) return null;
1648
+ const env2 = file.slice(0, -".blob".length);
1649
+ return /^[A-Z][A-Z0-9_]*$/.test(env2) ? env2 : null;
1650
+ }
1651
+ function readAgentEncryptionKey(keyPath = X25519_KEY_PATH) {
1652
+ if (!existsSync3(keyPath)) return null;
1653
+ const k2 = readFileSync3(keyPath, "utf8").trim();
1654
+ return k2.length > 0 ? k2 : null;
1655
+ }
1656
+ function materializeSecrets(opts = {}) {
1657
+ const dir = opts.dir ?? SECRETS_DIR;
1658
+ const env2 = opts.env ?? process.env;
1659
+ const log2 = opts.log ?? (() => {
1660
+ });
1661
+ const applied = [];
1662
+ const failed = [];
1663
+ const key = readAgentEncryptionKey(opts.keyPath);
1664
+ const files = key && existsSync3(dir) ? readdirSync2(dir) : [];
1665
+ for (const file of files) {
1666
+ const name = envNameFromFile(file);
1667
+ if (!name) continue;
1668
+ try {
1669
+ const box2 = JSON.parse(readFileSync3(join3(dir, file), "utf8"));
1670
+ const plaintext = openString(box2, key);
1671
+ const target = typeof box2.materializeTo === "string" ? box2.materializeTo : null;
1672
+ if (target) {
1673
+ const blobMtime = statSync(join3(dir, file)).mtimeMs;
1674
+ if (!existsSync3(target) || statSync(target).mtimeMs < blobMtime) {
1675
+ mkdirSync2(dirname(target), { recursive: true });
1676
+ writeFileSync2(target, plaintext, { mode: 384 });
1677
+ }
1678
+ } else {
1679
+ env2[name] = plaintext;
1680
+ }
1681
+ applied.push(name);
1682
+ } catch (e2) {
1683
+ failed.push(file);
1684
+ log2(`secrets: failed to open ${file}: ${e2.message}`);
1685
+ }
1686
+ }
1687
+ const live = new Set(applied);
1688
+ for (const prev of opts.previouslyApplied ?? []) {
1689
+ if (!live.has(prev)) {
1690
+ delete env2[prev];
1691
+ log2(`secrets: revoked ${prev}`);
1692
+ }
1693
+ }
1694
+ return { applied, failed };
1695
+ }
1696
+ function startSecretsWatcher(opts = {}) {
1697
+ const dir = opts.dir ?? SECRETS_DIR;
1698
+ const log2 = opts.log ?? (() => {
1699
+ });
1700
+ let appliedNames = /* @__PURE__ */ new Set();
1701
+ const run = () => {
1702
+ const r3 = materializeSecrets({ ...opts, previouslyApplied: appliedNames });
1703
+ appliedNames = new Set(r3.applied);
1704
+ };
1705
+ run();
1706
+ if (!existsSync3(dir)) return () => {
1707
+ };
1708
+ let timer = null;
1709
+ const watcher = watch(dir, () => {
1710
+ if (timer) clearTimeout(timer);
1711
+ timer = setTimeout(run, 150);
1712
+ });
1713
+ watcher.on("error", (err) => log2(`secrets: watcher error: ${err.message}`));
1714
+ return () => {
1715
+ if (timer) clearTimeout(timer);
1716
+ watcher.close();
1717
+ };
1718
+ }
1719
+
1720
+ // ../../packages/apes/dist/chunk-BA2V3BBO.js
1721
+ init_chunk_OBF7IMQ2();
1722
+
1723
+ // ../../node_modules/.pnpm/citty@0.2.2/node_modules/citty/dist/index.mjs
1724
+ import { parseArgs as parseArgs$1 } from "util";
1725
+ function defineCommand(def) {
1726
+ return def;
1727
+ }
1728
+
1729
+ // ../../packages/shapes/dist/index.js
1730
+ import { createHash } from "crypto";
1731
+ import { existsSync as existsSync22, readdirSync as readdirSync3, readFileSync as readFileSync4 } from "fs";
1732
+ import { homedir as homedir22 } from "os";
1733
+ import { basename, join as join22 } from "path";
1734
+
1642
1735
  // ../../packages/grants/dist/index.js
1643
1736
  function normalizeSelector(selector) {
1644
1737
  if (!selector)
@@ -2407,20 +2500,20 @@ var isColorSupported = !isDisabled && (isForced || isWindows && !isDumbTerminal
2407
2500
  function replaceClose(index, string, close, replace, head = string.slice(0, Math.max(0, index)) + replace, tail = string.slice(Math.max(0, index + close.length)), next = tail.indexOf(close)) {
2408
2501
  return head + (next < 0 ? tail : replaceClose(next, tail, close, replace));
2409
2502
  }
2410
- function clearBleed(index, string, open, close, replace) {
2411
- return index < 0 ? open + string + close : open + replaceClose(index, string, close, replace) + close;
2503
+ function clearBleed(index, string, open2, close, replace) {
2504
+ return index < 0 ? open2 + string + close : open2 + replaceClose(index, string, close, replace) + close;
2412
2505
  }
2413
- function filterEmpty(open, close, replace = open, at = open.length + 1) {
2506
+ function filterEmpty(open2, close, replace = open2, at = open2.length + 1) {
2414
2507
  return (string) => string || !(string === "" || string === void 0) ? clearBleed(
2415
2508
  ("" + string).indexOf(close, at),
2416
2509
  string,
2417
- open,
2510
+ open2,
2418
2511
  close,
2419
2512
  replace
2420
2513
  ) : "";
2421
2514
  }
2422
- function init(open, close, replace) {
2423
- return filterEmpty(`\x1B[${open}m`, `\x1B[${close}m`, replace);
2515
+ function init(open2, close, replace) {
2516
+ return filterEmpty(`\x1B[${open2}m`, `\x1B[${close}m`, replace);
2424
2517
  }
2425
2518
  var colorDefs = {
2426
2519
  reset: init(0, 0),
@@ -3040,10 +3133,10 @@ function findByExecutable(executable) {
3040
3133
  if (!existsSync22(dir))
3041
3134
  continue;
3042
3135
  try {
3043
- const files = readdirSync2(dir).filter((f3) => f3.endsWith(".toml"));
3136
+ const files = readdirSync3(dir).filter((f3) => f3.endsWith(".toml"));
3044
3137
  for (const file of files) {
3045
3138
  const path = join22(dir, file);
3046
- const content = readFileSync3(path, "utf-8");
3139
+ const content = readFileSync4(path, "utf-8");
3047
3140
  const match = content.match(/^\s*executable\s*=\s*"([^"]+)"/m);
3048
3141
  if (match && match[1] === executable)
3049
3142
  return path;
@@ -3070,7 +3163,7 @@ function resolveAdapterPath(cliId, explicitPath) {
3070
3163
  }
3071
3164
  function loadAdapter(cliId, explicitPath) {
3072
3165
  const source = resolveAdapterPath(cliId, explicitPath);
3073
- const content = readFileSync3(source, "utf-8");
3166
+ const content = readFileSync4(source, "utf-8");
3074
3167
  const adapter = parseAdapterToml(content);
3075
3168
  const idMatch = adapter.cli.id === cliId;
3076
3169
  const fileMatch = basename(source) === `${cliId}.toml`;
@@ -3180,13 +3273,16 @@ init_chunk_OBF7IMQ2();
3180
3273
 
3181
3274
  // ../../packages/agent-runtime/dist/index.js
3182
3275
  import { spawn } from "child_process";
3183
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
3184
- import { homedir as homedir4 } from "os";
3185
- import { dirname, normalize, resolve } from "path";
3276
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
3277
+ import { homedir as homedir6 } from "os";
3278
+ import { dirname as dirname2, normalize, resolve } from "path";
3186
3279
  import { homedir as homedir23 } from "os";
3187
3280
  import { resolve as resolve2 } from "path";
3188
3281
  import process2 from "process";
3189
3282
  import { execFileSync } from "child_process";
3283
+ import { readFileSync as readFileSync22 } from "fs";
3284
+ import { homedir as homedir32 } from "os";
3285
+ import { join as join6 } from "path";
3190
3286
  import { execFileSync as execFileSync2 } from "child_process";
3191
3287
  var DEFAULT_TIMEOUT_MS = 5 * 60 * 1e3;
3192
3288
  var MAX_STDIO_BYTES = 64 * 1024;
@@ -3280,21 +3376,31 @@ var bashTools = [
3280
3376
  }
3281
3377
  ];
3282
3378
  var MAX_BYTES = 1024 * 1024;
3283
- function jailPath(input) {
3379
+ var extraReadRoots = /* @__PURE__ */ new Set();
3380
+ function addReadRoot(absPath) {
3381
+ if (typeof absPath === "string" && absPath.startsWith("/")) extraReadRoots.add(normalize(absPath));
3382
+ }
3383
+ function isUnder(candidate, root) {
3384
+ return candidate === root || candidate.startsWith(`${root}/`);
3385
+ }
3386
+ function jailPath(input, opts = {}) {
3284
3387
  if (typeof input !== "string" || input === "") {
3285
3388
  throw new Error("path must be a non-empty string");
3286
3389
  }
3287
- const home = homedir4();
3390
+ const home = homedir6();
3288
3391
  const candidate = input.startsWith("~/") ? resolve(home, input.slice(2)) : input.startsWith("/") ? normalize(input) : resolve(home, input);
3289
- if (candidate !== home && !candidate.startsWith(`${home}/`)) {
3290
- throw new Error(`path "${input}" resolves outside the agent's home`);
3392
+ if (isUnder(candidate, home)) return candidate;
3393
+ if (opts.allowReadRoots) {
3394
+ for (const root of extraReadRoots) {
3395
+ if (isUnder(candidate, root)) return candidate;
3396
+ }
3291
3397
  }
3292
- return candidate;
3398
+ throw new Error(`path "${input}" resolves outside the agent's home`);
3293
3399
  }
3294
3400
  var fileTools = [
3295
3401
  {
3296
3402
  name: "file.read",
3297
- description: "Read a UTF-8 file from the agent's home directory ($HOME). Capped at 1MB. Path traversal blocked.",
3403
+ description: "Read a UTF-8 file from the agent's home directory ($HOME) or a bundled skill directory (e.g. a skill's SKILL.md). Capped at 1MB. Path traversal blocked.",
3298
3404
  parameters: {
3299
3405
  type: "object",
3300
3406
  properties: {
@@ -3304,8 +3410,8 @@ var fileTools = [
3304
3410
  },
3305
3411
  execute: async (args) => {
3306
3412
  const a2 = args;
3307
- const p = jailPath(a2.path);
3308
- const content = readFileSync4(p, "utf8");
3413
+ const p = jailPath(a2.path, { allowReadRoots: true });
3414
+ const content = readFileSync5(p, "utf8");
3309
3415
  if (Buffer.byteLength(content, "utf8") > MAX_BYTES) {
3310
3416
  return { path: p, truncated: true, content: content.slice(0, MAX_BYTES) };
3311
3417
  }
@@ -3330,8 +3436,8 @@ var fileTools = [
3330
3436
  throw new Error(`content exceeds ${MAX_BYTES} byte cap`);
3331
3437
  }
3332
3438
  const p = jailPath(a2.path);
3333
- mkdirSync2(dirname(p), { recursive: true });
3334
- writeFileSync2(p, a2.content, { encoding: "utf8" });
3439
+ mkdirSync3(dirname2(p), { recursive: true });
3440
+ writeFileSync3(p, a2.content, { encoding: "utf8" });
3335
3441
  return { path: p, bytes: Buffer.byteLength(a2.content, "utf8") };
3336
3442
  }
3337
3443
  },
@@ -3361,7 +3467,7 @@ var fileTools = [
3361
3467
  }
3362
3468
  const replaceAll = a2.replace_all === true;
3363
3469
  const p = jailPath(a2.path);
3364
- const before = readFileSync4(p, "utf8");
3470
+ const before = readFileSync5(p, "utf8");
3365
3471
  const occurrences = before.split(a2.old_string).length - 1;
3366
3472
  if (occurrences === 0) {
3367
3473
  throw new Error("old_string not found in file");
@@ -3373,7 +3479,7 @@ var fileTools = [
3373
3479
  if (Buffer.byteLength(after, "utf8") > MAX_BYTES) {
3374
3480
  throw new Error(`result exceeds ${MAX_BYTES} byte cap`);
3375
3481
  }
3376
- writeFileSync2(p, after, { encoding: "utf8" });
3482
+ writeFileSync3(p, after, { encoding: "utf8" });
3377
3483
  return { path: p, replacements: replaceAll ? occurrences : 1 };
3378
3484
  }
3379
3485
  }
@@ -3846,6 +3952,99 @@ var mailTools = [
3846
3952
  }
3847
3953
  }
3848
3954
  ];
3955
+ function troopBase() {
3956
+ return (process.env.OPENAPE_TROOP_URL ?? "https://troop.openape.ai").replace(/\/+$/, "");
3957
+ }
3958
+ function readAgentToken() {
3959
+ const path = process.env.OPENAPE_CLI_AUTH_HOME ? join6(process.env.OPENAPE_CLI_AUTH_HOME, "auth.json") : join6(homedir32(), ".config", "apes", "auth.json");
3960
+ const auth = JSON.parse(readFileSync22(path, "utf8"));
3961
+ if (!auth.access_token) throw new Error(`no access_token in ${path}`);
3962
+ return auth.access_token;
3963
+ }
3964
+ async function exchangeBearer(base, scope) {
3965
+ const res = await fetch(`${base}/api/cli/exchange`, {
3966
+ method: "POST",
3967
+ headers: { "content-type": "application/json" },
3968
+ body: JSON.stringify({ subject_token: readAgentToken(), scopes: [scope] })
3969
+ });
3970
+ if (!res.ok) {
3971
+ throw new Error(`token exchange ${res.status} \u2014 ${(await res.text().catch(() => "")).slice(0, 200)} (does this agent hold the ${scope} scope?)`);
3972
+ }
3973
+ return (await res.json()).access_token;
3974
+ }
3975
+ var spawnTools = [
3976
+ {
3977
+ name: "agent.spawn",
3978
+ description: "Spawn a worker agent on the nest via troop, tiering its compute by task difficulty: pick `model` (gpt-5.4-mini | gpt-5.4 | gpt-5.5) and `reasoning_effort` (minimal | low | medium | high) \u2014 quick-win = cheap+low, research/architecture = gpt-5.5+high. Optionally attach a `recipe_ref` so the worker runs a known persona. Returns the spawn intent id. Use multiple calls to fan out several workers in parallel.",
3979
+ parameters: {
3980
+ type: "object",
3981
+ properties: {
3982
+ name: { type: "string", description: "unique worker name, /^[a-z][a-z0-9-]{0,23}$/" },
3983
+ model: { type: "string", description: "gpt-5.4-mini | gpt-5.4 | gpt-5.5" },
3984
+ reasoning_effort: { type: "string", description: "minimal | low | medium | high" },
3985
+ recipe_ref: { type: "string", description: "optional recipe, e.g. github.com/openape-ai/agent-catalog/backend-engineer@v0.2.0" },
3986
+ system_prompt: { type: "string", description: "optional system prompt / task brief" }
3987
+ },
3988
+ required: ["name"]
3989
+ },
3990
+ execute: async (args) => {
3991
+ const a2 = args;
3992
+ const base = troopBase();
3993
+ let bearer;
3994
+ try {
3995
+ bearer = await exchangeBearer(base, "troop:spawn-agent");
3996
+ } catch (err) {
3997
+ return `spawn failed: ${err instanceof Error ? err.message : String(err)}`;
3998
+ }
3999
+ const body = { name: a2.name };
4000
+ if (a2.model) body.bridge_model = a2.model;
4001
+ if (a2.reasoning_effort) body.bridge_reasoning_effort = a2.reasoning_effort;
4002
+ if (a2.system_prompt) body.system_prompt = a2.system_prompt;
4003
+ if (a2.recipe_ref) body.recipe = { repo_ref: a2.recipe_ref, params: {} };
4004
+ const spRes = await fetch(`${base}/api/agents/spawn-intent`, {
4005
+ method: "POST",
4006
+ headers: { "content-type": "application/json", authorization: `Bearer ${bearer}` },
4007
+ body: JSON.stringify(body)
4008
+ });
4009
+ if (!spRes.ok) {
4010
+ return `spawn failed: spawn-intent ${spRes.status} \u2014 ${(await spRes.text().catch(() => "")).slice(0, 200)}`;
4011
+ }
4012
+ const sp = await spRes.json();
4013
+ return `spawned worker "${a2.name}" (model=${a2.model ?? "default"}, reasoning=${a2.reasoning_effort ?? "default"}); intent=${sp.intent_id ?? "?"}`;
4014
+ }
4015
+ },
4016
+ {
4017
+ name: "agent.destroy",
4018
+ description: "Destroy a worker agent on the nest (full teardown: OS user, IdP, bridge). The PM calls this after collecting an ephemeral worker's result, so workers do not linger idle. Requires the troop:destroy-agent scope.",
4019
+ parameters: {
4020
+ type: "object",
4021
+ properties: {
4022
+ name: { type: "string", description: "the worker agent name to destroy" }
4023
+ },
4024
+ required: ["name"]
4025
+ },
4026
+ execute: async (args) => {
4027
+ const a2 = args;
4028
+ const base = troopBase();
4029
+ let bearer;
4030
+ try {
4031
+ bearer = await exchangeBearer(base, "troop:destroy-agent");
4032
+ } catch (err) {
4033
+ return `destroy failed: ${err instanceof Error ? err.message : String(err)}`;
4034
+ }
4035
+ const res = await fetch(`${base}/api/agents/destroy-intent`, {
4036
+ method: "POST",
4037
+ headers: { "content-type": "application/json", authorization: `Bearer ${bearer}` },
4038
+ body: JSON.stringify({ name: a2.name })
4039
+ });
4040
+ if (!res.ok) {
4041
+ return `destroy failed: destroy-intent ${res.status} \u2014 ${(await res.text().catch(() => "")).slice(0, 200)}`;
4042
+ }
4043
+ const d2 = await res.json();
4044
+ return `destroying worker "${a2.name}"; intent=${d2.intent_id ?? "?"}`;
4045
+ }
4046
+ }
4047
+ ];
3849
4048
  function ape(args) {
3850
4049
  try {
3851
4050
  return execFileSync2("ape-tasks", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
@@ -3882,14 +4081,17 @@ var tasksTools = [
3882
4081
  },
3883
4082
  {
3884
4083
  name: "tasks.create",
3885
- description: "Create a new ape-task on the owner's task list at tasks.openape.ai.",
4084
+ description: "Create a new ape-task at tasks.openape.ai. Pass `team` (the team id) to file it on a shared team board, and `assignee` (an email) to delegate it to a teammate.",
3886
4085
  parameters: {
3887
4086
  type: "object",
3888
4087
  properties: {
3889
4088
  title: { type: "string" },
3890
4089
  notes: { type: "string" },
3891
4090
  priority: { type: "string", enum: ["low", "med", "high"] },
3892
- due_at: { type: "string", description: "ISO date or +Nh/+Nd shorthand." }
4091
+ due_at: { type: "string", description: "ISO date or +Nh/+Nd shorthand." },
4092
+ team: { type: "string", description: "Team id to file the task on (required when you belong to a team)." },
4093
+ assignee: { type: "string", description: "Email of the teammate to assign the task to." },
4094
+ dedup_key: { type: "string", description: "Stable id for the source (e.g. a mail Message-ID). If an open task with this key already exists, no duplicate is created \u2014 pass it for recurring triage so the same item is not filed twice." }
3893
4095
  },
3894
4096
  required: ["title"]
3895
4097
  },
@@ -3899,6 +4101,9 @@ var tasksTools = [
3899
4101
  if (a2.notes) argv2.push("--notes", a2.notes);
3900
4102
  if (a2.priority) argv2.push("--priority", a2.priority);
3901
4103
  if (a2.due_at) argv2.push("--due", a2.due_at);
4104
+ if (a2.team) argv2.push("--team", a2.team);
4105
+ if (a2.assignee) argv2.push("--assignee", a2.assignee);
4106
+ if (a2.dedup_key) argv2.push("--dedup-key", a2.dedup_key);
3902
4107
  const out = ape(argv2);
3903
4108
  try {
3904
4109
  return JSON.parse(out);
@@ -3923,6 +4128,83 @@ var timeTools = [
3923
4128
  }
3924
4129
  }
3925
4130
  ];
4131
+ var TROOP = "https://troop.openape.ai";
4132
+ var RESOURCES = ["objectives", "reports", "members", "cost-snapshots", "overview"];
4133
+ function pathFor(resource, orgId) {
4134
+ const id = encodeURIComponent(orgId);
4135
+ return resource === "overview" ? `/api/orgs/${id}` : `/api/orgs/${id}/${resource}`;
4136
+ }
4137
+ var OBJECTIVE_STATUS = ["planned", "in_progress", "done", "abandoned"];
4138
+ var troopTools = [
4139
+ {
4140
+ name: "troop.company.read",
4141
+ description: "Read your troop company data on troop.openape.ai. resource: objectives | reports | members | cost-snapshots | overview (vision+budget). Read-only.",
4142
+ parameters: {
4143
+ type: "object",
4144
+ properties: {
4145
+ resource: { type: "string", enum: [...RESOURCES], description: "Which company resource to read." },
4146
+ org_id: { type: "string", description: "Your company (org) id." }
4147
+ },
4148
+ required: ["resource", "org_id"]
4149
+ },
4150
+ execute: async (args) => {
4151
+ const { resource, org_id } = args ?? {};
4152
+ if (!resource || !RESOURCES.includes(resource)) {
4153
+ throw new Error(`troop.company.read: unknown resource '${resource}' (expected ${RESOURCES.join(" | ")})`);
4154
+ }
4155
+ if (!org_id) throw new Error("troop.company.read: org_id is required");
4156
+ const bearer = await getAuthorizedBearer({ endpoint: TROOP, aud: "troop.openape.ai" });
4157
+ const res = await fetch(`${TROOP}${pathFor(resource, org_id)}`, {
4158
+ headers: { authorization: bearer }
4159
+ });
4160
+ if (!res.ok) {
4161
+ throw new Error(`troop.company.read ${resource} \u2192 ${res.status}: ${(await res.text()).slice(0, 200)}`);
4162
+ }
4163
+ return JSON.stringify(await res.json());
4164
+ }
4165
+ },
4166
+ {
4167
+ name: "troop.objective.upsert",
4168
+ description: "Create or update a company objective on troop.openape.ai. Pass objective_id to update an existing one; omit it to create. Authenticated as the agent (acting for the owner).",
4169
+ parameters: {
4170
+ type: "object",
4171
+ properties: {
4172
+ org_id: { type: "string", description: "Your company (org) id." },
4173
+ objective_id: { type: "string", description: "Omit to create; pass to update an existing objective." },
4174
+ title: { type: "string" },
4175
+ description: { type: "string" },
4176
+ status: { type: "string", enum: [...OBJECTIVE_STATUS] },
4177
+ target_date: { type: "number", description: "Unix seconds, or null to clear." }
4178
+ },
4179
+ required: ["org_id"]
4180
+ },
4181
+ execute: async (args) => {
4182
+ const a2 = args ?? {};
4183
+ if (!a2.org_id) throw new Error("troop.objective.upsert: org_id is required");
4184
+ if (a2.status && !OBJECTIVE_STATUS.includes(a2.status)) {
4185
+ throw new Error(`troop.objective.upsert: bad status '${a2.status}'`);
4186
+ }
4187
+ if (!a2.objective_id && !a2.title) throw new Error("troop.objective.upsert: title is required to create an objective");
4188
+ const bearer = await getAuthorizedBearer({ endpoint: TROOP, aud: "troop.openape.ai" });
4189
+ const id = encodeURIComponent(a2.org_id);
4190
+ const body = {};
4191
+ if (a2.title !== void 0) body.title = a2.title;
4192
+ if (a2.description !== void 0) body.description = a2.description;
4193
+ if (a2.status !== void 0) body.status = a2.status;
4194
+ if (a2.target_date !== void 0) body.target_date = a2.target_date;
4195
+ const url = a2.objective_id ? `${TROOP}/api/orgs/${id}/objectives/${encodeURIComponent(a2.objective_id)}` : `${TROOP}/api/orgs/${id}/objectives`;
4196
+ const res = await fetch(url, {
4197
+ method: a2.objective_id ? "PATCH" : "POST",
4198
+ headers: { authorization: bearer, "content-type": "application/json" },
4199
+ body: JSON.stringify(body)
4200
+ });
4201
+ if (!res.ok) {
4202
+ throw new Error(`troop.objective.upsert \u2192 ${res.status}: ${(await res.text()).slice(0, 200)}`);
4203
+ }
4204
+ return JSON.stringify(await res.json());
4205
+ }
4206
+ }
4207
+ ];
3926
4208
  var CWD_RE = /^[\w./-]{1,256}$/;
3927
4209
  async function runVerify(cwd, command, timeoutMs) {
3928
4210
  if (typeof cwd !== "string" || !CWD_RE.test(cwd)) {
@@ -3969,7 +4251,9 @@ var ALL_TOOLS = [
3969
4251
  ...bashTools,
3970
4252
  ...gitWorktreeTools,
3971
4253
  ...verifyTools,
3972
- ...forgeTools
4254
+ ...forgeTools,
4255
+ ...spawnTools,
4256
+ ...troopTools
3973
4257
  ];
3974
4258
  var TOOLS = Object.fromEntries(
3975
4259
  ALL_TOOLS.map((t2) => [t2.name, t2])
@@ -4070,6 +4354,7 @@ async function runLoop(opts) {
4070
4354
  const requestBody = {
4071
4355
  model: opts.config.model,
4072
4356
  messages,
4357
+ ...opts.config.reasoningEffort ? { reasoning_effort: opts.config.reasoningEffort } : {},
4073
4358
  ...tools.length > 0 ? { tools, tool_choice: "auto" } : {},
4074
4359
  ...opts.streamAggregate ? { stream: true } : {}
4075
4360
  };
@@ -4180,13 +4465,180 @@ var REVIEW_SYSTEM = [
4180
4465
  'Respond ONLY as JSON: {"approved": boolean, "reason": string}.'
4181
4466
  ].join(" ");
4182
4467
 
4468
+ // src/bridge-config.ts
4469
+ var DEFAULT_ENDPOINT = "https://troop.openape.ai";
4470
+ var DEFAULT_APES_BIN = "apes";
4471
+ var DEFAULT_MAX_STEPS = 10;
4472
+ var DEFAULT_SYSTEM_PROMPT = `You are a helpful assistant in a 1:1 chat. Be concise and friendly. When asked for facts, say "I don't know" rather than guess.`;
4473
+ var REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
4474
+ function readTelegramConfig(env2) {
4475
+ const botToken = env2.TELEGRAM_BOT_TOKEN;
4476
+ if (!botToken) return void 0;
4477
+ const raw = env2.TELEGRAM_OWNER_USER_ID;
4478
+ if (raw === void 0 || raw === "") return { botToken };
4479
+ const ownerUserId = Number.parseInt(raw, 10);
4480
+ if (!Number.isInteger(ownerUserId)) {
4481
+ throw new TypeError(`TELEGRAM_OWNER_USER_ID is set but not a number: ${JSON.stringify(raw)}`);
4482
+ }
4483
+ return { botToken, ownerUserId };
4484
+ }
4485
+ function readConfig(env2 = process.env) {
4486
+ const toolsRaw = env2.APE_CHAT_BRIDGE_TOOLS ?? "";
4487
+ const tools = toolsRaw.split(",").map((s2) => s2.trim()).filter(Boolean);
4488
+ const maxStepsRaw = env2.APE_CHAT_BRIDGE_MAX_STEPS;
4489
+ const maxSteps = maxStepsRaw ? Number.parseInt(maxStepsRaw, 10) : DEFAULT_MAX_STEPS;
4490
+ const model = env2.APE_CHAT_BRIDGE_MODEL;
4491
+ if (!model) {
4492
+ throw new Error(
4493
+ "APE_CHAT_BRIDGE_MODEL is not set. Set it in the container env (compose environment: block) or globally in `~/litellm/.env`. Common values: `gpt-5.4` (ChatGPT-only LiteLLM proxy), `claude-haiku-4-5` (Anthropic-only)."
4494
+ );
4495
+ }
4496
+ const effortRaw = env2.APE_CHAT_BRIDGE_REASONING_EFFORT;
4497
+ const reasoningEffort = REASONING_EFFORTS.includes(effortRaw) ? effortRaw : void 0;
4498
+ return {
4499
+ endpoint: (env2.OPENAPE_TROOP_URL ?? DEFAULT_ENDPOINT).replace(/\/$/, ""),
4500
+ apesBin: env2.APE_CHAT_BRIDGE_APES_BIN ?? DEFAULT_APES_BIN,
4501
+ model,
4502
+ reasoningEffort,
4503
+ systemPrompt: env2.APE_CHAT_BRIDGE_SYSTEM_PROMPT ?? DEFAULT_SYSTEM_PROMPT,
4504
+ tools,
4505
+ maxSteps: Number.isFinite(maxSteps) && maxSteps > 0 ? maxSteps : DEFAULT_MAX_STEPS,
4506
+ roomFilter: env2.APE_CHAT_BRIDGE_ROOM,
4507
+ telegram: readTelegramConfig(env2)
4508
+ };
4509
+ }
4510
+
4511
+ // src/troop-chat-api.ts
4512
+ import { ofetch as ofetch6 } from "ofetch";
4513
+ var MAX_BODY = 64 * 1024;
4514
+ var SYNTHETIC_THREAD_ID = "main";
4515
+ function asHistory(msg, agentEmail, ownerEmail) {
4516
+ return {
4517
+ id: msg.id,
4518
+ roomId: msg.chatId,
4519
+ threadId: SYNTHETIC_THREAD_ID,
4520
+ senderEmail: msg.role === "agent" ? agentEmail : ownerEmail,
4521
+ senderAct: msg.role,
4522
+ body: msg.body,
4523
+ replyTo: msg.replyTo,
4524
+ createdAt: msg.createdAt
4525
+ };
4526
+ }
4527
+ function asPosted(msg) {
4528
+ return {
4529
+ id: msg.id,
4530
+ roomId: msg.chatId,
4531
+ threadId: SYNTHETIC_THREAD_ID,
4532
+ body: msg.body,
4533
+ createdAt: msg.createdAt
4534
+ };
4535
+ }
4536
+ var TroopChatApi = class {
4537
+ constructor(endpoint, bearer) {
4538
+ this.endpoint = endpoint;
4539
+ this.bearer = bearer;
4540
+ }
4541
+ endpoint;
4542
+ bearer;
4543
+ bootstrap = null;
4544
+ /** Resolve + cache the agent's chat row (lazy fetch on first use). */
4545
+ async getBootstrap() {
4546
+ if (this.bootstrap) return this.bootstrap;
4547
+ this.bootstrap = await ofetch6(`${this.endpoint}/api/agents/me/chat`, {
4548
+ method: "GET",
4549
+ headers: { Authorization: await this.bearer() }
4550
+ });
4551
+ return this.bootstrap;
4552
+ }
4553
+ /** chat.id + (lazy-fetched) ownerEmail for the bridge's frame-translation path. */
4554
+ async getChatContext() {
4555
+ const b2 = await this.getBootstrap();
4556
+ return { chatId: b2.chat.id, ownerEmail: b2.chat.ownerEmail, agentEmail: b2.chat.agentEmail };
4557
+ }
4558
+ async postMessage(roomId, body, opts = {}) {
4559
+ void roomId;
4560
+ void opts.threadId;
4561
+ const payload = {
4562
+ body: body.length > MAX_BODY ? `${body.slice(0, MAX_BODY - 1)}\u2026` : body
4563
+ };
4564
+ if (opts.replyTo) payload.reply_to = opts.replyTo;
4565
+ if (opts.streaming) payload.streaming = true;
4566
+ const msg = await ofetch6(`${this.endpoint}/api/agents/me/chat/messages`, {
4567
+ method: "POST",
4568
+ headers: { Authorization: await this.bearer() },
4569
+ body: payload
4570
+ });
4571
+ return asPosted(msg);
4572
+ }
4573
+ async listMessages(roomId, threadId, limit = 50) {
4574
+ void roomId;
4575
+ void threadId;
4576
+ void limit;
4577
+ const fresh = await ofetch6(`${this.endpoint}/api/agents/me/chat`, {
4578
+ method: "GET",
4579
+ headers: { Authorization: await this.bearer() }
4580
+ });
4581
+ this.bootstrap = fresh;
4582
+ return fresh.messages.map((m2) => asHistory(m2, fresh.chat.agentEmail, fresh.chat.ownerEmail));
4583
+ }
4584
+ async patchMessage(messageId, opts = {}) {
4585
+ const payload = {};
4586
+ if (opts.body !== void 0) {
4587
+ payload.body = opts.body.length > MAX_BODY ? `${opts.body.slice(0, MAX_BODY - 1)}\u2026` : opts.body;
4588
+ }
4589
+ if (opts.streaming !== void 0) payload.streaming = opts.streaming;
4590
+ if (opts.streamingStatus !== void 0) payload.streaming_status = opts.streamingStatus;
4591
+ if (Object.keys(payload).length === 0) return;
4592
+ await ofetch6(`${this.endpoint}/api/agents/me/chat/messages/${encodeURIComponent(messageId)}`, {
4593
+ method: "PATCH",
4594
+ headers: { Authorization: await this.bearer() },
4595
+ body: payload
4596
+ });
4597
+ }
4598
+ /**
4599
+ * Troop's chat doesn't have contacts — synthesize a single
4600
+ * always-connected entry pointing at the owner so the bridge's
4601
+ * initial-contact + allowlist flows are no-ops.
4602
+ */
4603
+ async listContacts() {
4604
+ const b2 = await this.getBootstrap();
4605
+ return [{
4606
+ peerEmail: b2.chat.ownerEmail,
4607
+ myStatus: "accepted",
4608
+ theirStatus: "accepted",
4609
+ connected: true,
4610
+ roomId: b2.chat.id
4611
+ }];
4612
+ }
4613
+ async requestContact(peerEmail) {
4614
+ void peerEmail;
4615
+ return (await this.listContacts())[0];
4616
+ }
4617
+ async acceptContact(peerEmail) {
4618
+ void peerEmail;
4619
+ return (await this.listContacts())[0];
4620
+ }
4621
+ /**
4622
+ * Troop has no threads — return a synthetic one. The bridge's
4623
+ * cron-runner falls back to the main thread on createThread
4624
+ * failure already, so a stable "main" stand-in is the right shape.
4625
+ */
4626
+ async createThread(roomId, name) {
4627
+ void roomId;
4628
+ return { id: SYNTHETIC_THREAD_ID, name: name.slice(0, 100) };
4629
+ }
4630
+ };
4631
+
4183
4632
  // src/cron-runner.ts
4184
- var TASK_CACHE_DIR = join4(homedir6(), ".openape", "agent", "tasks");
4185
- var AGENT_CONFIG_PATH = join4(homedir6(), ".openape", "agent", "agent.json");
4633
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readdirSync as readdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
4634
+ import { homedir as homedir7 } from "os";
4635
+ import { join as join7 } from "path";
4636
+ var TASK_CACHE_DIR = join7(homedir7(), ".openape", "agent", "tasks");
4637
+ var AGENT_CONFIG_PATH = join7(homedir7(), ".openape", "agent", "agent.json");
4186
4638
  function resolveRecipeDir() {
4187
- return process.env.OPENAPE_RECIPE_DEV_DIR || join4(homedir6(), "recipe");
4639
+ return process.env.OPENAPE_RECIPE_DEV_DIR || join7(homedir7(), "recipe");
4188
4640
  }
4189
- var TASK_THREADS_PATH = join4(homedir6(), ".openape", "agent", "task-threads.json");
4641
+ var TASK_THREADS_PATH = join7(homedir7(), ".openape", "agent", "task-threads.json");
4190
4642
  var TICK_INTERVAL_MS = 6e4;
4191
4643
  function parseField(token, range, allowStep) {
4192
4644
  if (token === "*") return { type: "any" };
@@ -4228,13 +4680,13 @@ function cronMatches(expr, now) {
4228
4680
  return fieldMatches(expr.minute, now.getMinutes()) && fieldMatches(expr.hour, now.getHours()) && fieldMatches(expr.dom, now.getDate()) && fieldMatches(expr.month, now.getMonth() + 1) && (fieldMatches(expr.dow, dow) || expr.dow.type === "fixed" && expr.dow.value === 7 && dow === 0);
4229
4681
  }
4230
4682
  function readTaskSpecs() {
4231
- if (!existsSync3(TASK_CACHE_DIR)) return [];
4683
+ if (!existsSync4(TASK_CACHE_DIR)) return [];
4232
4684
  const out = [];
4233
- for (const entry of readdirSync3(TASK_CACHE_DIR)) {
4685
+ for (const entry of readdirSync4(TASK_CACHE_DIR)) {
4234
4686
  if (!entry.endsWith(".json")) continue;
4235
- const path = join4(TASK_CACHE_DIR, entry);
4687
+ const path = join7(TASK_CACHE_DIR, entry);
4236
4688
  try {
4237
- const t2 = JSON.parse(readFileSync5(path, "utf8"));
4689
+ const t2 = JSON.parse(readFileSync6(path, "utf8"));
4238
4690
  if (t2.taskId && t2.cron && t2.enabled !== false) out.push(t2);
4239
4691
  } catch {
4240
4692
  }
@@ -4256,9 +4708,9 @@ function shouldReportCommandRun(exitCode, stdout2, stderr) {
4256
4708
  return exitCode !== 0 || `${stdout2}${stderr}`.trim() !== "";
4257
4709
  }
4258
4710
  function readSystemPrompt() {
4259
- if (!existsSync3(AGENT_CONFIG_PATH)) return "";
4711
+ if (!existsSync4(AGENT_CONFIG_PATH)) return "";
4260
4712
  try {
4261
- const parsed = JSON.parse(readFileSync5(AGENT_CONFIG_PATH, "utf8"));
4713
+ const parsed = JSON.parse(readFileSync6(AGENT_CONFIG_PATH, "utf8"));
4262
4714
  return typeof parsed.systemPrompt === "string" ? parsed.systemPrompt : "";
4263
4715
  } catch {
4264
4716
  return "";
@@ -4285,9 +4737,9 @@ var CronRunner = class {
4285
4737
  */
4286
4738
  taskThreads = /* @__PURE__ */ new Map();
4287
4739
  loadTaskThreads() {
4288
- if (!existsSync3(TASK_THREADS_PATH)) return;
4740
+ if (!existsSync4(TASK_THREADS_PATH)) return;
4289
4741
  try {
4290
- const parsed = JSON.parse(readFileSync5(TASK_THREADS_PATH, "utf8"));
4742
+ const parsed = JSON.parse(readFileSync6(TASK_THREADS_PATH, "utf8"));
4291
4743
  for (const [k2, v2] of Object.entries(parsed)) {
4292
4744
  if (typeof v2 === "string") this.taskThreads.set(k2, v2);
4293
4745
  }
@@ -4296,9 +4748,9 @@ var CronRunner = class {
4296
4748
  }
4297
4749
  persistTaskThreads() {
4298
4750
  try {
4299
- const dir = join4(homedir6(), ".openape", "agent");
4300
- mkdirSync3(dir, { recursive: true });
4301
- writeFileSync3(
4751
+ const dir = join7(homedir7(), ".openape", "agent");
4752
+ mkdirSync4(dir, { recursive: true });
4753
+ writeFileSync4(
4302
4754
  TASK_THREADS_PATH,
4303
4755
  `${JSON.stringify(Object.fromEntries(this.taskThreads), null, 2)}
4304
4756
  `,
@@ -4376,7 +4828,7 @@ var CronRunner = class {
4376
4828
  if (spec.command) {
4377
4829
  try {
4378
4830
  const recipeDir = resolveRecipeDir();
4379
- const res = await runApeShell(spec.command, 30 * 60 * 1e3, existsSync3(recipeDir) ? recipeDir : void 0);
4831
+ const res = await runApeShell(spec.command, 30 * 60 * 1e3, existsSync4(recipeDir) ? recipeDir : void 0);
4380
4832
  const turn = this.pending.get(sessionId);
4381
4833
  if (!turn) return;
4382
4834
  turn.status = res.exit_code === 0 ? "ok" : "error";
@@ -4397,8 +4849,9 @@ var CronRunner = class {
4397
4849
  return;
4398
4850
  }
4399
4851
  try {
4852
+ const apiKey = this.deps.refreshApiKey ? await this.deps.refreshApiKey() : this.deps.runtimeConfig.apiKey;
4400
4853
  const result = await runLoop({
4401
- config: this.deps.runtimeConfig,
4854
+ config: { ...this.deps.runtimeConfig, apiKey },
4402
4855
  systemPrompt,
4403
4856
  userMessage: spec.userPrompt,
4404
4857
  tools: taskTools(spec.tools),
@@ -4478,22 +4931,36 @@ ${text}`.slice(0, 9e3);
4478
4931
  }
4479
4932
  };
4480
4933
 
4934
+ // src/llm-gateway-key.ts
4935
+ async function resolveLlmGatewayKey(base, fallback, log2, exchange = getAuthorizedBearer) {
4936
+ if (!base.includes("llms.openape.ai"))
4937
+ return fallback;
4938
+ try {
4939
+ const u3 = new URL(base);
4940
+ const bearer = await exchange({ endpoint: u3.origin, aud: u3.host });
4941
+ return bearer.replace(/^Bearer\s+/i, "");
4942
+ } catch (err) {
4943
+ log2(`llm gateway token exchange failed (keeping current key): ${err instanceof Error ? err.message : String(err)}`);
4944
+ return fallback;
4945
+ }
4946
+ }
4947
+
4481
4948
  // src/identity.ts
4482
- import { existsSync as existsSync4, readFileSync as readFileSync6 } from "fs";
4483
- import { homedir as homedir7 } from "os";
4484
- import { join as join6 } from "path";
4485
- function authPath() {
4486
- return join6(homedir7(), ".config", "apes", "auth.json");
4949
+ import { existsSync as existsSync5, readFileSync as readFileSync7 } from "fs";
4950
+ import { homedir as homedir8 } from "os";
4951
+ import { join as join8 } from "path";
4952
+ function authPath(home) {
4953
+ return join8(home, ".config", "apes", "auth.json");
4487
4954
  }
4488
4955
  function allowlistPath() {
4489
- return join6(homedir7(), ".config", "openape", "bridge-allowlist.json");
4956
+ return join8(homedir8(), ".config", "openape", "bridge-allowlist.json");
4490
4957
  }
4491
- function readAgentIdentity() {
4492
- const path = authPath();
4493
- if (!existsSync4(path)) {
4958
+ function readAgentIdentity(home = homedir8()) {
4959
+ const path = authPath(home);
4960
+ if (!existsSync5(path)) {
4494
4961
  throw new Error(`agent identity not found at ${path}`);
4495
4962
  }
4496
- const raw = readFileSync6(path, "utf8");
4963
+ const raw = readFileSync7(path, "utf8");
4497
4964
  const parsed = JSON.parse(raw);
4498
4965
  if (!parsed.email) throw new Error(`auth.json at ${path} missing 'email'`);
4499
4966
  if (!parsed.idp) throw new Error(`auth.json at ${path} missing 'idp'`);
@@ -4507,9 +4974,9 @@ function readAgentIdentity() {
4507
4974
  }
4508
4975
  function readAllowlist() {
4509
4976
  const path = allowlistPath();
4510
- if (!existsSync4(path)) return /* @__PURE__ */ new Set();
4977
+ if (!existsSync5(path)) return /* @__PURE__ */ new Set();
4511
4978
  try {
4512
- const parsed = JSON.parse(readFileSync6(path, "utf8"));
4979
+ const parsed = JSON.parse(readFileSync7(path, "utf8"));
4513
4980
  if (!Array.isArray(parsed.emails)) return /* @__PURE__ */ new Set();
4514
4981
  return new Set(parsed.emails.map((e2) => e2.toLowerCase()));
4515
4982
  } catch {
@@ -4524,24 +4991,24 @@ function shouldAutoAccept(peerEmail, identity, allowlist) {
4524
4991
 
4525
4992
  // src/skills.ts
4526
4993
  import { execFileSync as execFileSync3 } from "child_process";
4527
- import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync7, statSync } from "fs";
4528
- import { homedir as homedir8 } from "os";
4529
- import { dirname as dirname2, join as join7, resolve as resolve3 } from "path";
4994
+ import { existsSync as existsSync6, readdirSync as readdirSync5, readFileSync as readFileSync8, statSync as statSync2 } from "fs";
4995
+ import { homedir as homedir9 } from "os";
4996
+ import { dirname as dirname3, join as join9, resolve as resolve3 } from "path";
4530
4997
  import { fileURLToPath } from "url";
4531
4998
  import { parse as parseYaml } from "yaml";
4532
4999
  var SKILLS_SUBDIR = [".openape", "agent", "skills"];
4533
5000
  var SOUL_PATH_PARTS = [".openape", "agent", "SOUL.md"];
4534
- function soulPath(home = homedir8()) {
4535
- return join7(home, ...SOUL_PATH_PARTS);
5001
+ function soulPath(home = homedir9()) {
5002
+ return join9(home, ...SOUL_PATH_PARTS);
4536
5003
  }
4537
- function skillsDir(home = homedir8()) {
4538
- return join7(home, ...SKILLS_SUBDIR);
5004
+ function skillsDir(home = homedir9()) {
5005
+ return join9(home, ...SKILLS_SUBDIR);
4539
5006
  }
4540
- function readSoul(home = homedir8()) {
5007
+ function readSoul(home = homedir9()) {
4541
5008
  const path = soulPath(home);
4542
- if (!existsSync5(path)) return null;
5009
+ if (!existsSync6(path)) return null;
4543
5010
  try {
4544
- const body = readFileSync7(path, "utf8").trim();
5011
+ const body = readFileSync8(path, "utf8").trim();
4545
5012
  return body.length > 0 ? body : null;
4546
5013
  } catch {
4547
5014
  return null;
@@ -4597,27 +5064,27 @@ function hasBinaryOnPath(bin) {
4597
5064
  return found;
4598
5065
  }
4599
5066
  function scanSkillsDir(dir) {
4600
- if (!existsSync5(dir)) return [];
5067
+ if (!existsSync6(dir)) return [];
4601
5068
  let entries;
4602
5069
  try {
4603
- entries = readdirSync4(dir);
5070
+ entries = readdirSync5(dir);
4604
5071
  } catch {
4605
5072
  return [];
4606
5073
  }
4607
5074
  const out = [];
4608
5075
  for (const entry of entries) {
4609
- const skillPath = join7(dir, entry, "SKILL.md");
4610
- if (!existsSync5(skillPath)) continue;
5076
+ const skillPath = join9(dir, entry, "SKILL.md");
5077
+ if (!existsSync6(skillPath)) continue;
4611
5078
  let st;
4612
5079
  try {
4613
- st = statSync(skillPath);
5080
+ st = statSync2(skillPath);
4614
5081
  } catch {
4615
5082
  continue;
4616
5083
  }
4617
5084
  if (!st.isFile()) continue;
4618
5085
  let body;
4619
5086
  try {
4620
- body = readFileSync7(skillPath, "utf8");
5087
+ body = readFileSync8(skillPath, "utf8");
4621
5088
  } catch {
4622
5089
  continue;
4623
5090
  }
@@ -4634,7 +5101,7 @@ function scanSkillsDir(dir) {
4634
5101
  return out;
4635
5102
  }
4636
5103
  function defaultSkillsDir() {
4637
- const here = dirname2(fileURLToPath(import.meta.url));
5104
+ const here = dirname3(fileURLToPath(import.meta.url));
4638
5105
  return resolve3(here, "..", "default-skills");
4639
5106
  }
4640
5107
  function composeSkills(home, enabledTools) {
@@ -4681,7 +5148,7 @@ function formatSkillsBlock(skills) {
4681
5148
  return lines.join("\n");
4682
5149
  }
4683
5150
  function composeSystemPrompt(input) {
4684
- const home = input.home ?? homedir8();
5151
+ const home = input.home ?? homedir9();
4685
5152
  const parts = [];
4686
5153
  const defaultPersona = readDefaultPersona();
4687
5154
  if (defaultPersona) parts.push(defaultPersona);
@@ -4698,13 +5165,13 @@ var _defaultPersonaCache;
4698
5165
  function readDefaultPersona() {
4699
5166
  if (_defaultPersonaCache !== void 0) return _defaultPersonaCache;
4700
5167
  try {
4701
- const here = dirname2(fileURLToPath(import.meta.url));
5168
+ const here = dirname3(fileURLToPath(import.meta.url));
4702
5169
  const path = resolve3(here, "..", "default-persona.md");
4703
- if (!existsSync5(path)) {
5170
+ if (!existsSync6(path)) {
4704
5171
  _defaultPersonaCache = null;
4705
5172
  return null;
4706
5173
  }
4707
- const raw = readFileSync7(path, "utf8").trim();
5174
+ const raw = readFileSync8(path, "utf8").trim();
4708
5175
  _defaultPersonaCache = raw.length > 0 ? raw : null;
4709
5176
  return _defaultPersonaCache;
4710
5177
  } catch {
@@ -4823,6 +5290,7 @@ var ThreadSession = class {
4823
5290
  }
4824
5291
  };
4825
5292
  const { systemPrompt, tools } = this.deps.resolveConfig();
5293
+ const runtimeConfig = this.deps.refreshRuntimeConfig ? await this.deps.refreshRuntimeConfig() : this.deps.runtimeConfig;
4826
5294
  await this.backfillHistoryOnce(replyToMessageId, body);
4827
5295
  let sawActivity = false;
4828
5296
  let turnSettled = false;
@@ -4838,7 +5306,7 @@ var ThreadSession = class {
4838
5306
  }, NO_ACTIVITY_TIMEOUT_MS);
4839
5307
  try {
4840
5308
  const result = await runLoop({
4841
- config: this.deps.runtimeConfig,
5309
+ config: runtimeConfig,
4842
5310
  systemPrompt,
4843
5311
  userMessage: body,
4844
5312
  tools: taskTools(tools),
@@ -4961,21 +5429,392 @@ var ThreadSession = class {
4961
5429
  }
4962
5430
  };
4963
5431
 
5432
+ // src/agent-session.ts
5433
+ var AgentSession = class {
5434
+ constructor(email, ownerEmail, config) {
5435
+ this.email = email;
5436
+ this.ownerEmail = ownerEmail;
5437
+ this.config = config;
5438
+ }
5439
+ email;
5440
+ ownerEmail;
5441
+ config;
5442
+ /**
5443
+ * Lazily-created prompt-injection detector, shared across this session's
5444
+ * messages. Matches the per-agent bridge, which holds one
5445
+ * `createHeuristicDetector()` for its lifetime.
5446
+ */
5447
+ injectionDetector;
5448
+ describe() {
5449
+ return `${this.email} (owner ${this.ownerEmail})`;
5450
+ }
5451
+ /**
5452
+ * Build this agent's troop chat WebSocket URL from its resolved endpoint and
5453
+ * a bearer token. Ports the exact derivation the per-agent bridge uses in
5454
+ * `pumpOnce` (http→ws, token carried as a query param, a leading `Bearer `
5455
+ * prefix stripped, the value URL-encoded) so the nest's in-process WS-open
5456
+ * increment connects to the same socket the bridge process opens today — with
5457
+ * no second copy of the URL rule once the nest drives the connection.
5458
+ */
5459
+ chatSocketUrl(bearer) {
5460
+ const base = this.config.endpoint.replace(/^http/, "ws");
5461
+ const token = encodeURIComponent(bearer.replace(/^Bearer\s+/i, ""));
5462
+ return `${base}/_ws/chat?token=${token}`;
5463
+ }
5464
+ /**
5465
+ * Decode one raw troop chat-socket frame into a {@link TroopChatFrame}, or
5466
+ * `null` for frames the agent ignores. Ports the exact decode + filter the
5467
+ * per-agent bridge applies in `pumpOnce`: tolerate string or `Buffer` data,
5468
+ * skip anything that is not valid JSON, and keep only `{type:'message'}`
5469
+ * frames that carry a payload. This is the canonical home for the framing
5470
+ * rule once the nest drives the connection — the WS-message increment routes
5471
+ * accepted frames into the agent loop with no second copy of the rule.
5472
+ */
5473
+ parseChatFrame(data) {
5474
+ const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf8") : "";
5475
+ if (!text)
5476
+ return null;
5477
+ let frame;
5478
+ try {
5479
+ frame = JSON.parse(text);
5480
+ } catch {
5481
+ return null;
5482
+ }
5483
+ if (frame.type !== "message" || !frame.payload)
5484
+ return null;
5485
+ return { chatId: frame.chat_id ?? "", payload: frame.payload };
5486
+ }
5487
+ /**
5488
+ * Translate an accepted {@link TroopChatFrame} into the {@link TroopMessage}
5489
+ * the agent loop runs on. Ports the bridge's `translateTroopPayload`: troop's
5490
+ * payload carries `role` (human|agent) but no sender email, so the email is
5491
+ * synthesized from role (agent → this session's own email, human → the owner)
5492
+ * — the bridge skips its own echoes via `senderEmail === selfEmail`, so this
5493
+ * mapping must match. `threadId` is the synthetic `'main'` because troop has
5494
+ * no threads. This is the canonical home for the payload→message rule once the
5495
+ * nest drives the connection: the runLoop-dispatch increment feeds this
5496
+ * message straight into the loop with no second copy of the translation.
5497
+ */
5498
+ toMessage(frame) {
5499
+ const { chatId, payload } = frame;
5500
+ const role = payload.role === "agent" ? "agent" : "human";
5501
+ return {
5502
+ id: String(payload.id ?? ""),
5503
+ roomId: chatId || String(payload.chatId ?? ""),
5504
+ threadId: "main",
5505
+ senderEmail: role === "agent" ? this.email : this.ownerEmail,
5506
+ senderAct: role,
5507
+ body: typeof payload.body === "string" ? payload.body : "",
5508
+ replyTo: typeof payload.replyTo === "string" ? payload.replyTo : null,
5509
+ createdAt: typeof payload.createdAt === "number" ? payload.createdAt : Math.floor(Date.now() / 1e3),
5510
+ editedAt: typeof payload.editedAt === "number" ? payload.editedAt : null
5511
+ };
5512
+ }
5513
+ /**
5514
+ * Whether a translated {@link TroopMessage} is this agent's own echo. troop
5515
+ * fans every chat message back to the socket that sent it, so the agent sees
5516
+ * its own replies; feeding those into the loop would be an infinite feedback
5517
+ * cycle. Ports the bridge's `handleInbound` guard (`senderEmail === selfEmail`)
5518
+ * — the canonical home for the self-echo rule once the nest drives the
5519
+ * connection: the runLoop-dispatch increment skips own echoes before it runs
5520
+ * the loop, with no second copy of the comparison.
5521
+ */
5522
+ isOwnEcho(message) {
5523
+ return message.senderEmail === this.email;
5524
+ }
5525
+ /**
5526
+ * Whether a translated, non-echo {@link TroopMessage} should reach the agent
5527
+ * loop. Ports the bridge's remaining pre-loop guards in `handleInbound`: an
5528
+ * empty or whitespace-only body carries nothing to act on, and a configured
5529
+ * `roomFilter` scopes the agent to a single chat. (The bridge's `threadId`
5530
+ * guard is moot here — {@link toMessage} always synthesizes `'main'`.) The
5531
+ * own-echo guard stays {@link isOwnEcho}, applied first by the caller. This is
5532
+ * the canonical home for the dispatch-filter rule once the nest drives the
5533
+ * connection: the runLoop-dispatch increment runs the loop only for messages
5534
+ * this accepts, with no second copy of the guards.
5535
+ */
5536
+ shouldDispatch(message) {
5537
+ if (!message.body.trim())
5538
+ return false;
5539
+ if (this.config.roomFilter && message.roomId !== this.config.roomFilter)
5540
+ return false;
5541
+ return true;
5542
+ }
5543
+ /**
5544
+ * Screen an accepted, non-echo {@link TroopMessage} for prompt injection
5545
+ * before it reaches the agent loop. Ports the bridge's `handleInbound`
5546
+ * choke-point: the bridge runs every inbound message through a heuristic
5547
+ * detector and refuses to forward it when the score crosses the threshold,
5548
+ * because once the text is in the loop's history a refusal is harder and
5549
+ * inconsistent. The owner gets a higher bar (legitimate "run shell, do X"
5550
+ * instructions aren't refused) — handled by `decide` keying the threshold off
5551
+ * `sender.isOwner`. This is the canonical home for the screening rule once the
5552
+ * nest drives the connection: the runLoop-dispatch increment refuses blocked
5553
+ * messages with no second copy of the detector setup or the sender mapping.
5554
+ */
5555
+ async screenInjection(message) {
5556
+ this.injectionDetector ??= createHeuristicDetector();
5557
+ return decide(this.injectionDetector, {
5558
+ text: message.body,
5559
+ sender: {
5560
+ email: message.senderEmail,
5561
+ isOwner: message.senderEmail === this.ownerEmail
5562
+ }
5563
+ });
5564
+ }
5565
+ /**
5566
+ * The short, neutral refusal the agent posts back when {@link screenInjection}
5567
+ * blocks a message. Ports the bridge's `refusalText`: the matched reason is
5568
+ * appended so the owner sees in their chat history + audit log why a specific
5569
+ * message was blocked, but the phrasing deliberately avoids language an
5570
+ * attacker could copy back ("ignore previous instructions and …") to
5571
+ * re-trigger the detector. This is the canonical home for the refusal-message
5572
+ * rule once the nest drives the connection: the runLoop-dispatch increment
5573
+ * posts this text on a block with no second copy of the wording.
5574
+ */
5575
+ refusalText(reason) {
5576
+ const base = "I won't process this message \u2014 it looks like a prompt-injection attempt.";
5577
+ return reason ? `${base}
5578
+
5579
+ (matched: ${reason})` : base;
5580
+ }
5581
+ };
5582
+
5583
+ // src/telegram-api.ts
5584
+ import { ofetch as ofetch7 } from "ofetch";
5585
+ function createTelegramTransport(botToken) {
5586
+ const base = `https://api.telegram.org/bot${botToken}`;
5587
+ return async (method, params) => {
5588
+ return await ofetch7(`${base}/${method}`, {
5589
+ method: "POST",
5590
+ body: params,
5591
+ ignoreResponseError: true
5592
+ });
5593
+ };
5594
+ }
5595
+ function chatIdParam(roomId) {
5596
+ return /^-?\d+$/.test(roomId) ? Number(roomId) : roomId;
5597
+ }
5598
+
5599
+ // src/telegram-chat-api.ts
5600
+ var PLACEHOLDER_TEXT = "\u2026";
5601
+ var SYNTHETIC_THREAD_ID2 = "main";
5602
+ function encodeId(roomId, messageId) {
5603
+ return `${roomId}|${messageId}`;
5604
+ }
5605
+ function decodeId(id) {
5606
+ const i2 = id.lastIndexOf("|");
5607
+ return { chatId: chatIdParam(id.slice(0, i2)), messageId: Number(id.slice(i2 + 1)) };
5608
+ }
5609
+ var TelegramChatApi = class {
5610
+ constructor(call) {
5611
+ this.call = call;
5612
+ }
5613
+ call;
5614
+ async postMessage(roomId, body, opts = {}) {
5615
+ const text = body.length > 0 ? body : PLACEHOLDER_TEXT;
5616
+ const params = { chat_id: chatIdParam(roomId), text };
5617
+ if (opts.threadId && opts.threadId !== SYNTHETIC_THREAD_ID2) {
5618
+ params.message_thread_id = Number(opts.threadId);
5619
+ }
5620
+ if (opts.replyTo && /^\d+$/.test(opts.replyTo)) {
5621
+ params.reply_parameters = { message_id: Number(opts.replyTo), allow_sending_without_reply: true };
5622
+ }
5623
+ const res = await this.call("sendMessage", params);
5624
+ if (!res.ok || !res.result) {
5625
+ throw new Error(`telegram sendMessage failed: ${res.description ?? "unknown error"}`);
5626
+ }
5627
+ const sent = res.result;
5628
+ return {
5629
+ id: encodeId(roomId, sent.message_id),
5630
+ roomId,
5631
+ threadId: opts.threadId ?? SYNTHETIC_THREAD_ID2,
5632
+ body: text,
5633
+ createdAt: sent.date ?? Math.floor(Date.now() / 1e3)
5634
+ };
5635
+ }
5636
+ async patchMessage(messageId, opts = {}) {
5637
+ if (opts.streaming !== false || opts.body === void 0) return;
5638
+ const { chatId, messageId: msgId } = decodeId(messageId);
5639
+ const res = await this.call("editMessageText", {
5640
+ chat_id: chatId,
5641
+ message_id: msgId,
5642
+ text: opts.body.length > 0 ? opts.body : PLACEHOLDER_TEXT
5643
+ });
5644
+ if (!res.ok && !/not modified/i.test(res.description ?? "")) {
5645
+ throw new Error(`telegram editMessageText failed: ${res.description ?? "unknown error"}`);
5646
+ }
5647
+ }
5648
+ // Telegram exposes no history-fetch API. A live ThreadSession keeps its own
5649
+ // in-process history across turns; only a bridge restart loses Telegram
5650
+ // context (acceptable for M0 — persisted backfill is later work).
5651
+ async listMessages() {
5652
+ return [];
5653
+ }
5654
+ // Contacts are a troop concept; the Telegram inbound path never invokes the
5655
+ // bridge's contact handshake, so these are inert stand-ins for the interface.
5656
+ async listContacts() {
5657
+ return [];
5658
+ }
5659
+ async requestContact(peerEmail) {
5660
+ return { peerEmail, myStatus: "accepted", theirStatus: "accepted", connected: true, roomId: null };
5661
+ }
5662
+ async acceptContact(peerEmail) {
5663
+ return { peerEmail, myStatus: "accepted", theirStatus: "accepted", connected: true, roomId: null };
5664
+ }
5665
+ // M0 has no Telegram threads; forum-topic creation is M1.
5666
+ async createThread(roomId, name) {
5667
+ void roomId;
5668
+ return { id: SYNTHETIC_THREAD_ID2, name: name.slice(0, 100) };
5669
+ }
5670
+ };
5671
+
5672
+ // src/telegram-channel.ts
5673
+ var LONG_POLL_SECONDS = 30;
5674
+ var POLL_ERROR_BACKOFF_MS = 3e3;
5675
+ function sleep(ms) {
5676
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
5677
+ }
5678
+ var TelegramChannel = class {
5679
+ constructor(deps) {
5680
+ this.deps = deps;
5681
+ this.owner = deps.ownerUserId ?? deps.loadOwnerPin?.();
5682
+ }
5683
+ deps;
5684
+ name = "telegram";
5685
+ offset = 0;
5686
+ // Chats we've already told "not authorized" — one hint per chat, not per message.
5687
+ warned = /* @__PURE__ */ new Set();
5688
+ // The locked owner: explicit id, else a previously-pinned one, else undefined
5689
+ // until the first message pins it (TOFU).
5690
+ owner;
5691
+ async start(onInbound) {
5692
+ await this.skipBacklog();
5693
+ this.deps.log("telegram channel up");
5694
+ while (true) {
5695
+ let updates = [];
5696
+ try {
5697
+ updates = await this.poll();
5698
+ } catch (err) {
5699
+ this.deps.log(`telegram poll error: ${err instanceof Error ? err.message : String(err)}`);
5700
+ await sleep(POLL_ERROR_BACKOFF_MS);
5701
+ continue;
5702
+ }
5703
+ for (const u3 of updates) {
5704
+ this.offset = Math.max(this.offset, u3.update_id + 1);
5705
+ await this.dispatch(u3, onInbound);
5706
+ }
5707
+ }
5708
+ }
5709
+ /**
5710
+ * On boot, advance the offset past any messages Telegram buffered while the
5711
+ * bridge was down (it holds updates ~24h) so a restart doesn't replay a
5712
+ * day of old messages as fresh turns.
5713
+ */
5714
+ async skipBacklog() {
5715
+ const res = await this.deps.call("getUpdates", { timeout: 0, offset: -1 });
5716
+ if (res.ok && Array.isArray(res.result) && res.result.length > 0) {
5717
+ const updates = res.result;
5718
+ const last = updates.at(-1);
5719
+ this.offset = last.update_id + 1;
5720
+ this.deps.log(`telegram: skipped ${updates.length} backlog update(s) on boot`);
5721
+ }
5722
+ }
5723
+ async poll() {
5724
+ const res = await this.deps.call("getUpdates", { timeout: LONG_POLL_SECONDS, offset: this.offset });
5725
+ if (!res.ok) {
5726
+ throw new Error(res.description ?? "getUpdates failed");
5727
+ }
5728
+ return Array.isArray(res.result) ? res.result : [];
5729
+ }
5730
+ async dispatch(u3, onInbound) {
5731
+ const m2 = u3.message;
5732
+ if (!m2 || typeof m2.text !== "string" || m2.text.length === 0) return;
5733
+ const from = m2.from?.id;
5734
+ if (from === void 0) return;
5735
+ if (this.owner === void 0) {
5736
+ this.owner = from;
5737
+ this.deps.saveOwnerPin?.(from);
5738
+ this.deps.log(`telegram: owner pinned to user ${from} on first contact`);
5739
+ } else if (from !== this.owner) {
5740
+ await this.refuseStranger(m2);
5741
+ return;
5742
+ }
5743
+ if (m2.text === "/start" || m2.text.startsWith("/start ")) {
5744
+ await this.reply(m2.chat.id, "Hi \u{1F44B} \u2014 schreib mir einfach, ich bin dein Agent und antworte direkt hier.", m2.message_thread_id);
5745
+ return;
5746
+ }
5747
+ const msg = {
5748
+ id: String(m2.message_id),
5749
+ roomId: String(m2.chat.id),
5750
+ threadId: m2.message_thread_id ? String(m2.message_thread_id) : "main",
5751
+ senderEmail: this.deps.ownerEmail,
5752
+ senderAct: "human",
5753
+ body: m2.text,
5754
+ replyTo: null,
5755
+ createdAt: m2.date ?? Math.floor(Date.now() / 1e3),
5756
+ editedAt: null
5757
+ };
5758
+ await onInbound(msg, this.deps.backend);
5759
+ }
5760
+ async refuseStranger(m2) {
5761
+ if (this.warned.has(m2.chat.id)) return;
5762
+ this.warned.add(m2.chat.id);
5763
+ this.deps.log(`telegram: ignoring message from non-owner user ${m2.from?.id ?? "unknown"}`);
5764
+ await this.reply(m2.chat.id, "Dieser Bot ist privat und nur f\xFCr seinen Owner.", m2.message_thread_id);
5765
+ }
5766
+ async reply(chatId, text, threadId) {
5767
+ const params = { chat_id: chatIdParam(String(chatId)), text };
5768
+ if (threadId) params.message_thread_id = threadId;
5769
+ try {
5770
+ await this.deps.call("sendMessage", params);
5771
+ } catch (err) {
5772
+ this.deps.log(`telegram reply failed: ${err instanceof Error ? err.message : String(err)}`);
5773
+ }
5774
+ }
5775
+ };
5776
+
4964
5777
  // src/bridge.ts
4965
- var AGENT_CONFIG_PATH2 = join8(homedir9(), ".openape", "agent", "agent.json");
5778
+ var AGENT_CONFIG_PATH2 = join10(homedir10(), ".openape", "agent", "agent.json");
5779
+ var TELEGRAM_OWNER_PIN_PATH = join10(homedir10(), ".openape", "agent", "telegram-owner.json");
5780
+ var MEMORY_PATH = join10(homedir10(), ".openape", "agent", "MEMORY.md");
5781
+ function ensureMemoryFile() {
5782
+ if (existsSync7(MEMORY_PATH)) return;
5783
+ try {
5784
+ mkdirSync5(dirname4(MEMORY_PATH), { recursive: true });
5785
+ writeFileSync5(MEMORY_PATH, "", { flag: "wx" });
5786
+ log("seeded empty MEMORY.md");
5787
+ } catch {
5788
+ }
5789
+ }
5790
+ function readTelegramOwnerPin() {
5791
+ try {
5792
+ const parsed = JSON.parse(readFileSync9(TELEGRAM_OWNER_PIN_PATH, "utf8"));
5793
+ return typeof parsed.ownerUserId === "number" ? parsed.ownerUserId : void 0;
5794
+ } catch {
5795
+ return void 0;
5796
+ }
5797
+ }
5798
+ function writeTelegramOwnerPin(id) {
5799
+ try {
5800
+ writeFileSync5(TELEGRAM_OWNER_PIN_PATH, JSON.stringify({ ownerUserId: id }));
5801
+ } catch (err) {
5802
+ log(`failed to persist telegram owner pin: ${err instanceof Error ? err.message : String(err)}`);
5803
+ }
5804
+ }
4966
5805
  function resolveSystemPrompt(envFallback) {
4967
- if (!existsSync6(AGENT_CONFIG_PATH2)) return envFallback;
5806
+ if (!existsSync7(AGENT_CONFIG_PATH2)) return envFallback;
4968
5807
  try {
4969
- const parsed = JSON.parse(readFileSync8(AGENT_CONFIG_PATH2, "utf8"));
5808
+ const parsed = JSON.parse(readFileSync9(AGENT_CONFIG_PATH2, "utf8"));
4970
5809
  return typeof parsed.systemPrompt === "string" ? parsed.systemPrompt : envFallback;
4971
5810
  } catch {
4972
5811
  return envFallback;
4973
5812
  }
4974
5813
  }
4975
5814
  function resolveTools(envFallback) {
4976
- if (existsSync6(AGENT_CONFIG_PATH2)) {
5815
+ if (existsSync7(AGENT_CONFIG_PATH2)) {
4977
5816
  try {
4978
- const parsed = JSON.parse(readFileSync8(AGENT_CONFIG_PATH2, "utf8"));
5817
+ const parsed = JSON.parse(readFileSync9(AGENT_CONFIG_PATH2, "utf8"));
4979
5818
  if (Array.isArray(parsed.tools)) {
4980
5819
  return parsed.tools.filter((t2) => typeof t2 === "string");
4981
5820
  }
@@ -4984,35 +5823,10 @@ function resolveTools(envFallback) {
4984
5823
  }
4985
5824
  return envFallback;
4986
5825
  }
4987
- var DEFAULT_ENDPOINT = "https://troop.openape.ai";
4988
- var DEFAULT_APES_BIN = "apes";
4989
- var DEFAULT_MAX_STEPS = 10;
4990
- var DEFAULT_SYSTEM_PROMPT = `You are a helpful assistant in a 1:1 chat. Be concise and friendly. When asked for facts, say "I don't know" rather than guess.`;
4991
5826
  var PING_INTERVAL_MS = 3e4;
4992
5827
  var RECONNECT_BASE_MS = 1e3;
4993
5828
  var RECONNECT_MAX_MS = 3e4;
4994
5829
  var ALLOWLIST_POLL_INTERVAL_MS = 3e4;
4995
- function readConfig() {
4996
- const toolsRaw = process3.env.APE_CHAT_BRIDGE_TOOLS ?? "";
4997
- const tools = toolsRaw.split(",").map((s2) => s2.trim()).filter(Boolean);
4998
- const maxStepsRaw = process3.env.APE_CHAT_BRIDGE_MAX_STEPS;
4999
- const maxSteps = maxStepsRaw ? Number.parseInt(maxStepsRaw, 10) : DEFAULT_MAX_STEPS;
5000
- const model = process3.env.APE_CHAT_BRIDGE_MODEL;
5001
- if (!model) {
5002
- throw new Error(
5003
- "APE_CHAT_BRIDGE_MODEL is not set. Set it in the container env (compose environment: block) or globally in `~/litellm/.env`. Common values: `gpt-5.4` (ChatGPT-only LiteLLM proxy), `claude-haiku-4-5` (Anthropic-only)."
5004
- );
5005
- }
5006
- return {
5007
- endpoint: (process3.env.OPENAPE_TROOP_URL ?? DEFAULT_ENDPOINT).replace(/\/$/, ""),
5008
- apesBin: process3.env.APE_CHAT_BRIDGE_APES_BIN ?? DEFAULT_APES_BIN,
5009
- model,
5010
- systemPrompt: process3.env.APE_CHAT_BRIDGE_SYSTEM_PROMPT ?? DEFAULT_SYSTEM_PROMPT,
5011
- tools,
5012
- maxSteps: Number.isFinite(maxSteps) && maxSteps > 0 ? maxSteps : DEFAULT_MAX_STEPS,
5013
- roomFilter: process3.env.APE_CHAT_BRIDGE_ROOM
5014
- };
5015
- }
5016
5830
  async function getIdentity() {
5017
5831
  const idp = await ensureFreshIdpAuth();
5018
5832
  const claims = decodeJwt(idp.access_token);
@@ -5025,23 +5839,18 @@ function log(line) {
5025
5839
  process3.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
5026
5840
  `);
5027
5841
  }
5028
- function sleep(ms) {
5842
+ function sleep2(ms) {
5029
5843
  return new Promise((resolve4) => setTimeout(resolve4, ms));
5030
5844
  }
5031
5845
  function truncate(s2, n2) {
5032
5846
  return s2.length <= n2 ? s2 : `${s2.slice(0, n2 - 1)}\u2026`;
5033
5847
  }
5034
- function refusalText(reason) {
5035
- const base = "I won't process this message \u2014 it looks like a prompt-injection attempt.";
5036
- return reason ? `${base}
5037
-
5038
- (matched: ${reason})` : base;
5039
- }
5040
5848
  var Bridge = class {
5041
- constructor(cfg, selfEmail, ownerEmail) {
5849
+ constructor(cfg, selfEmail, ownerEmail, session) {
5042
5850
  this.cfg = cfg;
5043
5851
  this.selfEmail = selfEmail;
5044
5852
  this.ownerEmail = ownerEmail;
5853
+ this.session = session;
5045
5854
  this.bearer = async () => {
5046
5855
  const idp = await ensureFreshIdpAuth();
5047
5856
  return `Bearer ${idp.access_token}`;
@@ -5049,6 +5858,10 @@ var Bridge = class {
5049
5858
  this.chat = new TroopChatApi(this.cfg.endpoint, this.bearer);
5050
5859
  this.cron = new CronRunner({
5051
5860
  runtimeConfig: this.runtimeConfig(),
5861
+ // Cron fires off-thread, so it can't ride freshRuntimeConfig() like chat
5862
+ // turns do — give it the same DDISA exchange directly so cron stops
5863
+ // leaning on the boot master key (Todo 1).
5864
+ refreshApiKey: () => resolveLlmGatewayKey(process3.env.LITELLM_BASE_URL ?? "", this.llmKey, log),
5052
5865
  chat: this.chat,
5053
5866
  ownerEmail: this.ownerEmail,
5054
5867
  log,
@@ -5056,10 +5869,13 @@ var Bridge = class {
5056
5869
  bearer: this.bearer
5057
5870
  });
5058
5871
  this.cron.start();
5872
+ void this.refreshLlmGatewayKey();
5873
+ setInterval(() => void this.refreshLlmGatewayKey(), 40 * 60 * 1e3);
5059
5874
  }
5060
5875
  cfg;
5061
5876
  selfEmail;
5062
5877
  ownerEmail;
5878
+ session;
5063
5879
  // Sessions keyed by `${roomId}:${threadId}`. Each ThreadSession holds
5064
5880
  // its own message history and calls @openape/apes' runLoop directly
5065
5881
  // (no stdio JSON-RPC subprocess — see thread-session.ts).
@@ -5071,6 +5887,25 @@ var Bridge = class {
5071
5887
  // backend later. The bridge is the choke-point for every chat message
5072
5888
  // before it reaches the agent runtime, so this is the right place.
5073
5889
  injectionDetector = createHeuristicDetector();
5890
+ // LLM gateway key. Starts as the env key (master_key — the rollout fallback);
5891
+ // upgraded to this agent's own DDISA-exchanged token by refreshLlmGatewayKey()
5892
+ // when the gateway is llms.openape.ai. ponytail: cron keeps the boot key,
5893
+ // chat threads pick up the refreshed token — drop master_key + cron-DDISA later.
5894
+ llmKey = process3.env.LITELLM_API_KEY ?? process3.env.LITELLM_MASTER_KEY ?? "";
5895
+ async refreshLlmGatewayKey() {
5896
+ const base = process3.env.LITELLM_BASE_URL ?? "";
5897
+ this.llmKey = await resolveLlmGatewayKey(base, this.llmKey, log);
5898
+ }
5899
+ /**
5900
+ * Re-exchange the gateway token (if stale) and return a fresh runtimeConfig.
5901
+ * Thread sessions call this per turn so a long-lived thread never presents an
5902
+ * expired DDISA token. getAuthorizedBearer is cheap when the cached SP-token
5903
+ * is still valid and re-mints only when it's within the expiry skew.
5904
+ */
5905
+ async freshRuntimeConfig() {
5906
+ await this.refreshLlmGatewayKey();
5907
+ return this.runtimeConfig();
5908
+ }
5074
5909
  /**
5075
5910
  * RuntimeConfig is shared across thread sessions and the cron runner.
5076
5911
  * The bridge resolves it from its own env at boot and reuses for the
@@ -5078,11 +5913,38 @@ var Bridge = class {
5078
5913
  */
5079
5914
  runtimeConfig() {
5080
5915
  const apiBase = (process3.env.LITELLM_BASE_URL ?? "http://127.0.0.1:4000/v1").replace(/\/$/, "");
5081
- const apiKey = process3.env.LITELLM_API_KEY ?? process3.env.LITELLM_MASTER_KEY ?? "";
5916
+ const apiKey = this.llmKey;
5082
5917
  if (!apiKey) {
5083
5918
  throw new Error("LITELLM_API_KEY (or LITELLM_MASTER_KEY) must be set in the bridge env.");
5084
5919
  }
5085
- return { apiBase, apiKey, model: this.cfg.model };
5920
+ return { apiBase, apiKey, model: this.cfg.model, reasoningEffort: this.cfg.reasoningEffort };
5921
+ }
5922
+ /**
5923
+ * Start the per-agent chat adapters configured via sealed secrets (today:
5924
+ * Telegram). Each runs its own long-lived inbound loop concurrently with
5925
+ * the troop WebSocket and feeds messages through the same handleInbound
5926
+ * choke-point, but with its own backend so replies go back to that channel.
5927
+ * Fire-and-forget: a channel crash is logged, never takes the bridge down.
5928
+ */
5929
+ startExtraChannels() {
5930
+ const tg = this.cfg.telegram;
5931
+ if (!tg) return;
5932
+ const call = createTelegramTransport(tg.botToken);
5933
+ const backend = new TelegramChatApi(call);
5934
+ const channel = new TelegramChannel({
5935
+ call,
5936
+ ownerUserId: tg.ownerUserId,
5937
+ // Persist the trust-on-first-use owner pin next to agent.json so a bridge
5938
+ // restart keeps the lock instead of re-learning (and re-opening the window).
5939
+ loadOwnerPin: readTelegramOwnerPin,
5940
+ saveOwnerPin: writeTelegramOwnerPin,
5941
+ ownerEmail: this.ownerEmail,
5942
+ backend,
5943
+ log
5944
+ });
5945
+ void channel.start((msg, b2) => this.handleInbound(msg, b2)).catch((err) => {
5946
+ log(`telegram channel crashed: ${err instanceof Error ? err.message : String(err)}`);
5947
+ });
5086
5948
  }
5087
5949
  async sendInitialOwnerRequestIfNeeded() {
5088
5950
  const contacts = await this.chat.listContacts();
@@ -5144,7 +6006,13 @@ var Bridge = class {
5144
6006
  editedAt: typeof payload.editedAt === "number" ? payload.editedAt : null
5145
6007
  };
5146
6008
  }
5147
- async handleInbound(msg) {
6009
+ /**
6010
+ * Handle one inbound message from any channel. `backend` is the channel's
6011
+ * own outbound surface (troop or telegram) — refusals and the agent's reply
6012
+ * go back out through it, so the agent communicates on the same channel it
6013
+ * was reached on.
6014
+ */
6015
+ async handleInbound(msg, backend) {
5148
6016
  if (msg.senderEmail === this.selfEmail) return;
5149
6017
  if (!msg.body.trim()) return;
5150
6018
  if (this.cfg.roomFilter && msg.roomId !== this.cfg.roomFilter) return;
@@ -5163,7 +6031,7 @@ var Bridge = class {
5163
6031
  if (decision.blocked) {
5164
6032
  log(`[${msg.roomId}/${msg.threadId.slice(0, 8)}] BLOCKED prompt-injection (score=${decision.score.toFixed(2)}, reason=${decision.reason ?? "n/a"})`);
5165
6033
  try {
5166
- await this.chat.postMessage(msg.roomId, refusalText(decision.reason), {
6034
+ await backend.postMessage(msg.roomId, this.session.refusalText(decision.reason), {
5167
6035
  replyTo: msg.id,
5168
6036
  threadId: msg.threadId
5169
6037
  });
@@ -5173,18 +6041,21 @@ var Bridge = class {
5173
6041
  }
5174
6042
  return;
5175
6043
  }
5176
- const session = this.getOrCreateThread(msg.roomId, msg.threadId);
6044
+ const session = this.getOrCreateThread(msg.roomId, msg.threadId, backend);
5177
6045
  session.enqueue(msg.body, msg.id);
5178
6046
  }
5179
- getOrCreateThread(roomId, threadId) {
6047
+ getOrCreateThread(roomId, threadId, backend) {
5180
6048
  const key = `${roomId}:${threadId}`;
5181
6049
  let s2 = this.threads.get(key);
5182
6050
  if (s2) return s2;
5183
6051
  s2 = new ThreadSession({
5184
6052
  roomId,
5185
6053
  threadId,
5186
- chat: this.chat,
6054
+ chat: backend,
5187
6055
  runtimeConfig: this.runtimeConfig(),
6056
+ // Re-resolve the gateway token per turn (short-lived DDISA token would
6057
+ // otherwise expire on a long-lived thread -> 401).
6058
+ refreshRuntimeConfig: () => this.freshRuntimeConfig(),
5188
6059
  // Resolve tools + systemPrompt on every turn from agent.json
5189
6060
  // (latest sync from troop). Owner edits in the troop UI thus
5190
6061
  // take effect on the very next message in an existing thread —
@@ -5240,7 +6111,7 @@ var Bridge = class {
5240
6111
  }
5241
6112
  if (frame.type !== "message" || !frame.payload) return;
5242
6113
  const msg = this.translateTroopPayload(frame.chat_id ?? "", frame.payload);
5243
- void this.handleInbound(msg);
6114
+ void this.handleInbound(msg, this.chat);
5244
6115
  });
5245
6116
  ws.on("close", () => {
5246
6117
  if (pingTimer) clearInterval(pingTimer);
@@ -5256,7 +6127,14 @@ var Bridge = class {
5256
6127
  }
5257
6128
  };
5258
6129
  async function main() {
6130
+ try {
6131
+ startSecretsWatcher({ log: (m2) => log(m2) });
6132
+ } catch (err) {
6133
+ log(`secrets watcher failed to start: ${err instanceof Error ? err.message : String(err)}`);
6134
+ }
5259
6135
  const cfg = readConfig();
6136
+ addReadRoot(defaultSkillsDir());
6137
+ ensureMemoryFile();
5260
6138
  const idpId = await getIdentity();
5261
6139
  const onDisk = readAgentIdentity();
5262
6140
  if (onDisk.email.toLowerCase() !== idpId.email.toLowerCase()) {
@@ -5264,10 +6142,12 @@ async function main() {
5264
6142
  `auth.json email (${onDisk.email}) doesn't match IdP token sub (${idpId.email}) \u2014 refusing to start`
5265
6143
  );
5266
6144
  }
6145
+ const session = new AgentSession(onDisk.email, onDisk.ownerEmail, cfg);
5267
6146
  log(
5268
- `bridge starting \u2014 agent=${onDisk.email} owner=${onDisk.ownerEmail} apes=${cfg.apesBin} model=${cfg.model} tools=[${cfg.tools.join(",") || "none"}] max_steps=${cfg.maxSteps} room=${cfg.roomFilter ?? "*"}`
6147
+ `bridge starting \u2014 agent=${session.describe()} apes=${cfg.apesBin} model=${cfg.model} tools=[${cfg.tools.join(",") || "none"}] max_steps=${cfg.maxSteps} room=${cfg.roomFilter ?? "*"}`
5269
6148
  );
5270
- const bridge = new Bridge(cfg, onDisk.email, onDisk.ownerEmail);
6149
+ const bridge = new Bridge(cfg, onDisk.email, onDisk.ownerEmail, session);
6150
+ bridge.startExtraChannels();
5271
6151
  let attempt = 0;
5272
6152
  while (true) {
5273
6153
  try {
@@ -5278,7 +6158,7 @@ async function main() {
5278
6158
  attempt++;
5279
6159
  const msg = err instanceof Error ? err.message : String(err);
5280
6160
  log(`disconnected (${msg}) \u2014 reconnecting in ${Math.round(delay / 1e3)}s`);
5281
- await sleep(delay);
6161
+ await sleep2(delay);
5282
6162
  }
5283
6163
  }
5284
6164
  }