@openape/ape-agent 2.9.2 → 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;
@@ -802,17 +802,58 @@ ${e.cyan(d)}
802
802
  }
803
803
  });
804
804
 
805
- // ../../node_modules/.pnpm/shell-quote@1.8.3/node_modules/shell-quote/quote.js
805
+ // ../../node_modules/.pnpm/shell-quote@1.8.4/node_modules/shell-quote/quote.js
806
806
  var require_quote = __commonJS({
807
- "../../node_modules/.pnpm/shell-quote@1.8.3/node_modules/shell-quote/quote.js"(exports, module) {
807
+ "../../node_modules/.pnpm/shell-quote@1.8.4/node_modules/shell-quote/quote.js"(exports, module) {
808
808
  "use strict";
809
+ var OPS = [
810
+ "||",
811
+ "&&",
812
+ ";;",
813
+ "|&",
814
+ "<(",
815
+ "<<<",
816
+ ">>",
817
+ ">&",
818
+ "<&",
819
+ "&",
820
+ ";",
821
+ "(",
822
+ ")",
823
+ "|",
824
+ "<",
825
+ ">"
826
+ ];
827
+ var LINE_TERMINATORS = /[\n\r\u2028\u2029]/;
828
+ var GLOB_SHELL_SPECIAL = /[\s#!"$&'():;<=>@\\^`|]/g;
809
829
  module.exports = function quote(xs) {
810
830
  return xs.map(function(s2) {
811
831
  if (s2 === "") {
812
832
  return "''";
813
833
  }
814
834
  if (s2 && typeof s2 === "object") {
815
- return s2.op.replace(/(.)/g, "\\$1");
835
+ if (s2.op === "glob") {
836
+ if (typeof s2.pattern !== "string") {
837
+ throw new TypeError("glob token requires a string `pattern`");
838
+ }
839
+ if (LINE_TERMINATORS.test(s2.pattern)) {
840
+ throw new TypeError("glob `pattern` must not contain line terminators");
841
+ }
842
+ return s2.pattern.replace(GLOB_SHELL_SPECIAL, "\\$&");
843
+ }
844
+ if (typeof s2.op === "string") {
845
+ if (OPS.indexOf(s2.op) < 0) {
846
+ throw new TypeError("invalid `op` value: " + JSON.stringify(s2.op));
847
+ }
848
+ return s2.op.replace(/[\s\S]/g, "\\$&");
849
+ }
850
+ if (typeof s2.comment === "string") {
851
+ if (LINE_TERMINATORS.test(s2.comment)) {
852
+ throw new TypeError("`comment` must not contain line terminators");
853
+ }
854
+ return "#" + s2.comment;
855
+ }
856
+ throw new TypeError("unrecognized object token shape");
816
857
  }
817
858
  if (/["\s\\]/.test(s2) && !/'/.test(s2)) {
818
859
  return "'" + s2.replace(/(['])/g, "\\$1") + "'";
@@ -826,9 +867,9 @@ var require_quote = __commonJS({
826
867
  }
827
868
  });
828
869
 
829
- // ../../node_modules/.pnpm/shell-quote@1.8.3/node_modules/shell-quote/parse.js
870
+ // ../../node_modules/.pnpm/shell-quote@1.8.4/node_modules/shell-quote/parse.js
830
871
  var require_parse = __commonJS({
831
- "../../node_modules/.pnpm/shell-quote@1.8.3/node_modules/shell-quote/parse.js"(exports, module) {
872
+ "../../node_modules/.pnpm/shell-quote@1.8.4/node_modules/shell-quote/parse.js"(exports, module) {
832
873
  "use strict";
833
874
  var CONTROL = "(?:" + [
834
875
  "\\|\\|",
@@ -1023,9 +1064,9 @@ var require_parse = __commonJS({
1023
1064
  }
1024
1065
  });
1025
1066
 
1026
- // ../../node_modules/.pnpm/shell-quote@1.8.3/node_modules/shell-quote/index.js
1067
+ // ../../node_modules/.pnpm/shell-quote@1.8.4/node_modules/shell-quote/index.js
1027
1068
  var require_shell_quote = __commonJS({
1028
- "../../node_modules/.pnpm/shell-quote@1.8.3/node_modules/shell-quote/index.js"(exports) {
1069
+ "../../node_modules/.pnpm/shell-quote@1.8.4/node_modules/shell-quote/index.js"(exports) {
1029
1070
  "use strict";
1030
1071
  exports.quote = require_quote();
1031
1072
  exports.parse = require_parse();
@@ -1033,9 +1074,9 @@ var require_shell_quote = __commonJS({
1033
1074
  });
1034
1075
 
1035
1076
  // src/bridge.ts
1036
- import { existsSync as existsSync6, readFileSync as readFileSync8 } from "fs";
1037
- import { homedir as homedir9 } from "os";
1038
- 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";
1039
1080
  import process3 from "process";
1040
1081
 
1041
1082
  // ../../packages/cli-auth/dist/index.js
@@ -1054,22 +1095,33 @@ import { Buffer as Buffer3 } from "buffer";
1054
1095
  import { createPrivateKey } from "crypto";
1055
1096
  import { ofetch as ofetch4 } from "ofetch";
1056
1097
  import { ofetch as ofetch5 } from "ofetch";
1057
- function getConfigDir() {
1098
+ function getConfigDir(authHome) {
1099
+ if (authHome) return join(authHome, ".config", "apes");
1058
1100
  const override = process.env.OPENAPE_CLI_AUTH_HOME;
1059
1101
  if (override) return override;
1060
1102
  return join(homedir(), ".config", "apes");
1061
1103
  }
1062
- function getAuthFile() {
1063
- 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
+ }
1064
1115
  }
1065
- function ensureConfigDir() {
1066
- const dir = getConfigDir();
1116
+ function ensureSpTokensDir() {
1117
+ ensureConfigDir();
1118
+ const dir = getSpTokensDir();
1067
1119
  if (!existsSync(dir)) {
1068
1120
  mkdirSync(dir, { recursive: true, mode: 448 });
1069
1121
  }
1070
1122
  }
1071
- function loadIdpAuth() {
1072
- const file = getAuthFile();
1123
+ function loadIdpAuth(authHome) {
1124
+ const file = getAuthFile(authHome);
1073
1125
  if (!existsSync(file)) return null;
1074
1126
  try {
1075
1127
  const raw = readFileSync(file, "utf-8");
@@ -1079,9 +1131,9 @@ function loadIdpAuth() {
1079
1131
  return null;
1080
1132
  }
1081
1133
  }
1082
- function saveIdpAuth(auth) {
1083
- ensureConfigDir();
1084
- const file = getAuthFile();
1134
+ function saveIdpAuth(auth, authHome) {
1135
+ ensureConfigDir(authHome);
1136
+ const file = getAuthFile(authHome);
1085
1137
  let extra = {};
1086
1138
  if (existsSync(file)) {
1087
1139
  try {
@@ -1101,6 +1153,27 @@ function saveIdpAuth(auth) {
1101
1153
  const merged = { ...extra, ...auth };
1102
1154
  writeFileSync(file, JSON.stringify(merged, null, 2), { mode: 384 });
1103
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
+ }
1104
1177
  var AuthError = class extends Error {
1105
1178
  status;
1106
1179
  hint;
@@ -1122,6 +1195,39 @@ var NotLoggedInError = class extends AuthError {
1122
1195
  this.name = "NotLoggedInError";
1123
1196
  }
1124
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
+ }
1125
1231
  var OPENSSH_MAGIC = "openssh-key-v1\0";
1126
1232
  function loadEd25519PrivateKey(pem) {
1127
1233
  if (pem.includes("BEGIN OPENSSH PRIVATE KEY")) {
@@ -1277,8 +1383,8 @@ async function getTokenEndpoint(idp) {
1277
1383
  }
1278
1384
  return `${idp}/token`;
1279
1385
  }
1280
- async function ensureFreshIdpAuth(now = Math.floor(Date.now() / 1e3)) {
1281
- const auth = loadIdpAuth();
1386
+ async function ensureFreshIdpAuth(now = Math.floor(Date.now() / 1e3), authHome) {
1387
+ const auth = loadIdpAuth(authHome);
1282
1388
  if (!auth) {
1283
1389
  throw new NotLoggedInError();
1284
1390
  }
@@ -1288,7 +1394,7 @@ async function ensureFreshIdpAuth(now = Math.floor(Date.now() / 1e3)) {
1288
1394
  if (!auth.refresh_token) {
1289
1395
  const refreshed = await refreshAgentToken(auth, now);
1290
1396
  if (refreshed) {
1291
- saveIdpAuth(refreshed);
1397
+ saveIdpAuth(refreshed, authHome);
1292
1398
  return refreshed;
1293
1399
  }
1294
1400
  throw new NotLoggedInError(
@@ -1310,7 +1416,7 @@ async function ensureFreshIdpAuth(now = Math.floor(Date.now() / 1e3)) {
1310
1416
  } catch (err) {
1311
1417
  const status = err.status ?? err.statusCode ?? 0;
1312
1418
  if (status === 400 || status === 401) {
1313
- saveIdpAuth({ ...auth, refresh_token: void 0 });
1419
+ saveIdpAuth({ ...auth, refresh_token: void 0 }, authHome);
1314
1420
  throw new NotLoggedInError(
1315
1421
  `Refresh token rejected by ${auth.idp}. Run \`apes login\` again.`
1316
1422
  );
@@ -1330,9 +1436,26 @@ async function ensureFreshIdpAuth(now = Math.floor(Date.now() / 1e3)) {
1330
1436
  refresh_token: response.refresh_token ?? auth.refresh_token,
1331
1437
  expires_at: now + (response.expires_in ?? 3600)
1332
1438
  };
1333
- saveIdpAuth(next);
1439
+ saveIdpAuth(next, authHome);
1334
1440
  return next;
1335
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
+ }
1336
1459
 
1337
1460
  // ../../packages/prompt-injection-detector/dist/index.js
1338
1461
  var DEFAULT_THRESHOLD = 0.7;
@@ -1398,152 +1521,69 @@ function createHeuristicDetector() {
1398
1521
  import { decodeJwt } from "jose";
1399
1522
  import WebSocket from "ws";
1400
1523
 
1401
- // src/troop-chat-api.ts
1402
- import { ofetch as ofetch6 } from "ofetch";
1403
- var MAX_BODY = 64 * 1024;
1404
- var SYNTHETIC_THREAD_ID = "main";
1405
- function asHistory(msg, agentEmail, ownerEmail) {
1406
- return {
1407
- id: msg.id,
1408
- roomId: msg.chatId,
1409
- threadId: SYNTHETIC_THREAD_ID,
1410
- senderEmail: msg.role === "agent" ? agentEmail : ownerEmail,
1411
- senderAct: msg.role,
1412
- body: msg.body,
1413
- replyTo: msg.replyTo,
1414
- createdAt: msg.createdAt
1415
- };
1416
- }
1417
- function asPosted(msg) {
1418
- return {
1419
- id: msg.id,
1420
- roomId: msg.chatId,
1421
- threadId: SYNTHETIC_THREAD_ID,
1422
- body: msg.body,
1423
- createdAt: msg.createdAt
1424
- };
1425
- }
1426
- var TroopChatApi = class {
1427
- constructor(endpoint, bearer) {
1428
- this.endpoint = endpoint;
1429
- this.bearer = bearer;
1430
- }
1431
- endpoint;
1432
- bearer;
1433
- bootstrap = null;
1434
- /** Resolve + cache the agent's chat row (lazy fetch on first use). */
1435
- async getBootstrap() {
1436
- if (this.bootstrap) return this.bootstrap;
1437
- this.bootstrap = await ofetch6(`${this.endpoint}/api/agents/me/chat`, {
1438
- method: "GET",
1439
- headers: { Authorization: await this.bearer() }
1440
- });
1441
- return this.bootstrap;
1442
- }
1443
- /** chat.id + (lazy-fetched) ownerEmail for the bridge's frame-translation path. */
1444
- async getChatContext() {
1445
- const b2 = await this.getBootstrap();
1446
- return { chatId: b2.chat.id, ownerEmail: b2.chat.ownerEmail, agentEmail: b2.chat.agentEmail };
1447
- }
1448
- async postMessage(roomId, body, opts = {}) {
1449
- void roomId;
1450
- void opts.threadId;
1451
- const payload = {
1452
- body: body.length > MAX_BODY ? `${body.slice(0, MAX_BODY - 1)}\u2026` : body
1453
- };
1454
- if (opts.replyTo) payload.reply_to = opts.replyTo;
1455
- if (opts.streaming) payload.streaming = true;
1456
- const msg = await ofetch6(`${this.endpoint}/api/agents/me/chat/messages`, {
1457
- method: "POST",
1458
- headers: { Authorization: await this.bearer() },
1459
- body: payload
1460
- });
1461
- return asPosted(msg);
1462
- }
1463
- async listMessages(roomId, threadId, limit = 50) {
1464
- void roomId;
1465
- void threadId;
1466
- void limit;
1467
- const fresh = await ofetch6(`${this.endpoint}/api/agents/me/chat`, {
1468
- method: "GET",
1469
- headers: { Authorization: await this.bearer() }
1470
- });
1471
- this.bootstrap = fresh;
1472
- return fresh.messages.map((m2) => asHistory(m2, fresh.chat.agentEmail, fresh.chat.ownerEmail));
1473
- }
1474
- async patchMessage(messageId, opts = {}) {
1475
- const payload = {};
1476
- if (opts.body !== void 0) {
1477
- payload.body = opts.body.length > MAX_BODY ? `${opts.body.slice(0, MAX_BODY - 1)}\u2026` : opts.body;
1478
- }
1479
- if (opts.streaming !== void 0) payload.streaming = opts.streaming;
1480
- if (opts.streamingStatus !== void 0) payload.streaming_status = opts.streamingStatus;
1481
- if (Object.keys(payload).length === 0) return;
1482
- await ofetch6(`${this.endpoint}/api/agents/me/chat/messages/${encodeURIComponent(messageId)}`, {
1483
- method: "PATCH",
1484
- headers: { Authorization: await this.bearer() },
1485
- body: payload
1486
- });
1487
- }
1488
- /**
1489
- * Troop's chat doesn't have contacts — synthesize a single
1490
- * always-connected entry pointing at the owner so the bridge's
1491
- * initial-contact + allowlist flows are no-ops.
1492
- */
1493
- async listContacts() {
1494
- const b2 = await this.getBootstrap();
1495
- return [{
1496
- peerEmail: b2.chat.ownerEmail,
1497
- myStatus: "accepted",
1498
- theirStatus: "accepted",
1499
- connected: true,
1500
- roomId: b2.chat.id
1501
- }];
1502
- }
1503
- async requestContact(peerEmail) {
1504
- void peerEmail;
1505
- return (await this.listContacts())[0];
1506
- }
1507
- async acceptContact(peerEmail) {
1508
- void peerEmail;
1509
- return (await this.listContacts())[0];
1510
- }
1511
- /**
1512
- * Troop has no threads — return a synthetic one. The bridge's
1513
- * cron-runner falls back to the main thread on createThread
1514
- * failure already, so a stable "main" stand-in is the right shape.
1515
- */
1516
- async createThread(roomId, name) {
1517
- void roomId;
1518
- return { id: SYNTHETIC_THREAD_ID, name: name.slice(0, 100) };
1519
- }
1520
- };
1521
-
1522
- // src/cron-runner.ts
1523
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
1524
- import { homedir as homedir6 } from "os";
1525
- import { join as join4 } from "path";
1526
-
1527
- // ../../packages/apes/dist/chunk-BA2V3BBO.js
1528
- init_chunk_OBF7IMQ2();
1529
-
1530
- // ../../node_modules/.pnpm/citty@0.2.2/node_modules/citty/dist/index.mjs
1531
- import { parseArgs as parseArgs$1 } from "util";
1532
- function defineCommand(def) {
1533
- return def;
1534
- }
1535
-
1536
- // ../../packages/shapes/dist/index.js
1537
- import { createHash } from "crypto";
1538
- import { existsSync as existsSync22, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
1539
- import { homedir as homedir22 } from "os";
1540
- 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";
1541
1528
 
1542
1529
  // ../../packages/core/dist/index.js
1543
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";
1544
1541
  import { lookup } from "dns/promises";
1545
1542
  import { isIP } from "net";
1546
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
+ }
1547
1587
  function isBlockedAddress(ip) {
1548
1588
  const fam = isIP(ip);
1549
1589
  if (fam === 4) {
@@ -1598,6 +1638,100 @@ async function assertPublicUrl(rawUrl, opts = {}) {
1598
1638
  return url;
1599
1639
  }
1600
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
+
1601
1735
  // ../../packages/grants/dist/index.js
1602
1736
  function normalizeSelector(selector) {
1603
1737
  if (!selector)
@@ -2366,20 +2500,20 @@ var isColorSupported = !isDisabled && (isForced || isWindows && !isDumbTerminal
2366
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)) {
2367
2501
  return head + (next < 0 ? tail : replaceClose(next, tail, close, replace));
2368
2502
  }
2369
- function clearBleed(index, string, open, close, replace) {
2370
- 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;
2371
2505
  }
2372
- function filterEmpty(open, close, replace = open, at = open.length + 1) {
2506
+ function filterEmpty(open2, close, replace = open2, at = open2.length + 1) {
2373
2507
  return (string) => string || !(string === "" || string === void 0) ? clearBleed(
2374
2508
  ("" + string).indexOf(close, at),
2375
2509
  string,
2376
- open,
2510
+ open2,
2377
2511
  close,
2378
2512
  replace
2379
2513
  ) : "";
2380
2514
  }
2381
- function init(open, close, replace) {
2382
- 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);
2383
2517
  }
2384
2518
  var colorDefs = {
2385
2519
  reset: init(0, 0),
@@ -2999,10 +3133,10 @@ function findByExecutable(executable) {
2999
3133
  if (!existsSync22(dir))
3000
3134
  continue;
3001
3135
  try {
3002
- const files = readdirSync2(dir).filter((f3) => f3.endsWith(".toml"));
3136
+ const files = readdirSync3(dir).filter((f3) => f3.endsWith(".toml"));
3003
3137
  for (const file of files) {
3004
3138
  const path = join22(dir, file);
3005
- const content = readFileSync3(path, "utf-8");
3139
+ const content = readFileSync4(path, "utf-8");
3006
3140
  const match = content.match(/^\s*executable\s*=\s*"([^"]+)"/m);
3007
3141
  if (match && match[1] === executable)
3008
3142
  return path;
@@ -3029,7 +3163,7 @@ function resolveAdapterPath(cliId, explicitPath) {
3029
3163
  }
3030
3164
  function loadAdapter(cliId, explicitPath) {
3031
3165
  const source = resolveAdapterPath(cliId, explicitPath);
3032
- const content = readFileSync3(source, "utf-8");
3166
+ const content = readFileSync4(source, "utf-8");
3033
3167
  const adapter = parseAdapterToml(content);
3034
3168
  const idMatch = adapter.cli.id === cliId;
3035
3169
  const fileMatch = basename(source) === `${cliId}.toml`;
@@ -3130,7 +3264,7 @@ function extractOption(args, name) {
3130
3264
  return void 0;
3131
3265
  }
3132
3266
 
3133
- // ../../packages/apes/dist/chunk-NYJSBFLG.js
3267
+ // ../../packages/apes/dist/chunk-MMBFV5WN.js
3134
3268
  init_chunk_OBF7IMQ2();
3135
3269
  var debug = process.argv.includes("--debug");
3136
3270
 
@@ -3139,13 +3273,16 @@ init_chunk_OBF7IMQ2();
3139
3273
 
3140
3274
  // ../../packages/agent-runtime/dist/index.js
3141
3275
  import { spawn } from "child_process";
3142
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
3143
- import { homedir as homedir4 } from "os";
3144
- 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";
3145
3279
  import { homedir as homedir23 } from "os";
3146
3280
  import { resolve as resolve2 } from "path";
3147
3281
  import process2 from "process";
3148
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";
3149
3286
  import { execFileSync as execFileSync2 } from "child_process";
3150
3287
  var DEFAULT_TIMEOUT_MS = 5 * 60 * 1e3;
3151
3288
  var MAX_STDIO_BYTES = 64 * 1024;
@@ -3156,13 +3293,14 @@ function capStdio(s2) {
3156
3293
  return `${buf.subarray(0, MAX_STDIO_BYTES).toString("utf8")}
3157
3294
  [truncated to ${MAX_STDIO_BYTES} bytes]`;
3158
3295
  }
3159
- function runApeShell(cmd, timeoutMs = DEFAULT_TIMEOUT_MS) {
3296
+ function runApeShell(cmd, timeoutMs = DEFAULT_TIMEOUT_MS, cwd) {
3160
3297
  const bypass = process.env.OPENAPE_BYPASS_APE_SHELL === "1";
3161
3298
  const [execBin, execArgs] = bypass ? ["/bin/bash", ["-c", cmd]] : [BIN, ["-c", cmd]];
3162
3299
  return new Promise((resolveResult) => {
3163
3300
  const child = spawn(execBin, execArgs, {
3164
3301
  env: { ...process.env, APE_WAIT: "1" },
3165
- stdio: ["ignore", "pipe", "pipe"]
3302
+ stdio: ["ignore", "pipe", "pipe"],
3303
+ ...cwd ? { cwd } : {}
3166
3304
  });
3167
3305
  let stdout2 = "";
3168
3306
  let stderr = "";
@@ -3238,21 +3376,31 @@ var bashTools = [
3238
3376
  }
3239
3377
  ];
3240
3378
  var MAX_BYTES = 1024 * 1024;
3241
- 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 = {}) {
3242
3387
  if (typeof input !== "string" || input === "") {
3243
3388
  throw new Error("path must be a non-empty string");
3244
3389
  }
3245
- const home = homedir4();
3390
+ const home = homedir6();
3246
3391
  const candidate = input.startsWith("~/") ? resolve(home, input.slice(2)) : input.startsWith("/") ? normalize(input) : resolve(home, input);
3247
- if (candidate !== home && !candidate.startsWith(`${home}/`)) {
3248
- 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
+ }
3249
3397
  }
3250
- return candidate;
3398
+ throw new Error(`path "${input}" resolves outside the agent's home`);
3251
3399
  }
3252
3400
  var fileTools = [
3253
3401
  {
3254
3402
  name: "file.read",
3255
- 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.",
3256
3404
  parameters: {
3257
3405
  type: "object",
3258
3406
  properties: {
@@ -3262,8 +3410,8 @@ var fileTools = [
3262
3410
  },
3263
3411
  execute: async (args) => {
3264
3412
  const a2 = args;
3265
- const p = jailPath(a2.path);
3266
- const content = readFileSync4(p, "utf8");
3413
+ const p = jailPath(a2.path, { allowReadRoots: true });
3414
+ const content = readFileSync5(p, "utf8");
3267
3415
  if (Buffer.byteLength(content, "utf8") > MAX_BYTES) {
3268
3416
  return { path: p, truncated: true, content: content.slice(0, MAX_BYTES) };
3269
3417
  }
@@ -3288,8 +3436,8 @@ var fileTools = [
3288
3436
  throw new Error(`content exceeds ${MAX_BYTES} byte cap`);
3289
3437
  }
3290
3438
  const p = jailPath(a2.path);
3291
- mkdirSync2(dirname(p), { recursive: true });
3292
- writeFileSync2(p, a2.content, { encoding: "utf8" });
3439
+ mkdirSync3(dirname2(p), { recursive: true });
3440
+ writeFileSync3(p, a2.content, { encoding: "utf8" });
3293
3441
  return { path: p, bytes: Buffer.byteLength(a2.content, "utf8") };
3294
3442
  }
3295
3443
  },
@@ -3319,7 +3467,7 @@ var fileTools = [
3319
3467
  }
3320
3468
  const replaceAll = a2.replace_all === true;
3321
3469
  const p = jailPath(a2.path);
3322
- const before = readFileSync4(p, "utf8");
3470
+ const before = readFileSync5(p, "utf8");
3323
3471
  const occurrences = before.split(a2.old_string).length - 1;
3324
3472
  if (occurrences === 0) {
3325
3473
  throw new Error("old_string not found in file");
@@ -3331,7 +3479,7 @@ var fileTools = [
3331
3479
  if (Buffer.byteLength(after, "utf8") > MAX_BYTES) {
3332
3480
  throw new Error(`result exceeds ${MAX_BYTES} byte cap`);
3333
3481
  }
3334
- writeFileSync2(p, after, { encoding: "utf8" });
3482
+ writeFileSync3(p, after, { encoding: "utf8" });
3335
3483
  return { path: p, replacements: replaceAll ? occurrences : 1 };
3336
3484
  }
3337
3485
  }
@@ -3804,50 +3952,146 @@ var mailTools = [
3804
3952
  }
3805
3953
  }
3806
3954
  ];
3807
- function ape(args) {
3808
- try {
3809
- return execFileSync2("ape-tasks", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
3810
- } catch (err) {
3811
- const e2 = err;
3812
- const stderr = typeof e2.stderr === "string" ? e2.stderr : e2.stderr?.toString("utf8");
3813
- throw new Error(`ape-tasks failed: ${stderr ?? e2.message ?? err}`);
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?)`);
3814
3972
  }
3973
+ return (await res.json()).access_token;
3815
3974
  }
3816
- var tasksTools = [
3975
+ var spawnTools = [
3817
3976
  {
3818
- name: "tasks.list",
3819
- description: "List the owner's open ape-tasks (the user's personal task list at tasks.openape.ai).",
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.",
3820
3979
  parameters: {
3821
3980
  type: "object",
3822
3981
  properties: {
3823
- status: { type: "string", enum: ["open", "doing", "done", "archived"] },
3824
- team_id: { type: "string" }
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" }
3825
3987
  },
3826
- required: []
3988
+ required: ["name"]
3827
3989
  },
3828
3990
  execute: async (args) => {
3829
- const a2 = args ?? {};
3830
- const argv2 = ["list", "--json"];
3831
- if (a2.status) argv2.push("--status", a2.status);
3832
- if (a2.team_id) argv2.push("--team", a2.team_id);
3833
- const out = ape(argv2);
3991
+ const a2 = args;
3992
+ const base = troopBase();
3993
+ let bearer;
3834
3994
  try {
3835
- return JSON.parse(out);
3836
- } catch {
3837
- return { raw: out };
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
+ ];
4048
+ function ape(args) {
4049
+ try {
4050
+ return execFileSync2("ape-tasks", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
4051
+ } catch (err) {
4052
+ const e2 = err;
4053
+ const stderr = typeof e2.stderr === "string" ? e2.stderr : e2.stderr?.toString("utf8");
4054
+ throw new Error(`ape-tasks failed: ${stderr ?? e2.message ?? err}`);
4055
+ }
4056
+ }
4057
+ var tasksTools = [
4058
+ {
4059
+ name: "tasks.list",
4060
+ description: "List the owner's open ape-tasks (the user's personal task list at tasks.openape.ai).",
4061
+ parameters: {
4062
+ type: "object",
4063
+ properties: {
4064
+ status: { type: "string", enum: ["open", "doing", "done", "archived"] },
4065
+ team_id: { type: "string" }
4066
+ },
4067
+ required: []
4068
+ },
4069
+ execute: async (args) => {
4070
+ const a2 = args ?? {};
4071
+ const argv2 = ["list", "--json"];
4072
+ if (a2.status) argv2.push("--status", a2.status);
4073
+ if (a2.team_id) argv2.push("--team", a2.team_id);
4074
+ const out = ape(argv2);
4075
+ try {
4076
+ return JSON.parse(out);
4077
+ } catch {
4078
+ return { raw: out };
3838
4079
  }
3839
4080
  }
3840
4081
  },
3841
4082
  {
3842
4083
  name: "tasks.create",
3843
- 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.",
3844
4085
  parameters: {
3845
4086
  type: "object",
3846
4087
  properties: {
3847
4088
  title: { type: "string" },
3848
4089
  notes: { type: "string" },
3849
4090
  priority: { type: "string", enum: ["low", "med", "high"] },
3850
- 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." }
3851
4095
  },
3852
4096
  required: ["title"]
3853
4097
  },
@@ -3857,6 +4101,9 @@ var tasksTools = [
3857
4101
  if (a2.notes) argv2.push("--notes", a2.notes);
3858
4102
  if (a2.priority) argv2.push("--priority", a2.priority);
3859
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);
3860
4107
  const out = ape(argv2);
3861
4108
  try {
3862
4109
  return JSON.parse(out);
@@ -3881,6 +4128,83 @@ var timeTools = [
3881
4128
  }
3882
4129
  }
3883
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
+ ];
3884
4208
  var CWD_RE = /^[\w./-]{1,256}$/;
3885
4209
  async function runVerify(cwd, command, timeoutMs) {
3886
4210
  if (typeof cwd !== "string" || !CWD_RE.test(cwd)) {
@@ -3927,7 +4251,9 @@ var ALL_TOOLS = [
3927
4251
  ...bashTools,
3928
4252
  ...gitWorktreeTools,
3929
4253
  ...verifyTools,
3930
- ...forgeTools
4254
+ ...forgeTools,
4255
+ ...spawnTools,
4256
+ ...troopTools
3931
4257
  ];
3932
4258
  var TOOLS = Object.fromEntries(
3933
4259
  ALL_TOOLS.map((t2) => [t2.name, t2])
@@ -4028,6 +4354,7 @@ async function runLoop(opts) {
4028
4354
  const requestBody = {
4029
4355
  model: opts.config.model,
4030
4356
  messages,
4357
+ ...opts.config.reasoningEffort ? { reasoning_effort: opts.config.reasoningEffort } : {},
4031
4358
  ...tools.length > 0 ? { tools, tool_choice: "auto" } : {},
4032
4359
  ...opts.streamAggregate ? { stream: true } : {}
4033
4360
  };
@@ -4138,10 +4465,180 @@ var REVIEW_SYSTEM = [
4138
4465
  'Respond ONLY as JSON: {"approved": boolean, "reason": string}.'
4139
4466
  ].join(" ");
4140
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
+
4141
4632
  // src/cron-runner.ts
4142
- var TASK_CACHE_DIR = join4(homedir6(), ".openape", "agent", "tasks");
4143
- var AGENT_CONFIG_PATH = join4(homedir6(), ".openape", "agent", "agent.json");
4144
- var TASK_THREADS_PATH = join4(homedir6(), ".openape", "agent", "task-threads.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");
4638
+ function resolveRecipeDir() {
4639
+ return process.env.OPENAPE_RECIPE_DEV_DIR || join7(homedir7(), "recipe");
4640
+ }
4641
+ var TASK_THREADS_PATH = join7(homedir7(), ".openape", "agent", "task-threads.json");
4145
4642
  var TICK_INTERVAL_MS = 6e4;
4146
4643
  function parseField(token, range, allowStep) {
4147
4644
  if (token === "*") return { type: "any" };
@@ -4183,13 +4680,13 @@ function cronMatches(expr, now) {
4183
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);
4184
4681
  }
4185
4682
  function readTaskSpecs() {
4186
- if (!existsSync3(TASK_CACHE_DIR)) return [];
4683
+ if (!existsSync4(TASK_CACHE_DIR)) return [];
4187
4684
  const out = [];
4188
- for (const entry of readdirSync3(TASK_CACHE_DIR)) {
4685
+ for (const entry of readdirSync4(TASK_CACHE_DIR)) {
4189
4686
  if (!entry.endsWith(".json")) continue;
4190
- const path = join4(TASK_CACHE_DIR, entry);
4687
+ const path = join7(TASK_CACHE_DIR, entry);
4191
4688
  try {
4192
- const t2 = JSON.parse(readFileSync5(path, "utf8"));
4689
+ const t2 = JSON.parse(readFileSync6(path, "utf8"));
4193
4690
  if (t2.taskId && t2.cron && t2.enabled !== false) out.push(t2);
4194
4691
  } catch {
4195
4692
  }
@@ -4207,10 +4704,13 @@ ${tail(out, 2500)}`);
4207
4704
  ${tail(err, 2500)}`);
4208
4705
  return parts.join("\n\n");
4209
4706
  }
4707
+ function shouldReportCommandRun(exitCode, stdout2, stderr) {
4708
+ return exitCode !== 0 || `${stdout2}${stderr}`.trim() !== "";
4709
+ }
4210
4710
  function readSystemPrompt() {
4211
- if (!existsSync3(AGENT_CONFIG_PATH)) return "";
4711
+ if (!existsSync4(AGENT_CONFIG_PATH)) return "";
4212
4712
  try {
4213
- const parsed = JSON.parse(readFileSync5(AGENT_CONFIG_PATH, "utf8"));
4713
+ const parsed = JSON.parse(readFileSync6(AGENT_CONFIG_PATH, "utf8"));
4214
4714
  return typeof parsed.systemPrompt === "string" ? parsed.systemPrompt : "";
4215
4715
  } catch {
4216
4716
  return "";
@@ -4237,9 +4737,9 @@ var CronRunner = class {
4237
4737
  */
4238
4738
  taskThreads = /* @__PURE__ */ new Map();
4239
4739
  loadTaskThreads() {
4240
- if (!existsSync3(TASK_THREADS_PATH)) return;
4740
+ if (!existsSync4(TASK_THREADS_PATH)) return;
4241
4741
  try {
4242
- const parsed = JSON.parse(readFileSync5(TASK_THREADS_PATH, "utf8"));
4742
+ const parsed = JSON.parse(readFileSync6(TASK_THREADS_PATH, "utf8"));
4243
4743
  for (const [k2, v2] of Object.entries(parsed)) {
4244
4744
  if (typeof v2 === "string") this.taskThreads.set(k2, v2);
4245
4745
  }
@@ -4248,9 +4748,9 @@ var CronRunner = class {
4248
4748
  }
4249
4749
  persistTaskThreads() {
4250
4750
  try {
4251
- const dir = join4(homedir6(), ".openape", "agent");
4252
- mkdirSync3(dir, { recursive: true });
4253
- writeFileSync3(
4751
+ const dir = join7(homedir7(), ".openape", "agent");
4752
+ mkdirSync4(dir, { recursive: true });
4753
+ writeFileSync4(
4254
4754
  TASK_THREADS_PATH,
4255
4755
  `${JSON.stringify(Object.fromEntries(this.taskThreads), null, 2)}
4256
4756
  `,
@@ -4327,13 +4827,15 @@ var CronRunner = class {
4327
4827
  async runTask(sessionId, systemPrompt, spec) {
4328
4828
  if (spec.command) {
4329
4829
  try {
4330
- const res = await runApeShell(spec.command, 30 * 60 * 1e3);
4830
+ const recipeDir = resolveRecipeDir();
4831
+ const res = await runApeShell(spec.command, 30 * 60 * 1e3, existsSync4(recipeDir) ? recipeDir : void 0);
4331
4832
  const turn = this.pending.get(sessionId);
4332
4833
  if (!turn) return;
4333
4834
  turn.status = res.exit_code === 0 ? "ok" : "error";
4334
4835
  turn.accumulated = composeTaskOutput(spec.command, res.exit_code, res.stdout, res.stderr);
4335
4836
  await this.finaliseRun(turn, 1);
4336
- await this.postResult(sessionId, turn);
4837
+ if (shouldReportCommandRun(res.exit_code, res.stdout, res.stderr))
4838
+ await this.postResult(sessionId, turn);
4337
4839
  this.pending.delete(sessionId);
4338
4840
  } catch (err) {
4339
4841
  const turn = this.pending.get(sessionId);
@@ -4347,8 +4849,9 @@ var CronRunner = class {
4347
4849
  return;
4348
4850
  }
4349
4851
  try {
4852
+ const apiKey = this.deps.refreshApiKey ? await this.deps.refreshApiKey() : this.deps.runtimeConfig.apiKey;
4350
4853
  const result = await runLoop({
4351
- config: this.deps.runtimeConfig,
4854
+ config: { ...this.deps.runtimeConfig, apiKey },
4352
4855
  systemPrompt,
4353
4856
  userMessage: spec.userPrompt,
4354
4857
  tools: taskTools(spec.tools),
@@ -4428,22 +4931,36 @@ ${text}`.slice(0, 9e3);
4428
4931
  }
4429
4932
  };
4430
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
+
4431
4948
  // src/identity.ts
4432
- import { existsSync as existsSync4, readFileSync as readFileSync6 } from "fs";
4433
- import { homedir as homedir7 } from "os";
4434
- import { join as join6 } from "path";
4435
- function authPath() {
4436
- 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");
4437
4954
  }
4438
4955
  function allowlistPath() {
4439
- return join6(homedir7(), ".config", "openape", "bridge-allowlist.json");
4956
+ return join8(homedir8(), ".config", "openape", "bridge-allowlist.json");
4440
4957
  }
4441
- function readAgentIdentity() {
4442
- const path = authPath();
4443
- if (!existsSync4(path)) {
4958
+ function readAgentIdentity(home = homedir8()) {
4959
+ const path = authPath(home);
4960
+ if (!existsSync5(path)) {
4444
4961
  throw new Error(`agent identity not found at ${path}`);
4445
4962
  }
4446
- const raw = readFileSync6(path, "utf8");
4963
+ const raw = readFileSync7(path, "utf8");
4447
4964
  const parsed = JSON.parse(raw);
4448
4965
  if (!parsed.email) throw new Error(`auth.json at ${path} missing 'email'`);
4449
4966
  if (!parsed.idp) throw new Error(`auth.json at ${path} missing 'idp'`);
@@ -4457,9 +4974,9 @@ function readAgentIdentity() {
4457
4974
  }
4458
4975
  function readAllowlist() {
4459
4976
  const path = allowlistPath();
4460
- if (!existsSync4(path)) return /* @__PURE__ */ new Set();
4977
+ if (!existsSync5(path)) return /* @__PURE__ */ new Set();
4461
4978
  try {
4462
- const parsed = JSON.parse(readFileSync6(path, "utf8"));
4979
+ const parsed = JSON.parse(readFileSync7(path, "utf8"));
4463
4980
  if (!Array.isArray(parsed.emails)) return /* @__PURE__ */ new Set();
4464
4981
  return new Set(parsed.emails.map((e2) => e2.toLowerCase()));
4465
4982
  } catch {
@@ -4474,24 +4991,24 @@ function shouldAutoAccept(peerEmail, identity, allowlist) {
4474
4991
 
4475
4992
  // src/skills.ts
4476
4993
  import { execFileSync as execFileSync3 } from "child_process";
4477
- import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync7, statSync } from "fs";
4478
- import { homedir as homedir8 } from "os";
4479
- 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";
4480
4997
  import { fileURLToPath } from "url";
4481
4998
  import { parse as parseYaml } from "yaml";
4482
4999
  var SKILLS_SUBDIR = [".openape", "agent", "skills"];
4483
5000
  var SOUL_PATH_PARTS = [".openape", "agent", "SOUL.md"];
4484
- function soulPath(home = homedir8()) {
4485
- return join7(home, ...SOUL_PATH_PARTS);
5001
+ function soulPath(home = homedir9()) {
5002
+ return join9(home, ...SOUL_PATH_PARTS);
4486
5003
  }
4487
- function skillsDir(home = homedir8()) {
4488
- return join7(home, ...SKILLS_SUBDIR);
5004
+ function skillsDir(home = homedir9()) {
5005
+ return join9(home, ...SKILLS_SUBDIR);
4489
5006
  }
4490
- function readSoul(home = homedir8()) {
5007
+ function readSoul(home = homedir9()) {
4491
5008
  const path = soulPath(home);
4492
- if (!existsSync5(path)) return null;
5009
+ if (!existsSync6(path)) return null;
4493
5010
  try {
4494
- const body = readFileSync7(path, "utf8").trim();
5011
+ const body = readFileSync8(path, "utf8").trim();
4495
5012
  return body.length > 0 ? body : null;
4496
5013
  } catch {
4497
5014
  return null;
@@ -4547,27 +5064,27 @@ function hasBinaryOnPath(bin) {
4547
5064
  return found;
4548
5065
  }
4549
5066
  function scanSkillsDir(dir) {
4550
- if (!existsSync5(dir)) return [];
5067
+ if (!existsSync6(dir)) return [];
4551
5068
  let entries;
4552
5069
  try {
4553
- entries = readdirSync4(dir);
5070
+ entries = readdirSync5(dir);
4554
5071
  } catch {
4555
5072
  return [];
4556
5073
  }
4557
5074
  const out = [];
4558
5075
  for (const entry of entries) {
4559
- const skillPath = join7(dir, entry, "SKILL.md");
4560
- if (!existsSync5(skillPath)) continue;
5076
+ const skillPath = join9(dir, entry, "SKILL.md");
5077
+ if (!existsSync6(skillPath)) continue;
4561
5078
  let st;
4562
5079
  try {
4563
- st = statSync(skillPath);
5080
+ st = statSync2(skillPath);
4564
5081
  } catch {
4565
5082
  continue;
4566
5083
  }
4567
5084
  if (!st.isFile()) continue;
4568
5085
  let body;
4569
5086
  try {
4570
- body = readFileSync7(skillPath, "utf8");
5087
+ body = readFileSync8(skillPath, "utf8");
4571
5088
  } catch {
4572
5089
  continue;
4573
5090
  }
@@ -4584,7 +5101,7 @@ function scanSkillsDir(dir) {
4584
5101
  return out;
4585
5102
  }
4586
5103
  function defaultSkillsDir() {
4587
- const here = dirname2(fileURLToPath(import.meta.url));
5104
+ const here = dirname3(fileURLToPath(import.meta.url));
4588
5105
  return resolve3(here, "..", "default-skills");
4589
5106
  }
4590
5107
  function composeSkills(home, enabledTools) {
@@ -4631,7 +5148,7 @@ function formatSkillsBlock(skills) {
4631
5148
  return lines.join("\n");
4632
5149
  }
4633
5150
  function composeSystemPrompt(input) {
4634
- const home = input.home ?? homedir8();
5151
+ const home = input.home ?? homedir9();
4635
5152
  const parts = [];
4636
5153
  const defaultPersona = readDefaultPersona();
4637
5154
  if (defaultPersona) parts.push(defaultPersona);
@@ -4648,13 +5165,13 @@ var _defaultPersonaCache;
4648
5165
  function readDefaultPersona() {
4649
5166
  if (_defaultPersonaCache !== void 0) return _defaultPersonaCache;
4650
5167
  try {
4651
- const here = dirname2(fileURLToPath(import.meta.url));
5168
+ const here = dirname3(fileURLToPath(import.meta.url));
4652
5169
  const path = resolve3(here, "..", "default-persona.md");
4653
- if (!existsSync5(path)) {
5170
+ if (!existsSync6(path)) {
4654
5171
  _defaultPersonaCache = null;
4655
5172
  return null;
4656
5173
  }
4657
- const raw = readFileSync7(path, "utf8").trim();
5174
+ const raw = readFileSync8(path, "utf8").trim();
4658
5175
  _defaultPersonaCache = raw.length > 0 ? raw : null;
4659
5176
  return _defaultPersonaCache;
4660
5177
  } catch {
@@ -4773,6 +5290,7 @@ var ThreadSession = class {
4773
5290
  }
4774
5291
  };
4775
5292
  const { systemPrompt, tools } = this.deps.resolveConfig();
5293
+ const runtimeConfig = this.deps.refreshRuntimeConfig ? await this.deps.refreshRuntimeConfig() : this.deps.runtimeConfig;
4776
5294
  await this.backfillHistoryOnce(replyToMessageId, body);
4777
5295
  let sawActivity = false;
4778
5296
  let turnSettled = false;
@@ -4788,7 +5306,7 @@ var ThreadSession = class {
4788
5306
  }, NO_ACTIVITY_TIMEOUT_MS);
4789
5307
  try {
4790
5308
  const result = await runLoop({
4791
- config: this.deps.runtimeConfig,
5309
+ config: runtimeConfig,
4792
5310
  systemPrompt,
4793
5311
  userMessage: body,
4794
5312
  tools: taskTools(tools),
@@ -4911,21 +5429,392 @@ var ThreadSession = class {
4911
5429
  }
4912
5430
  };
4913
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
+
4914
5777
  // src/bridge.ts
4915
- 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
+ }
4916
5805
  function resolveSystemPrompt(envFallback) {
4917
- if (!existsSync6(AGENT_CONFIG_PATH2)) return envFallback;
5806
+ if (!existsSync7(AGENT_CONFIG_PATH2)) return envFallback;
4918
5807
  try {
4919
- const parsed = JSON.parse(readFileSync8(AGENT_CONFIG_PATH2, "utf8"));
5808
+ const parsed = JSON.parse(readFileSync9(AGENT_CONFIG_PATH2, "utf8"));
4920
5809
  return typeof parsed.systemPrompt === "string" ? parsed.systemPrompt : envFallback;
4921
5810
  } catch {
4922
5811
  return envFallback;
4923
5812
  }
4924
5813
  }
4925
5814
  function resolveTools(envFallback) {
4926
- if (existsSync6(AGENT_CONFIG_PATH2)) {
5815
+ if (existsSync7(AGENT_CONFIG_PATH2)) {
4927
5816
  try {
4928
- const parsed = JSON.parse(readFileSync8(AGENT_CONFIG_PATH2, "utf8"));
5817
+ const parsed = JSON.parse(readFileSync9(AGENT_CONFIG_PATH2, "utf8"));
4929
5818
  if (Array.isArray(parsed.tools)) {
4930
5819
  return parsed.tools.filter((t2) => typeof t2 === "string");
4931
5820
  }
@@ -4934,35 +5823,10 @@ function resolveTools(envFallback) {
4934
5823
  }
4935
5824
  return envFallback;
4936
5825
  }
4937
- var DEFAULT_ENDPOINT = "https://troop.openape.ai";
4938
- var DEFAULT_APES_BIN = "apes";
4939
- var DEFAULT_MAX_STEPS = 10;
4940
- 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.`;
4941
5826
  var PING_INTERVAL_MS = 3e4;
4942
5827
  var RECONNECT_BASE_MS = 1e3;
4943
5828
  var RECONNECT_MAX_MS = 3e4;
4944
5829
  var ALLOWLIST_POLL_INTERVAL_MS = 3e4;
4945
- function readConfig() {
4946
- const toolsRaw = process3.env.APE_CHAT_BRIDGE_TOOLS ?? "";
4947
- const tools = toolsRaw.split(",").map((s2) => s2.trim()).filter(Boolean);
4948
- const maxStepsRaw = process3.env.APE_CHAT_BRIDGE_MAX_STEPS;
4949
- const maxSteps = maxStepsRaw ? Number.parseInt(maxStepsRaw, 10) : DEFAULT_MAX_STEPS;
4950
- const model = process3.env.APE_CHAT_BRIDGE_MODEL;
4951
- if (!model) {
4952
- throw new Error(
4953
- "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)."
4954
- );
4955
- }
4956
- return {
4957
- endpoint: (process3.env.OPENAPE_TROOP_URL ?? DEFAULT_ENDPOINT).replace(/\/$/, ""),
4958
- apesBin: process3.env.APE_CHAT_BRIDGE_APES_BIN ?? DEFAULT_APES_BIN,
4959
- model,
4960
- systemPrompt: process3.env.APE_CHAT_BRIDGE_SYSTEM_PROMPT ?? DEFAULT_SYSTEM_PROMPT,
4961
- tools,
4962
- maxSteps: Number.isFinite(maxSteps) && maxSteps > 0 ? maxSteps : DEFAULT_MAX_STEPS,
4963
- roomFilter: process3.env.APE_CHAT_BRIDGE_ROOM
4964
- };
4965
- }
4966
5830
  async function getIdentity() {
4967
5831
  const idp = await ensureFreshIdpAuth();
4968
5832
  const claims = decodeJwt(idp.access_token);
@@ -4975,23 +5839,18 @@ function log(line) {
4975
5839
  process3.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
4976
5840
  `);
4977
5841
  }
4978
- function sleep(ms) {
5842
+ function sleep2(ms) {
4979
5843
  return new Promise((resolve4) => setTimeout(resolve4, ms));
4980
5844
  }
4981
5845
  function truncate(s2, n2) {
4982
5846
  return s2.length <= n2 ? s2 : `${s2.slice(0, n2 - 1)}\u2026`;
4983
5847
  }
4984
- function refusalText(reason) {
4985
- const base = "I won't process this message \u2014 it looks like a prompt-injection attempt.";
4986
- return reason ? `${base}
4987
-
4988
- (matched: ${reason})` : base;
4989
- }
4990
5848
  var Bridge = class {
4991
- constructor(cfg, selfEmail, ownerEmail) {
5849
+ constructor(cfg, selfEmail, ownerEmail, session) {
4992
5850
  this.cfg = cfg;
4993
5851
  this.selfEmail = selfEmail;
4994
5852
  this.ownerEmail = ownerEmail;
5853
+ this.session = session;
4995
5854
  this.bearer = async () => {
4996
5855
  const idp = await ensureFreshIdpAuth();
4997
5856
  return `Bearer ${idp.access_token}`;
@@ -4999,6 +5858,10 @@ var Bridge = class {
4999
5858
  this.chat = new TroopChatApi(this.cfg.endpoint, this.bearer);
5000
5859
  this.cron = new CronRunner({
5001
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),
5002
5865
  chat: this.chat,
5003
5866
  ownerEmail: this.ownerEmail,
5004
5867
  log,
@@ -5006,10 +5869,13 @@ var Bridge = class {
5006
5869
  bearer: this.bearer
5007
5870
  });
5008
5871
  this.cron.start();
5872
+ void this.refreshLlmGatewayKey();
5873
+ setInterval(() => void this.refreshLlmGatewayKey(), 40 * 60 * 1e3);
5009
5874
  }
5010
5875
  cfg;
5011
5876
  selfEmail;
5012
5877
  ownerEmail;
5878
+ session;
5013
5879
  // Sessions keyed by `${roomId}:${threadId}`. Each ThreadSession holds
5014
5880
  // its own message history and calls @openape/apes' runLoop directly
5015
5881
  // (no stdio JSON-RPC subprocess — see thread-session.ts).
@@ -5021,6 +5887,25 @@ var Bridge = class {
5021
5887
  // backend later. The bridge is the choke-point for every chat message
5022
5888
  // before it reaches the agent runtime, so this is the right place.
5023
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
+ }
5024
5909
  /**
5025
5910
  * RuntimeConfig is shared across thread sessions and the cron runner.
5026
5911
  * The bridge resolves it from its own env at boot and reuses for the
@@ -5028,11 +5913,38 @@ var Bridge = class {
5028
5913
  */
5029
5914
  runtimeConfig() {
5030
5915
  const apiBase = (process3.env.LITELLM_BASE_URL ?? "http://127.0.0.1:4000/v1").replace(/\/$/, "");
5031
- const apiKey = process3.env.LITELLM_API_KEY ?? process3.env.LITELLM_MASTER_KEY ?? "";
5916
+ const apiKey = this.llmKey;
5032
5917
  if (!apiKey) {
5033
5918
  throw new Error("LITELLM_API_KEY (or LITELLM_MASTER_KEY) must be set in the bridge env.");
5034
5919
  }
5035
- 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
+ });
5036
5948
  }
5037
5949
  async sendInitialOwnerRequestIfNeeded() {
5038
5950
  const contacts = await this.chat.listContacts();
@@ -5094,7 +6006,13 @@ var Bridge = class {
5094
6006
  editedAt: typeof payload.editedAt === "number" ? payload.editedAt : null
5095
6007
  };
5096
6008
  }
5097
- 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) {
5098
6016
  if (msg.senderEmail === this.selfEmail) return;
5099
6017
  if (!msg.body.trim()) return;
5100
6018
  if (this.cfg.roomFilter && msg.roomId !== this.cfg.roomFilter) return;
@@ -5113,7 +6031,7 @@ var Bridge = class {
5113
6031
  if (decision.blocked) {
5114
6032
  log(`[${msg.roomId}/${msg.threadId.slice(0, 8)}] BLOCKED prompt-injection (score=${decision.score.toFixed(2)}, reason=${decision.reason ?? "n/a"})`);
5115
6033
  try {
5116
- await this.chat.postMessage(msg.roomId, refusalText(decision.reason), {
6034
+ await backend.postMessage(msg.roomId, this.session.refusalText(decision.reason), {
5117
6035
  replyTo: msg.id,
5118
6036
  threadId: msg.threadId
5119
6037
  });
@@ -5123,18 +6041,21 @@ var Bridge = class {
5123
6041
  }
5124
6042
  return;
5125
6043
  }
5126
- const session = this.getOrCreateThread(msg.roomId, msg.threadId);
6044
+ const session = this.getOrCreateThread(msg.roomId, msg.threadId, backend);
5127
6045
  session.enqueue(msg.body, msg.id);
5128
6046
  }
5129
- getOrCreateThread(roomId, threadId) {
6047
+ getOrCreateThread(roomId, threadId, backend) {
5130
6048
  const key = `${roomId}:${threadId}`;
5131
6049
  let s2 = this.threads.get(key);
5132
6050
  if (s2) return s2;
5133
6051
  s2 = new ThreadSession({
5134
6052
  roomId,
5135
6053
  threadId,
5136
- chat: this.chat,
6054
+ chat: backend,
5137
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(),
5138
6059
  // Resolve tools + systemPrompt on every turn from agent.json
5139
6060
  // (latest sync from troop). Owner edits in the troop UI thus
5140
6061
  // take effect on the very next message in an existing thread —
@@ -5190,7 +6111,7 @@ var Bridge = class {
5190
6111
  }
5191
6112
  if (frame.type !== "message" || !frame.payload) return;
5192
6113
  const msg = this.translateTroopPayload(frame.chat_id ?? "", frame.payload);
5193
- void this.handleInbound(msg);
6114
+ void this.handleInbound(msg, this.chat);
5194
6115
  });
5195
6116
  ws.on("close", () => {
5196
6117
  if (pingTimer) clearInterval(pingTimer);
@@ -5206,7 +6127,14 @@ var Bridge = class {
5206
6127
  }
5207
6128
  };
5208
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
+ }
5209
6135
  const cfg = readConfig();
6136
+ addReadRoot(defaultSkillsDir());
6137
+ ensureMemoryFile();
5210
6138
  const idpId = await getIdentity();
5211
6139
  const onDisk = readAgentIdentity();
5212
6140
  if (onDisk.email.toLowerCase() !== idpId.email.toLowerCase()) {
@@ -5214,10 +6142,12 @@ async function main() {
5214
6142
  `auth.json email (${onDisk.email}) doesn't match IdP token sub (${idpId.email}) \u2014 refusing to start`
5215
6143
  );
5216
6144
  }
6145
+ const session = new AgentSession(onDisk.email, onDisk.ownerEmail, cfg);
5217
6146
  log(
5218
- `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 ?? "*"}`
5219
6148
  );
5220
- const bridge = new Bridge(cfg, onDisk.email, onDisk.ownerEmail);
6149
+ const bridge = new Bridge(cfg, onDisk.email, onDisk.ownerEmail, session);
6150
+ bridge.startExtraChannels();
5221
6151
  let attempt = 0;
5222
6152
  while (true) {
5223
6153
  try {
@@ -5228,7 +6158,7 @@ async function main() {
5228
6158
  attempt++;
5229
6159
  const msg = err instanceof Error ? err.message : String(err);
5230
6160
  log(`disconnected (${msg}) \u2014 reconnecting in ${Math.round(delay / 1e3)}s`);
5231
- await sleep(delay);
6161
+ await sleep2(delay);
5232
6162
  }
5233
6163
  }
5234
6164
  }