agentcollar 0.1.0 → 0.2.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/mcp/tools.js CHANGED
@@ -1,9 +1,18 @@
1
+ const MAX_WAIT_SECONDS = 120;
1
2
  function text(value) {
2
3
  return { content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] };
3
4
  }
4
5
  function failure(message) {
5
6
  return { content: [{ type: "text", text: message }], isError: true };
6
7
  }
8
+ function isPending(result) {
9
+ try {
10
+ return JSON.parse(result.content[0]?.text ?? "{}").status === "pending";
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
7
16
  const BROKER_DOWN = failure("The AgentCollar broker is not running. Ask the human to start it: agcl server");
8
17
  const mandateId = { type: "string", description: "The mandateId returned by request_mandate." };
9
18
  const emailFields = {
@@ -86,9 +95,35 @@ export function createTools(options) {
86
95
  },
87
96
  {
88
97
  name: "mandate_status",
89
- description: "Current state of a mandate: pending / approved / denied, revoked, expiry, actions used.",
90
- inputSchema: { type: "object", properties: { mandateId }, required: ["mandateId"] },
91
- call: (args) => withMandate(args, "GET", "/mandate"),
98
+ description: "Current state of a mandate: pending / approved / denied, revoked, expiry, actions used. " +
99
+ "Right after request_mandate, pass waitSeconds (e.g. 90): the call returns as soon as the human " +
100
+ "approves or denies in Telegram, so you do not need to call it again and again.",
101
+ inputSchema: {
102
+ type: "object",
103
+ properties: {
104
+ mandateId,
105
+ waitSeconds: {
106
+ type: "number",
107
+ minimum: 0,
108
+ maximum: MAX_WAIT_SECONDS,
109
+ description: "Wait up to this many seconds while the mandate is still pending.",
110
+ },
111
+ },
112
+ required: ["mandateId"],
113
+ },
114
+ async call(args) {
115
+ // Long polling inside THIS process: the model makes one call, we ask the broker every
116
+ // 2 s until the human decides (or the time is up). The interval is ours, not the model's.
117
+ const waitSeconds = Math.min(Math.max(Number(args.waitSeconds) || 0, 0), MAX_WAIT_SECONDS);
118
+ const deadline = Date.now() + waitSeconds * 1000;
119
+ const every = options.pollEveryMs ?? 2000;
120
+ let result = await withMandate(args, "GET", "/mandate");
121
+ while (!result.isError && isPending(result) && Date.now() < deadline) {
122
+ await new Promise((resolve) => setTimeout(resolve, Math.min(every, Math.max(deadline - Date.now(), 0))));
123
+ result = await withMandate(args, "GET", "/mandate");
124
+ }
125
+ return result;
126
+ },
92
127
  },
93
128
  {
94
129
  name: "gmail_read_inbox",
package/dist/paths.js CHANGED
@@ -9,6 +9,8 @@ export const homeDir = process.env.AGENTCOLLAR_HOME ?? join(homedir(), ".agentco
9
9
  export const envFile = join(homeDir, ".env"); // Telegram bot token + your user id
10
10
  export const auditLogFile = join(homeDir, "audit.log"); // every check and every human decision
11
11
  export const snapshotFile = join(homeDir, "mandates.json"); // for `agentcollar mandates`
12
+ export const googleClientFile = join(homeDir, "google-client.json"); // your Google Cloud "Desktop app" client
13
+ export const gmailInfoFile = join(homeDir, "gmail.json"); // which Gmail is connected (no secrets)
12
14
  // 700: only your macOS user can open the folder at all. chmod also fixes an existing folder.
13
15
  export function ensureHome() {
14
16
  mkdirSync(homeDir, { recursive: true, mode: 0o700 });
package/dist/revoke.js CHANGED
@@ -1,18 +1,18 @@
1
1
  export async function revokeMandate(id, port) {
2
2
  // Mandate ids are 8 hex characters; anything else never reaches the network.
3
3
  if (!/^[0-9a-f]{8}$/.test(id)) {
4
- return { ok: false, message: "Как использовать: agcl revoke <id> (id мандата: 8 символов, есть в сообщении Telegram)" };
4
+ return { ok: false, message: "Usage: agcl revoke <id> (the mandate id: 8 characters, shown in the Telegram message)" };
5
5
  }
6
6
  let response;
7
7
  try {
8
8
  response = await fetch(`http://127.0.0.1:${port}/mandates/${id}/revoke`, { method: "POST" });
9
9
  }
10
10
  catch {
11
- return { ok: false, message: "Брокер не отвечает. Он запущен (agcl server)?" };
11
+ return { ok: false, message: "The broker is not answering. Is it running (agcl server)?" };
12
12
  }
13
13
  if (!response.ok) {
14
14
  const body = (await response.json());
15
- return { ok: false, message: `Не получилось: ${body.error}` };
15
+ return { ok: false, message: `Could not revoke: ${body.error}` };
16
16
  }
17
- return { ok: true, message: `🛑 Мандат ${id} отозван` };
17
+ return { ok: true, message: `🛑 Mandate ${id} revoked` };
18
18
  }
package/dist/server.js CHANGED
@@ -5,7 +5,8 @@ import "./env.js"; // first: loads broker/.env into process.env
5
5
  import { createServer } from "node:http";
6
6
  import { askInTerminal, decide, denyStalePending, oneLine } from "./approval.js";
7
7
  import { check } from "./check.js";
8
- import { createDraft, listInbox, sendEmail } from "./gmail-fake.js";
8
+ import { loadMailbox, readGmailInfo } from "./google/connection.js";
9
+ import { isEmailAddress } from "./mailbox.js";
9
10
  import { mandates, onMandatesChanged, requestMandate } from "./mandate.js";
10
11
  import { writeMandatesSnapshot } from "./snapshot.js";
11
12
  import { notifyRevoked, notifyTimedOut, sendApprovalRequest, startTelegramPolling } from "./telegram.js";
@@ -27,6 +28,23 @@ const telegram = telegramConfig();
27
28
  // data/mandates.json for `agentcollar mandates`: fresh on start (memory is empty), then after every change.
28
29
  writeMandatesSnapshot();
29
30
  onMandatesChanged(() => writeMandatesSnapshot());
31
+ // Your real Gmail after `agcl gmail connect`, otherwise the fake inbox.
32
+ const mailbox = loadMailbox();
33
+ // Reading/drafting through Gmail can fail (network, Google sign-in expired): tell the agent why, as 502.
34
+ async function viaMailbox(action) {
35
+ try {
36
+ return await action();
37
+ }
38
+ catch (error) {
39
+ throw new HttpError(502, error.message);
40
+ }
41
+ }
42
+ function requireEmail(body, field) {
43
+ const value = requireString(body, field);
44
+ if (!isEmailAddress(value))
45
+ throw new HttpError(400, `"${field}" must be one email address`);
46
+ return value;
47
+ }
30
48
  // A pending mandate nobody answered becomes "denied" after 10 minutes (fail closed).
31
49
  const PENDING_TIMEOUT_MS = 10 * 60 * 1000;
32
50
  // --- small HTTP helpers ---
@@ -185,18 +203,20 @@ async function route(req, res) {
185
203
  }
186
204
  if (method === "GET" && path === "/inbox") {
187
205
  guard(req, "gmail.read");
188
- return sendJson(res, 200, { messages: listInbox() });
206
+ return sendJson(res, 200, { messages: await viaMailbox(() => mailbox.listInbox()) });
189
207
  }
190
208
  if (method === "POST" && path === "/drafts") {
191
209
  guard(req, "gmail.draft");
192
210
  const body = await readJson(req);
193
- const draft = createDraft(requireString(body, "to"), requireString(body, "subject"), requireString(body, "body"));
211
+ const [to, subject, text] = [requireEmail(body, "to"), requireString(body, "subject"), requireString(body, "body")];
212
+ const draft = await viaMailbox(() => mailbox.createDraft(to, subject, text));
194
213
  return sendJson(res, 201, { draft }); // 201 = "created"
195
214
  }
196
215
  if (method === "POST" && path === "/send") {
197
216
  guard(req, "gmail.send");
198
217
  const body = await readJson(req);
199
- const email = sendEmail(requireString(body, "to"), requireString(body, "subject"), requireString(body, "body"));
218
+ const [to, subject, text] = [requireEmail(body, "to"), requireString(body, "subject"), requireString(body, "body")];
219
+ const email = await viaMailbox(() => mailbox.sendEmail(to, subject, text));
200
220
  return sendJson(res, 200, { sent: email });
201
221
  }
202
222
  throw new HttpError(404, `no endpoint ${method} ${path}`);
@@ -217,8 +237,19 @@ const server = createServer(async (req, res) => {
217
237
  }
218
238
  }
219
239
  });
240
+ // Port taken: most likely a broker is already running. Say so instead of a stack trace.
241
+ server.on("error", (error) => {
242
+ if (error.code === "EADDRINUSE") {
243
+ console.error(`Port ${PORT} is already in use: is another AgentCollar broker running? (check: agcl mandates)`);
244
+ console.error(`Stop it, or start this one on another port: BROKER_PORT=8788 agcl server`);
245
+ process.exit(1);
246
+ }
247
+ throw error;
248
+ });
220
249
  server.listen(PORT, HOST, () => {
221
250
  console.log(`Broker listening on http://${HOST}:${PORT}`);
251
+ const gmail = readGmailInfo();
252
+ console.log(mailbox.kind === "gmail" ? `Mailbox: Gmail (${gmail?.email ?? "connected"})` : "Mailbox: fake inbox (connect your Gmail: agcl gmail connect)");
222
253
  console.log(telegram ? "Approvals: Telegram" : "Approvals: this terminal (no TELEGRAM_BOT_TOKEN in .env)");
223
254
  });
224
255
  // Every 30 seconds: deny requests nobody answered, and fix their Telegram messages.
package/dist/setup.js CHANGED
@@ -70,15 +70,15 @@ async function call(token, method, params) {
70
70
  if (data.ok)
71
71
  return data.result;
72
72
  if (data.error_code === 401)
73
- throw new Error("Telegram не узнал этот токен. Скопируй его заново у @BotFather (/mybots → бот → API Token).");
73
+ throw new Error("Telegram does not know this token. Copy it again from @BotFather (/mybots → your bot → API Token).");
74
74
  if (data.error_code === 409)
75
- throw new Error("Этого бота уже слушает другая программа. Останови agcl server и запусти agcl setup снова.");
75
+ throw new Error("Another program is already listening to this bot. Stop agcl server, then run agcl setup again.");
76
76
  throw new Error(`Telegram ${method}: ${data.description}`);
77
77
  }
78
78
  // Checks the token for real (getMe) and returns the bot's @username.
79
79
  export async function getBotUsername(token) {
80
80
  if (!isTokenFormat(token))
81
- throw new Error("Это не похоже на токен бота (должно быть вида 123456789:AAH...).");
81
+ throw new Error("This does not look like a bot token (it looks like 123456789:AAH...).");
82
82
  const me = (await call(token, "getMe", {}));
83
83
  return me.username;
84
84
  }
package/dist/telegram.js CHANGED
@@ -19,23 +19,23 @@ async function callApi(config, method, params) {
19
19
  function buttons(mandate) {
20
20
  if (mandate.status === "pending") {
21
21
  return [[
22
- { text: "✅ Одобрить", callback_data: `approve:${mandate.id}` },
23
- { text: "❌ Отклонить", callback_data: `deny:${mandate.id}` },
22
+ { text: "✅ Approve", callback_data: `approve:${mandate.id}` },
23
+ { text: "❌ Deny", callback_data: `deny:${mandate.id}` },
24
24
  ]];
25
25
  }
26
26
  if (mandate.status === "approved" && !mandate.revoked) {
27
- return [[{ text: "🛑 Отозвать", callback_data: `revoke:${mandate.id}` }]];
27
+ return [[{ text: "🛑 Revoke", callback_data: `revoke:${mandate.id}` }]];
28
28
  }
29
29
  return []; // denied or revoked: nothing left to press
30
30
  }
31
31
  function statusLine(mandate) {
32
32
  if (mandate.revoked)
33
- return "🛑 Отозван";
33
+ return "🛑 Revoked";
34
34
  if (mandate.status === "approved")
35
- return "✅ Одобрен";
35
+ return "✅ Approved";
36
36
  if (mandate.status === "denied")
37
- return "❌ Отклонён";
38
- return "⏳ Ждёт решения";
37
+ return "❌ Denied";
38
+ return "⏳ Waiting for your decision";
39
39
  }
40
40
  // Which Telegram message shows which mandate, so it can be edited later (e.g. on timeout).
41
41
  const sentMessages = new Map(); // mandate id -> message
@@ -44,7 +44,7 @@ const sentMessages = new Map(); // mandate id -> message
44
44
  export async function sendApprovalRequest(config, mandate) {
45
45
  const message = (await callApi(config, "sendMessage", {
46
46
  chat_id: config.approverId, // in a private chat, chat id = user id
47
- text: `🔐 Запрос мандата\n\n${describe(mandate)}\n\n${statusLine(mandate)}`,
47
+ text: `🔐 Mandate request\n\n${describe(mandate)}\n\n${statusLine(mandate)}`,
48
48
  reply_markup: { inline_keyboard: buttons(mandate) },
49
49
  }));
50
50
  sentMessages.set(mandate.id, { chatId: config.approverId, messageId: message.message_id });
@@ -58,17 +58,17 @@ async function closeMessage(config, mandate, finalLine) {
58
58
  await callApi(config, "editMessageText", {
59
59
  chat_id: sent.chatId,
60
60
  message_id: sent.messageId,
61
- text: `🔐 Запрос мандата\n\n${describe(mandate)}\n\n${finalLine}`,
61
+ text: `🔐 Mandate request\n\n${describe(mandate)}\n\n${finalLine}`,
62
62
  reply_markup: { inline_keyboard: [] },
63
63
  });
64
64
  }
65
65
  // Nobody answered in time.
66
66
  export async function notifyTimedOut(config, mandate) {
67
- await closeMessage(config, mandate, "⌛ Время вышло");
67
+ await closeMessage(config, mandate, "⌛ Timed out: no answer in 10 minutes");
68
68
  }
69
69
  // Revoked from the terminal (npm run revoke) or over HTTP.
70
70
  export async function notifyRevoked(config, mandate) {
71
- await closeMessage(config, mandate, "🛑 Отозван");
71
+ await closeMessage(config, mandate, "🛑 Revoked");
72
72
  }
73
73
  // A button was pressed.
74
74
  async function handleButton(config, query) {
@@ -76,7 +76,7 @@ async function handleButton(config, query) {
76
76
  // Only the approver's presses count.
77
77
  if (query.from.id !== config.approverId) {
78
78
  console.log(`Telegram: ignored a button press from user ${query.from.id} (not the approver)`);
79
- await callApi(config, "answerCallbackQuery", { callback_query_id: query.id, text: "Нет доступа" });
79
+ await callApi(config, "answerCallbackQuery", { callback_query_id: query.id, text: "No access" });
80
80
  return;
81
81
  }
82
82
  const [action, id] = (query.data ?? "").split(":");
@@ -85,14 +85,14 @@ async function handleButton(config, query) {
85
85
  // Telegram shows a spinner on the button until we answer.
86
86
  await callApi(config, "answerCallbackQuery", {
87
87
  callback_query_id: query.id,
88
- text: mandate ? statusLine(mandate) : "Уже решено или мандат не найден",
88
+ text: mandate ? statusLine(mandate) : "Already decided, or the mandate is gone",
89
89
  });
90
- // Update the message: new status line, new buttons (e.g. "Отозвать" after approval).
90
+ // Update the message: new status line, new buttons (e.g. "Revoke" after approval).
91
91
  if (mandate && query.message) {
92
92
  await callApi(config, "editMessageText", {
93
93
  chat_id: query.message.chat.id,
94
94
  message_id: query.message.message_id,
95
- text: `🔐 Запрос мандата\n\n${describe(mandate)}\n\n${statusLine(mandate)}`,
95
+ text: `🔐 Mandate request\n\n${describe(mandate)}\n\n${statusLine(mandate)}`,
96
96
  reply_markup: { inline_keyboard: buttons(mandate) },
97
97
  });
98
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentcollar",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A small local broker between your AI agents and your accounts: short-lived, task-scoped mandates that you approve in Telegram, instead of passwords.",
5
5
  "keywords": [
6
6
  "ai-agents",