@inetafrica/open-claudia 2.1.0 → 2.1.1

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/bin/auth.js ADDED
@@ -0,0 +1,62 @@
1
+ // open-claudia auth <subcommand>
2
+ // Owner-only view of the raw auth.json store and direct deauth. Use
3
+ // `open-claudia people` for richer roster operations. This is the
4
+ // low-level escape hatch for legacy entries that don't have a person
5
+ // record yet.
6
+
7
+ const { postJson } = require("./loopback-client");
8
+
9
+ const HELP = `
10
+ Raw authorization store.
11
+
12
+ open-claudia auth list
13
+ open-claudia auth revoke <chatId>
14
+
15
+ Owner-only. Revoking also unlinks the chat from any matching person.
16
+ Owner entries are protected — refuse to self-deauth.
17
+ `;
18
+
19
+ async function runList() {
20
+ try {
21
+ const res = await postJson("auth-list", {});
22
+ const authorized = res.authorized || [];
23
+ const pending = res.pending || [];
24
+ if (authorized.length === 0 && pending.length === 0) { console.log("No authorized or pending chats."); process.exit(0); }
25
+ if (authorized.length) {
26
+ console.log("Authorized:");
27
+ for (const a of authorized) {
28
+ const owner = a.isOwner ? " (owner)" : "";
29
+ const person = a.personName ? ` → ${a.personName}` : " → (no person record)";
30
+ console.log(` ${a.chatId}${owner} ${a.name || a.username || ""}${person}`);
31
+ }
32
+ }
33
+ if (pending.length) {
34
+ console.log("\nPending:");
35
+ for (const p of pending) console.log(` ${p.chatId} ${p.name || p.username || ""} (requested ${p.requestedAt})`);
36
+ }
37
+ process.exit(0);
38
+ } catch (e) { console.error(e.message); process.exit(1); }
39
+ }
40
+
41
+ async function runRevoke(args) {
42
+ const chatId = args[0];
43
+ if (!chatId) { console.error("Usage: auth revoke <chatId>"); process.exit(2); }
44
+ try {
45
+ const res = await postJson("auth-revoke", { chatId });
46
+ console.log(`Revoked ${chatId}${res.personId ? ` (was linked to person ${res.personId})` : ""}.`);
47
+ process.exit(0);
48
+ } catch (e) { console.error(e.message); process.exit(1); }
49
+ }
50
+
51
+ async function run(args) {
52
+ const sub = args[0];
53
+ const rest = args.slice(1);
54
+ if (!sub || sub === "--help" || sub === "help") { console.log(HELP); process.exit(sub ? 0 : 2); }
55
+ switch (sub) {
56
+ case "list": case "ls": return runList();
57
+ case "revoke": case "rm": return runRevoke(rest);
58
+ default: console.error(`Unknown auth subcommand: ${sub}`); console.log(HELP); process.exit(2);
59
+ }
60
+ }
61
+
62
+ module.exports = { run, HELP };
package/bin/cli.js CHANGED
@@ -188,11 +188,17 @@ switch (command) {
188
188
  break;
189
189
  }
190
190
 
191
- case "auth":
192
- // Pass through to setup.js auth mode
191
+ case "auth": {
192
+ const sub = args[1];
193
+ if (["list", "ls", "revoke", "rm"].includes(sub)) {
194
+ require("./auth").run(args.slice(1));
195
+ break;
196
+ }
197
+ // Legacy: open-claudia auth (no subcommand) → setup.js auth mode
193
198
  process.argv = [process.argv[0], process.argv[1], "--auth"];
194
199
  require(path.join(botDir, "setup.js"));
195
200
  break;
201
+ }
196
202
 
197
203
  case "stop": {
198
204
  const pids = findBotProcesses();
@@ -318,6 +324,7 @@ Background work (only inside an active bot-spawned task):
318
324
  Multi-user / cross-channel:
319
325
  open-claudia people list|show|add|note|link Roster of known team members
320
326
  open-claudia intros list|approve|reject Pending introductions from unknown chats
327
+ open-claudia auth list|revoke <chatId> Raw authorization view + direct deauth
321
328
  open-claudia send-to --person "<name>" "<msg>" Relay a message to another team member
322
329
  open-claudia recent --person "<name>" [--limit] Read recent activity from another chat
323
330
 
package/core/access.js CHANGED
@@ -134,6 +134,36 @@ function recordPendingAuthRequest({ chatId, name, username }) {
134
134
  return { status: "queued" };
135
135
  }
136
136
 
137
+ // Remove a chatId from the legacy auth.json. Owners are never removed
138
+ // this way to avoid self-locking; the caller has to handle owner
139
+ // demotion explicitly. Returns { ok, reason }.
140
+ function revokeChat(chatId) {
141
+ const id = String(chatId || "");
142
+ if (!id) return { ok: false, reason: "missing_id" };
143
+ const auth = loadAuth();
144
+ const before = auth.authorized.length;
145
+ auth.authorized = auth.authorized.filter((a) => {
146
+ if (String(a.chatId) !== id) return true;
147
+ if (a.isOwner === true) return true;
148
+ return false;
149
+ });
150
+ auth.pending = auth.pending.filter((p) => String(p.chatId) !== id);
151
+ if (auth.authorized.length === before) {
152
+ if (auth.pending.length === 0) return { ok: false, reason: "not_found" };
153
+ }
154
+ saveAuth(auth);
155
+ updateAuthorizedChatEnv(auth);
156
+ return { ok: true };
157
+ }
158
+
159
+ function listAuthorizedRaw() {
160
+ const auth = loadAuth();
161
+ return {
162
+ authorized: auth.authorized.slice(),
163
+ pending: auth.pending.slice(),
164
+ };
165
+ }
166
+
137
167
  function hasOwner() {
138
168
  if (CHAT_ID) return true;
139
169
  try {
@@ -173,4 +203,6 @@ module.exports = {
173
203
  updateAuthorizedChatEnv,
174
204
  hasOwner,
175
205
  bootstrapOwner,
206
+ revokeChat,
207
+ listAuthorizedRaw,
176
208
  };
package/core/audit.js CHANGED
@@ -1,13 +1,30 @@
1
1
  // Append-only audit log for security-sensitive events: people CRUD,
2
2
  // intro decisions, cross-channel sends. One JSON record per line.
3
+ // Auto-rotates when the file exceeds MAX_BYTES — the current file is
4
+ // renamed to <name>.1 (overwriting any previous archive) and a fresh
5
+ // file starts.
3
6
 
4
7
  const fs = require("fs");
5
8
  const { AUDIT_FILE } = require("./config");
6
9
 
10
+ const MAX_BYTES = 2 * 1024 * 1024; // 2 MB; rotate at this size
11
+
12
+ function rotateIfNeeded() {
13
+ try {
14
+ const stat = fs.statSync(AUDIT_FILE);
15
+ if (stat.size > MAX_BYTES) {
16
+ try { fs.renameSync(AUDIT_FILE, `${AUDIT_FILE}.1`); } catch (e) {}
17
+ }
18
+ } catch (e) {} // file may not exist yet
19
+ }
20
+
7
21
  function log(kind, payload) {
8
22
  const entry = { at: new Date().toISOString(), kind, ...payload };
9
23
  const line = JSON.stringify(entry) + "\n";
10
- try { fs.appendFileSync(AUDIT_FILE, line); } catch (e) {}
24
+ try {
25
+ rotateIfNeeded();
26
+ fs.appendFileSync(AUDIT_FILE, line);
27
+ } catch (e) {}
11
28
  return entry;
12
29
  }
13
30
 
@@ -20,4 +37,4 @@ function tail(n = 50) {
20
37
  } catch (e) { return []; }
21
38
  }
22
39
 
23
- module.exports = { log, tail };
40
+ module.exports = { log, tail, MAX_BYTES };
package/core/handlers.js CHANGED
@@ -121,6 +121,7 @@ register({
121
121
  "Session: /session /sessions /projects /continue /status /stop /end",
122
122
  "Settings: /model /effort /budget /plan /compact /worktree /mode",
123
123
  "Identity: /whoami /link",
124
+ "Team: /people /intros /auth (owner)",
124
125
  "Automation: /cron /vault /soul",
125
126
  "Claude auth: /auth_status /login /setup_token /use_oauth_token /clear_oauth_token",
126
127
  "Codex auth: /codex_auth_status /codex_login /codex_setup_token",
@@ -133,9 +134,41 @@ register({
133
134
  });
134
135
 
135
136
  register({
136
- name: "auth", description: "Request access to this bot",
137
+ name: "auth", description: "Request access (or list/revoke as owner)", args: "[list | revoke <chatId>]",
137
138
  authRequired: false,
138
- handler: async (env) => {
139
+ handler: async (env, { tail }) => {
140
+ if (tail) {
141
+ if (!authorized(env)) return;
142
+ if (!ownerEnv(env)) return send("Owner only — auth list/revoke are restricted.");
143
+ if (tail === "list") {
144
+ const raw = accessStore.listAuthorizedRaw();
145
+ const lines = ["Authorized chats:"];
146
+ for (const a of raw.authorized) {
147
+ const person = peopleStore.findByHandle("telegram", a.chatId) || peopleStore.findByHandle("kazee", a.chatId);
148
+ lines.push(` ${a.chatId}${a.isOwner ? " (owner)" : ""} ${a.name || a.username || ""}${person ? " → " + person.name : " → (no person)"}`);
149
+ }
150
+ if (raw.authorized.length === 0) lines.push(" (none)");
151
+ if (raw.pending.length > 0) {
152
+ lines.push("", "Pending:");
153
+ for (const p of raw.pending) lines.push(` ${p.chatId} ${p.name || p.username || ""}`);
154
+ }
155
+ return send(lines.join("\n"));
156
+ }
157
+ const revokeMatch = tail.match(/^revoke\s+(\S+)$/i);
158
+ if (revokeMatch) {
159
+ const chatId = revokeMatch[1];
160
+ const person = peopleStore.findByHandle("telegram", chatId) || peopleStore.findByHandle("kazee", chatId);
161
+ if (person && person.isOwner) return send("Refusing to revoke owner chat.");
162
+ if (person) {
163
+ const detected = (person.handles || []).find((h) => String(h.channelId) === String(chatId));
164
+ if (detected) peopleStore.unlinkHandle(person.id, { adapter: detected.adapter, channelId: detected.channelId });
165
+ }
166
+ const result = accessStore.revokeChat(chatId);
167
+ if (!result.ok) return send(`Revoke failed: ${result.reason}`);
168
+ return send(`Revoked ${chatId}${person ? ` (was ${person.name})` : ""}.`);
169
+ }
170
+ return send("Usage: /auth | /auth list | /auth revoke <chatId>");
171
+ }
139
172
  if (authorized(env)) {
140
173
  send("You're already authorized.");
141
174
  return;
@@ -1001,4 +1034,149 @@ register({
1001
1034
  },
1002
1035
  });
1003
1036
 
1004
- module.exports.startSession = startSession;
1037
+ // ── Team (people / intros / auth) ─────────────────────────────────
1038
+
1039
+ const peopleStore = require("./people");
1040
+ const introsStore = require("./intros");
1041
+ const introFlow = require("./intro-flow");
1042
+ const accessStore = require("./access");
1043
+
1044
+ function formatPersonShort(p) {
1045
+ const handles = (p.handles || []).map((h) => `${h.adapter}:${h.channelId}`).join(", ") || "(no handles)";
1046
+ return `${p.name}${p.isOwner ? " (owner)" : ""} — ${handles}`;
1047
+ }
1048
+
1049
+ register({
1050
+ name: "people", description: "Manage team members", args: "[show|note|link|unlink|set-primary|add|remove ...]",
1051
+ handler: async (env, { tail }) => {
1052
+ if (!authorized(env)) return;
1053
+ if (!tail || tail === "list") {
1054
+ const all = peopleStore.list();
1055
+ if (all.length === 0) return send("No people yet.");
1056
+ const lines = ["Team:"];
1057
+ for (const p of all) lines.push(` ${formatPersonShort(p)}`);
1058
+ lines.push("", "More: /people show <name>, /people note <name> <text>");
1059
+ return send(lines.join("\n"));
1060
+ }
1061
+ const showMatch = tail.match(/^show\s+(.+)$/i);
1062
+ if (showMatch) {
1063
+ const ref = showMatch[1].trim();
1064
+ const person = peopleStore.findById(ref) || peopleStore.findByName(ref);
1065
+ if (!person) return send(`Not found: ${ref}`);
1066
+ const handles = (person.handles || []).map((h) => ` ${h.adapter}:${h.channelId}${person.primaryChannel && person.primaryChannel.adapter === h.adapter && String(person.primaryChannel.channelId) === String(h.channelId) ? " (primary)" : ""}`).join("\n");
1067
+ const notes = (person.notes || []).slice(-5).map((n) => ` - [${(n.at || "").slice(0, 10)}] ${n.text}`).join("\n");
1068
+ return send([
1069
+ `${person.name}${person.isOwner ? " (owner)" : ""} id=${person.id}`,
1070
+ person.bio ? `Bio: ${person.bio}` : null,
1071
+ "Handles:",
1072
+ handles || " (none)",
1073
+ notes ? "Notes:" : null,
1074
+ notes || null,
1075
+ ].filter((x) => x !== null && x !== "").join("\n"));
1076
+ }
1077
+ const noteMatch = tail.match(/^note\s+(\S+)\s+(.+)$/i);
1078
+ if (noteMatch) {
1079
+ const person = peopleStore.findById(noteMatch[1]) || peopleStore.findByName(noteMatch[1]);
1080
+ if (!person) return send(`Not found: ${noteMatch[1]}`);
1081
+ const entry = peopleStore.note(person.id, noteMatch[2], { by: env.canonicalUserId || null });
1082
+ return send(`Noted on ${person.name}: ${entry.text}`);
1083
+ }
1084
+ if (!ownerEnv(env)) return send("Owner only — add/link/unlink/set-primary/remove.");
1085
+ const addMatch = tail.match(/^add\s+(.+)$/i);
1086
+ if (addMatch) {
1087
+ try {
1088
+ const p = peopleStore.add({ name: addMatch[1].trim() });
1089
+ return send(`Added ${p.name} (${p.id}). Link a channel with /people link ${p.name} <adapter> <channelId>.`);
1090
+ } catch (e) { return send(`Failed: ${e.message}`); }
1091
+ }
1092
+ const linkMatch = tail.match(/^link\s+(\S+)\s+(\S+)\s+(\S+)$/i);
1093
+ if (linkMatch) {
1094
+ const person = peopleStore.findById(linkMatch[1]) || peopleStore.findByName(linkMatch[1]);
1095
+ if (!person) return send(`Not found: ${linkMatch[1]}`);
1096
+ try {
1097
+ peopleStore.linkHandle(person.id, { adapter: linkMatch[2], channelId: linkMatch[3], approvedBy: env.canonicalUserId });
1098
+ return send(`Linked ${linkMatch[2]}:${linkMatch[3]} → ${person.name}`);
1099
+ } catch (e) { return send(`Failed: ${e.message}`); }
1100
+ }
1101
+ const unlinkMatch = tail.match(/^unlink\s+(\S+)\s+(\S+)\s+(\S+)$/i);
1102
+ if (unlinkMatch) {
1103
+ const person = peopleStore.findById(unlinkMatch[1]) || peopleStore.findByName(unlinkMatch[1]);
1104
+ if (!person) return send(`Not found: ${unlinkMatch[1]}`);
1105
+ const updated = peopleStore.unlinkHandle(person.id, { adapter: unlinkMatch[2], channelId: unlinkMatch[3] });
1106
+ if (!updated) return send("Handle not found on that person.");
1107
+ if (!person.isOwner) accessStore.revokeChat(unlinkMatch[3]);
1108
+ return send(`Unlinked ${unlinkMatch[2]}:${unlinkMatch[3]} from ${person.name}.`);
1109
+ }
1110
+ const setPrimaryMatch = tail.match(/^set-primary\s+(\S+)\s+(\S+)\s+(\S+)$/i);
1111
+ if (setPrimaryMatch) {
1112
+ const person = peopleStore.findById(setPrimaryMatch[1]) || peopleStore.findByName(setPrimaryMatch[1]);
1113
+ if (!person) return send(`Not found: ${setPrimaryMatch[1]}`);
1114
+ try {
1115
+ peopleStore.setPrimary(person.id, { adapter: setPrimaryMatch[2], channelId: setPrimaryMatch[3] });
1116
+ return send(`Primary for ${person.name} → ${setPrimaryMatch[2]}:${setPrimaryMatch[3]}`);
1117
+ } catch (e) { return send(`Failed: ${e.message}`); }
1118
+ }
1119
+ const removeMatch = tail.match(/^remove\s+(.+)$/i);
1120
+ if (removeMatch) {
1121
+ const person = peopleStore.findById(removeMatch[1]) || peopleStore.findByName(removeMatch[1]);
1122
+ if (!person) return send(`Not found: ${removeMatch[1]}`);
1123
+ if (person.isOwner) return send("Refusing to remove the owner.");
1124
+ const handles = (person.handles || []).slice();
1125
+ peopleStore.remove(person.id);
1126
+ let revokedCount = 0;
1127
+ for (const h of handles) { const r = accessStore.revokeChat(h.channelId); if (r.ok) revokedCount += 1; }
1128
+ return send(`Removed ${person.name}. Deauthed ${revokedCount} chat${revokedCount === 1 ? "" : "s"}.`);
1129
+ }
1130
+ send("Usage: /people | /people show <name> | /people note <name> <text> | /people add|link|unlink|set-primary|remove ... (owner)");
1131
+ },
1132
+ });
1133
+
1134
+ register({
1135
+ name: "intros", description: "Pending intros from unknown chats", args: "[approve|reject <id>]", ownerOnly: true,
1136
+ handler: async (env, { tail }) => {
1137
+ if (!authorized(env)) return;
1138
+ if (!ownerEnv(env)) return send("Owner only.");
1139
+ introsStore.sweep();
1140
+ if (!tail || tail === "list") {
1141
+ const pending = introsStore.listPending();
1142
+ if (pending.length === 0) return send("No pending intros.");
1143
+ const lines = ["Pending intros:"];
1144
+ const buttons = [];
1145
+ for (const i of pending) {
1146
+ const claim = i.claim || {};
1147
+ let claimLine = "no claim yet";
1148
+ if (claim.kind === "existing") {
1149
+ const p = peopleStore.findById(claim.personId);
1150
+ claimLine = `claims to be ${p ? p.name : claim.personId}`;
1151
+ } else if (claim.kind === "new") {
1152
+ claimLine = `new: ${claim.name}${claim.bio ? " — " + claim.bio : ""}`;
1153
+ }
1154
+ lines.push(` ${i.id} ${i.adapter}:${i.channelId} ${claimLine}`);
1155
+ buttons.push([
1156
+ { text: `Approve ${i.id.slice(-6)}`, callback_data: `intro:approve:${i.id}` },
1157
+ { text: `Reject ${i.id.slice(-6)}`, callback_data: `intro:reject:${i.id}` },
1158
+ ]);
1159
+ }
1160
+ return send(lines.join("\n"), buttons.length ? { keyboard: { inline_keyboard: buttons } } : undefined);
1161
+ }
1162
+ const approveMatch = tail.match(/^approve\s+(\S+)$/i);
1163
+ if (approveMatch) {
1164
+ const intro = introsStore.byId(approveMatch[1]);
1165
+ if (!intro) return send(`No intro: ${approveMatch[1]}`);
1166
+ try {
1167
+ const result = await introFlow.applyApproval(intro, env.from?.name || env.canonicalUserId || "owner");
1168
+ await introFlow.notifyIntroUser(intro, `You're in. Welcome${result.person ? ", " + result.person.name : ""}.`);
1169
+ return send(`Approved. Person: ${result.person?.name || "?"}`);
1170
+ } catch (e) { return send(`Failed: ${e.message}`); }
1171
+ }
1172
+ const rejectMatch = tail.match(/^reject\s+(\S+)(?:\s+(.+))?$/i);
1173
+ if (rejectMatch) {
1174
+ const intro = introsStore.byId(rejectMatch[1]);
1175
+ if (!intro) return send(`No intro: ${rejectMatch[1]}`);
1176
+ introsStore.reject(intro.id, env.from?.name || env.canonicalUserId || "owner", rejectMatch[2] || null);
1177
+ await introFlow.notifyIntroUser(intro, "Your access request was denied.");
1178
+ return send(`Rejected ${intro.id}.`);
1179
+ }
1180
+ send("Usage: /intros | /intros approve <id> | /intros reject <id> [reason]");
1181
+ },
1182
+ });
package/core/loopback.js CHANGED
@@ -23,6 +23,7 @@ const intros = require("./intros");
23
23
  const introFlow = require("./intro-flow");
24
24
  const relay = require("./relay");
25
25
  const audit = require("./audit");
26
+ const access = require("./access");
26
27
  const fs2 = require("fs");
27
28
  const path2 = require("path");
28
29
  const { TRANSCRIPTS_DIR } = require("./config");
@@ -77,6 +78,7 @@ const JSON_KINDS = new Set([
77
78
  "people-remove", "people-set-primary",
78
79
  "intros-list", "intros-approve", "intros-reject",
79
80
  "relay-send", "recent-fetch", "audit-tail",
81
+ "auth-list", "auth-revoke",
80
82
  ]);
81
83
 
82
84
  function callerIsOwner(payload) {
@@ -305,8 +307,12 @@ async function handleJson(req, res, url, kind) {
305
307
  if (!person) return reply(res, 404, { error: "not found" });
306
308
  const updated = peopleStore.unlinkHandle(person.id, { adapter: payload.linkAdapter, channelId: payload.linkChannelId });
307
309
  if (!updated) return reply(res, 404, { error: "handle not found on person" });
308
- audit.log("people.unlink", { by: payload.canonicalUserId, personId: person.id, adapter: payload.linkAdapter, channelId: payload.linkChannelId });
309
- return reply(res, 200, { ok: true, person: updated });
310
+ let revoked = null;
311
+ if (!person.isOwner) {
312
+ revoked = access.revokeChat(payload.linkChannelId);
313
+ }
314
+ audit.log("people.unlink", { by: payload.canonicalUserId, personId: person.id, adapter: payload.linkAdapter, channelId: payload.linkChannelId, authRevoked: revoked });
315
+ return reply(res, 200, { ok: true, person: updated, authRevoked: !!(revoked && revoked.ok) });
310
316
  }
311
317
 
312
318
  if (kind === "people-set-primary") {
@@ -329,9 +335,42 @@ async function handleJson(req, res, url, kind) {
329
335
  const person = peopleStore.findById(ref) || peopleStore.findByName(ref);
330
336
  if (!person) return reply(res, 404, { error: "not found" });
331
337
  if (person.isOwner) return reply(res, 400, { error: "refusing to delete owner record" });
338
+ const handles = (person.handles || []).slice();
332
339
  peopleStore.remove(person.id);
333
- audit.log("people.remove", { by: payload.canonicalUserId, personId: person.id, name: person.name });
334
- return reply(res, 200, { ok: true, removed: person });
340
+ let revokedCount = 0;
341
+ for (const h of handles) {
342
+ const r = access.revokeChat(h.channelId);
343
+ if (r && r.ok) revokedCount += 1;
344
+ }
345
+ audit.log("people.remove", { by: payload.canonicalUserId, personId: person.id, name: person.name, revokedCount, handles });
346
+ return reply(res, 200, { ok: true, removed: person, revokedCount });
347
+ }
348
+
349
+ if (kind === "auth-list") {
350
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
351
+ const raw = access.listAuthorizedRaw();
352
+ const enriched = raw.authorized.map((a) => {
353
+ const person = peopleStore.findByHandle("telegram", a.chatId) || peopleStore.findByHandle("kazee", a.chatId);
354
+ return { ...a, personId: person?.id || null, personName: person?.name || null };
355
+ });
356
+ return reply(res, 200, { ok: true, authorized: enriched, pending: raw.pending });
357
+ }
358
+
359
+ if (kind === "auth-revoke") {
360
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
361
+ const chatId = payload.chatId;
362
+ if (!chatId) return reply(res, 400, { error: "missing chatId" });
363
+ const person = peopleStore.findByHandle("telegram", chatId) || peopleStore.findByHandle("kazee", chatId);
364
+ if (person && person.isOwner) return reply(res, 400, { error: "refusing to revoke owner chat" });
365
+ if (person) {
366
+ const detected = (person.handles || []).find((h) => String(h.channelId) === String(chatId));
367
+ if (detected) {
368
+ peopleStore.unlinkHandle(person.id, { adapter: detected.adapter, channelId: detected.channelId });
369
+ }
370
+ }
371
+ const result = access.revokeChat(chatId);
372
+ audit.log("auth.revoke", { by: payload.canonicalUserId, chatId, personId: person?.id || null, result });
373
+ return reply(res, result.ok ? 200 : 404, { ...result, personId: person?.id || null });
335
374
  }
336
375
 
337
376
  if (kind === "intros-list") {
@@ -11,6 +11,7 @@ const { vault } = require("./vault-store");
11
11
  const { transcriptPointerNote } = require("./transcripts");
12
12
  const tasksStore = require("./tasks");
13
13
  const people = require("./people");
14
+ const commandsRegistry = require("./commands");
14
15
 
15
16
  function loadSoul() {
16
17
  try { return fs.readFileSync(SOUL_FILE, "utf-8"); }
@@ -63,6 +64,15 @@ function buildSystemPrompt() {
63
64
  }
64
65
  } catch (e) {}
65
66
 
67
+ let slashCommandsBlock = "";
68
+ try {
69
+ const cmds = commandsRegistry.publicCommands();
70
+ if (cmds.length > 0) {
71
+ const lines = cmds.map((c) => `- /${c.name}${c.args ? " " + c.args : ""}${c.description ? " — " + c.description : ""}`);
72
+ slashCommandsBlock = `\n## Slash commands available in chat\nThe user can type these directly. When a request maps to a slash command, prefer guiding them to use it rather than running open-claudia CLIs on their behalf. Owner-only commands are marked in their descriptions.\n\n${lines.join("\n")}\n`;
73
+ }
74
+ } catch (e) {}
75
+
66
76
  return `
67
77
  ${soul}
68
78
 
@@ -82,7 +92,7 @@ ${soul}
82
92
  - Received user files directory: ${FILES_DIR}
83
93
 
84
94
  ${transcriptPointerNote(state)}
85
- ${currentSpeakerBlock}${teamBlock}${pendingTasksBlock}
95
+ ${currentSpeakerBlock}${teamBlock}${pendingTasksBlock}${slashCommandsBlock}
86
96
  ## Delivery
87
97
  Reply normally in your final answer. To send a file, image, or voice clip back to the current chat, run the bot CLI from inside this task — channel context is already in the env:
88
98
  - \`open-claudia send-file <path> [caption]\` — any document/binary
@@ -101,6 +111,8 @@ Wake-ups and crons (real schedulers, not hallucinations — use them instead of
101
111
 
102
112
  Persistent todo list with plans + subtasks (per channel; survives compaction and restart).
103
113
 
114
+ Note: any \`<system-reminder>\` you see about "task tools" / TaskCreate / TaskUpdate is from the underlying Claude Code harness — that's a different, ephemeral todo system. Open Claudia work belongs in \`open-claudia task\` (the persistent, channel-scoped one). Don't double-track.
115
+
104
116
  Use a plan when work is likely to outlive a single turn — i.e. it may hit a compaction, a restart, or a scheduled wakeup before completing, or its progress is something the user will want to see between turns. The plan is a parent task whose children are the steps. As you work, mark each subtask in_progress when you begin and completed when done — this is how a resumed turn sees where you left off. If pending tasks already exist when you start a turn they will be shown under "## Pending tasks" above; check them first.
105
117
 
106
118
  Skip the plan for work that visibly completes within the current turn (e.g. running a few CLI commands in sequence and replying). A plan there is overhead the user has to read past.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {