@inetafrica/open-claudia 2.6.29 → 2.6.31

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/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.6.30
4
+ - **Cluster self-management for AgentSpace pods.** When running as an AgentSpace pod, the bot can inspect and manage its own deployment through the backend broker — `/cluster status|logs [n]|restart|start|stop|scale <0|1>|sync` (owner-gated) for humans, and `open-claudia cluster …` for the agent. Both are thin clients over `core/cluster-client.js`, which posts to the broker's capability-gated `/pods/self/cluster` endpoint with the pod's bearer token; the bot holds no Kubernetes credentials. `brokerConfigured()` requires both `AGENTSPACE_API_URL` and `AGENTSPACE_POD_TOKEN`, so an unconfigured local install safely replies "not available" while a provisioned pod reaches the broker. Also tightens the `core/pack-guard.js` exfil heuristic: the broad "URL co-occurring with a sensitive word" rule (which false-positived on legitimate infra notes) is replaced by two precise carriers — a send/store/upload verb pointed at a URL/address, and a credential attached directly to a URL as a query/fragment param.
5
+
6
+ ## v2.6.29
7
+ - **Kazee channel hardening.** `send()` now retries up to 3× with 1s/2s backoff (skipping 4xx), `edit()` normalizes text the same way as `send()`, inbound handling is async and detects ogg/opus voice notes (promoting them from "audio" to "voice" so the router dispatches correctly), reply-to context best-effort fetches the replied-to message before emitting the envelope, and `format.js` converts stray Telegram HTML tags (`<b>`, `<code>`, `<pre>`, `<i>`, `<s>`, `<a>`) to Markdown as a safety net.
8
+
9
+ ## v2.6.28
10
+ - **Usage: report true peak context, not the inflated per-turn sum.** The result event re-counted the cached prefix once per tool round-trip, so `contextTokens` ballooned into the millions and the "Context this turn" alert fired every turn. It now tracks the peak single-call prefix (input + cache_read + cache_creation) and reports that as `contextTokens`; the summed value is kept as `billedContextTokens` for cost attribution.
11
+
12
+ ## v2.6.27
13
+ - **Tasks: recency-ranked injection, staleness footer, and dream hygiene.** Open-task injection now ranks by activity (max of root and child `updatedAt`) rather than status — the most-recently-worked render in full, colder ones render title-only with a `task get <id>` pointer (new accessor + loopback endpoint + CLI sub), and anything untouched >14d (`OPEN_CLAUDIA_TASK_STALE_DAYS`) collapses into a capped one-line stale footer. The dream pass gains a task-hygiene step that surfaces the stalest tasks in the morning report — flag-and-ask only; it never edits or deletes tasks.
14
+
15
+ ## v2.6.26
16
+ - **Memory: threat-scan guard + FTS-miss instrumentation.** `core/pack-guard.js` scans reviewer/dream content before write (override/exfil/base64 heuristics; strict on the auto-injected Stance/Procedure/State/description, lenient on Journal) and is wired into the pack reviewer and dream merge/umbrella/retag — a hit skips the write and announces one line. `logRecall` now records FTS-miss rescues (packs the judge kept that FTS scored ~0) into `recall-stats.json`, so a vector backend becomes a data-driven decision.
17
+
18
+ ## v2.6.25
19
+ - **Memory: provenance tracking for pack writes.** A per-pack `.provenance.json` sidecar records which origin (user / reviewer / dream) last authored each section; `updatePack`/`createPack` take an origin, and an automated writer never silently overwrites a user-authored Stance. An absent sidecar defaults to "user" (protect-by-default).
20
+
21
+ ## v2.6.24
22
+ - **Memory: pack usage telemetry + lifecycle retirement.** Packs gain `usage_count` telemetry (bumped on each use alongside `last_used`), a dream archive action retires cold packs (idle ≥30d, used ≤3×) into `.archived` with safety guards, and `pack archive`/`restore`/`archived` CLI commands are added; `/skills` surfaces the archived count. Also adds a post-dream chat summary (`DREAM_SUMMARY`), toggled with the new `/dreamsummary on|off` command (default on); when off, the nightly consolidation runs silently.
23
+
24
+ ## v2.6.23
25
+ - **Progressive pack disclosure on by default.** Injects Stance + State teasers with on-demand Procedure/Journal and widens the pack match limit to 6. Override with `PACK_PROGRESSIVE=off`.
26
+
27
+ ## v2.6.22
28
+ - **Wire `/skills` and `/learn` into the context-pack store.** Both previously read from / wrote to the empty legacy `~/.claude/skills` dir; now `/skills` lists packs with show/remove, and `/learn` folds work into a matching pack's Procedure plus a dated Journal line.
29
+
30
+ ## v2.6.21
31
+ - **CI: allow tests to run without bot env.** The test suite no longer requires bot environment variables to be present.
32
+
3
33
  ## v2.6.20
4
34
  - **AgentSpace pod durability.** `/upgrade` and password-change callbacks now preserve the `/api` base path expected by AgentSpace, CLI paths are resolved to concrete executables at startup, and runs repair a stale/missing session workspace before spawning Claude, Cursor, or Codex. This prevents misleading `spawn ... ENOENT` failures when the saved session cwd no longer exists.
5
35
 
package/README.md CHANGED
@@ -47,6 +47,7 @@ Send text, voice notes, screenshots, and files from your phone. Your chosen AI a
47
47
  - **Token economy** — byte-stable system prompt for maximum Anthropic prompt-cache hits; dynamic state rides each message instead
48
48
  - **Web UI** — optional browser UI for setup and config (`open-claudia start --web`)
49
49
  - **Auto-updates** — checks npm every 5 minutes, upgrade with `/upgrade`
50
+ - **Cluster self-management** — when running as an AgentSpace pod, the bot can inspect and manage its own deployment (`/cluster`, or `open-claudia cluster …` for the agent) through the broker, which authenticates, capability-checks, and audits every operation
50
51
  - **Requirements doctor** — `/doctor` checks CLI installs, auth, voice tools, and writable paths
51
52
  - **Cross-platform** — macOS, Linux, and Windows
52
53
 
@@ -187,6 +188,7 @@ When you select a project, the last conversation is automatically resumed. Tap "
187
188
  | `/learn [<hint>]` | Capture the last piece of work into the matching context pack |
188
189
  | `/skills [show\|remove <name>]` | List, show, or remove legacy learned skills |
189
190
  | `/soul` | View/edit assistant identity and personality |
191
+ | `/dreamsummary [on\|off]` | Toggle the post-dream memory summary in chat |
190
192
 
191
193
  ### Automation
192
194
 
@@ -235,9 +237,23 @@ Tokens are redacted from chat output and logs.
235
237
  | `/version` | Show current running version |
236
238
  | `/upgrade` | Upgrade to latest version and restart (includes a post-upgrade doctor summary) |
237
239
  | `/restart` | Restart the bot |
240
+ | `/dashboard` | Get a one-time login link to the web dashboard |
238
241
  | `/stop` | Cancel a running task |
239
242
  | `/help` | Show all commands |
240
243
 
244
+ ### Cluster control
245
+
246
+ Available only when the bot runs as an AgentSpace-provisioned pod (the broker credentials `AGENTSPACE_API_URL` and `AGENTSPACE_POD_TOKEN` are present). Off this network the command replies that it isn't available and does nothing. Owner-gated; every operation is authenticated, capability-checked, and audited by the broker, and confined to the bot's own deployment.
247
+
248
+ | Command | Description |
249
+ |---------|-------------|
250
+ | `/cluster status` | Deployment readiness/phase |
251
+ | `/cluster logs [n]` | Tail the last n container log lines |
252
+ | `/cluster restart` | Rollout restart (pulls latest image) |
253
+ | `/cluster start` / `/cluster stop` | Scale to 1 / 0 replicas |
254
+ | `/cluster scale <0\|1>` | Stop (0) or start (1) |
255
+ | `/cluster sync` | Re-apply the deployment manifest |
256
+
241
257
  ## Memory & Long-Term Context
242
258
 
243
259
  Open Claudia layers three memory systems on top of the backend's native sessions:
package/bin/cli.js CHANGED
@@ -274,6 +274,11 @@ switch (command) {
274
274
  break;
275
275
  }
276
276
 
277
+ case "cluster": {
278
+ require("./cluster").run(args.slice(1));
279
+ break;
280
+ }
281
+
277
282
  case "pack": {
278
283
  require("./pack").run(args.slice(1));
279
284
  break;
@@ -347,6 +352,9 @@ Background work (only inside an active bot-spawned task):
347
352
  open-claudia task add|list|start|done|remove Per-channel todo (survives restarts)
348
353
  open-claudia agent "<prompt>" [--role X] Spawn a throwaway sub-agent for research
349
354
 
355
+ Cluster control (self-management of this bot's own deployment via the broker):
356
+ open-claudia cluster status|logs|restart|start|stop|scale <0|1>|sync
357
+
350
358
  Multi-user / cross-channel:
351
359
  open-claudia people list|show|add|note|link Roster of known team members
352
360
  open-claudia intros list|approve|reject Pending introductions from unknown chats
package/bin/cluster.js ADDED
@@ -0,0 +1,75 @@
1
+ // open-claudia cluster <operation>
2
+ //
3
+ // Self-management of this bot's own Kubernetes deployment, mediated by the
4
+ // AgentSpace broker. The bot has no direct cluster credentials — every command
5
+ // here is a request the broker authenticates, capability-checks, audits, and
6
+ // executes against the pod's own namespace.
7
+
8
+ const { clusterRequest } = require("../core/cluster-client");
9
+
10
+ const HELP = `
11
+ Cluster control for this bot's own deployment (via the AgentSpace broker).
12
+
13
+ open-claudia cluster status Show deployment readiness/phase
14
+ open-claudia cluster logs [n] Tail the last n container log lines (default 100, max 1000)
15
+ open-claudia cluster restart Rollout restart (pulls latest image)
16
+ open-claudia cluster start Scale to 1 replica
17
+ open-claudia cluster stop Scale to 0 replicas
18
+ open-claudia cluster scale <0|1> Stop (0) or start (1)
19
+ open-claudia cluster sync Re-apply the deployment manifest
20
+
21
+ All operations are confined to this pod's own namespace and gated by the
22
+ capabilities the broker has granted this bot.
23
+ `;
24
+
25
+ function ok(line) { if (line) console.log(line); process.exit(0); }
26
+ function fail(line) { if (line) console.error(line); process.exit(1); }
27
+
28
+ // Render a broker response into a human line + correct exit code.
29
+ function report(res, successLine) {
30
+ if (res.status >= 200 && res.status < 300) {
31
+ ok(typeof successLine === "function" ? successLine(res.json || {}) : successLine);
32
+ }
33
+ const detail = res.json && res.json.error ? res.json.error : (res.body || "").slice(0, 400);
34
+ if (res.status === 0) fail(`Broker request failed: ${detail}`);
35
+ fail(`Broker ${res.status}: ${detail}`);
36
+ }
37
+
38
+ async function run(args) {
39
+ const op = (args[0] || "").toLowerCase();
40
+ const rest = args.slice(1);
41
+ if (!op || op === "help" || op === "--help") { console.log(HELP); process.exit(op ? 0 : 2); }
42
+
43
+ switch (op) {
44
+ case "status": {
45
+ const res = await clusterRequest("status");
46
+ return report(res, (j) =>
47
+ `Deployment: ${j.phase || "?"}${j.ready ? " (ready)" : ""}${j.message ? ` — ${j.message}` : ""}`);
48
+ }
49
+ case "logs": {
50
+ const tailLines = rest[0] ? parseInt(rest[0], 10) : undefined;
51
+ if (rest[0] && Number.isNaN(tailLines)) fail("Usage: cluster logs [n] (n = number of lines)");
52
+ const res = await clusterRequest("logs", tailLines ? { tailLines } : {});
53
+ return report(res, (j) => (j.logs || "(no logs)"));
54
+ }
55
+ case "restart":
56
+ return report(await clusterRequest("restart"), (j) => j.message || "Restart requested.");
57
+ case "start":
58
+ return report(await clusterRequest("start"), (j) => j.message || "Started.");
59
+ case "stop":
60
+ return report(await clusterRequest("stop"), (j) => j.message || "Stopped.");
61
+ case "scale": {
62
+ const n = rest[0];
63
+ if (n !== "0" && n !== "1") fail("Usage: cluster scale <0|1>");
64
+ return report(await clusterRequest("scale", { replicas: parseInt(n, 10) }), (j) => j.message || `Scaled to ${n}.`);
65
+ }
66
+ case "sync":
67
+ return report(await clusterRequest("sync"), (j) => j.message || "Reconciled.");
68
+ default:
69
+ console.error(`Unknown cluster operation: ${op}`);
70
+ console.log(HELP);
71
+ process.exit(2);
72
+ }
73
+ }
74
+
75
+ module.exports = { run, HELP };
package/core/actions.js CHANGED
@@ -19,6 +19,9 @@ const { finishOnboarding } = require("./onboarding");
19
19
  const { startSession } = require("./handlers");
20
20
  const jobs = require("./jobs");
21
21
  const scheduler = require("./scheduler");
22
+ const {
23
+ getClaudeOAuthToken, runClaudeAuthCommand, runCodexLoginStatus, runCodexDeviceLogin,
24
+ } = require("./auth-flow");
22
25
 
23
26
  async function handleAction(envelope) {
24
27
  const adapter = envelope.adapter;
@@ -251,6 +254,20 @@ async function handleAction(envelope) {
251
254
  state.settings.model = model;
252
255
  saveState();
253
256
  const beLabel = be === "cursor" ? "Cursor Agent" : be === "codex" ? "Codex" : "Claude Code";
257
+ // Auth-aware: if the chosen provider isn't connected, start its login flow
258
+ // now. The model is already set, so once auth completes the user is on it.
259
+ if (be === "claude" && !getClaudeOAuthToken().value) {
260
+ if (!isChatOwner(envelope.channelId)) { await send("Claude isn't connected and only the owner can sign it in."); return; }
261
+ await send(`Set to ${model}, but Claude isn't connected yet. Starting login — open the URL I send, then paste the code here.`);
262
+ await runClaudeAuthCommand(["auth", "login", "--claudeai"], "Claude login");
263
+ return;
264
+ }
265
+ if (be === "codex" && !runCodexLoginStatus().ok) {
266
+ if (!isChatOwner(envelope.channelId)) { await send("Codex isn't connected and only the owner can sign it in."); return; }
267
+ await send(`Set to ${model}, but Codex isn't connected yet. Starting device login — open the URL and enter the code I send.`);
268
+ await runCodexDeviceLogin();
269
+ return;
270
+ }
254
271
  await send(switched ? `Switched to ${beLabel}.\nModel: ${model}` : `Model: ${model}`);
255
272
  return;
256
273
  }
package/core/auth-flow.js CHANGED
@@ -317,6 +317,16 @@ function runCodexLoginStatus() {
317
317
  return { ...result, ok: result.ok && !isCodexAuthErrorText(result.output) };
318
318
  }
319
319
 
320
+ // Quick "is this provider usable non-interactively" check for the unified
321
+ // /login model picker. Claude is cheap (token presence is what the bot relies
322
+ // on). Codex spawns `codex login status` once — fine for a setup screen.
323
+ function providerAuthStatus() {
324
+ return {
325
+ claude: !!getClaudeOAuthToken().value,
326
+ codex: resolvedCodexPath ? runCodexLoginStatus().ok : false,
327
+ };
328
+ }
329
+
320
330
  async function sendCodexAuthStatusSummary(prefix = "Codex auth status") {
321
331
  const status = runCodexLoginStatus();
322
332
  const version = resolvedCodexPath ? runCommand(resolvedCodexPath, ["--version"]) : { ok: false, output: "not found" };
@@ -427,6 +437,7 @@ module.exports = {
427
437
  clearPendingCodexAuth,
428
438
  runCommand,
429
439
  runCodexLoginStatus,
440
+ providerAuthStatus,
430
441
  sendCodexAuthStatusSummary,
431
442
  saveCodexApiKeyWithCli,
432
443
  runCodexDeviceLogin,
@@ -0,0 +1,79 @@
1
+ // Thin client for the AgentSpace cluster-control broker.
2
+ //
3
+ // Both entry points use this single implementation:
4
+ // - bin/cluster.js (the `open-claudia cluster ...` CLI, driven by the agent)
5
+ // - core/handlers.js (the `/cluster` Telegram slash command, driven by a human)
6
+ //
7
+ // The bot holds no Kubernetes credentials. It authenticates to the broker as
8
+ // itself with the pod-self token; the broker enforces capabilities, policy and
9
+ // audit and performs the actual cluster operation on the pod's own namespace.
10
+
11
+ const CLUSTER_OPERATIONS = ["status", "logs", "restart", "start", "stop", "scale", "sync"];
12
+
13
+ function brokerConfigured() {
14
+ return !!(process.env.AGENTSPACE_API_URL && process.env.AGENTSPACE_POD_TOKEN);
15
+ }
16
+
17
+ // Build the absolute URL to a pods/self/* endpoint, mirroring the path-base
18
+ // logic used by the upgrade flow in core/handlers.js.
19
+ function endpoint(pathSuffix) {
20
+ const apiUrl = process.env.AGENTSPACE_API_URL;
21
+ const base = new URL(apiUrl);
22
+ if (base.pathname === "/") base.pathname = "/api/";
23
+ if (!base.pathname.endsWith("/")) base.pathname += "/";
24
+ return new URL(pathSuffix, base);
25
+ }
26
+
27
+ // POST a cluster operation to the broker. Resolves to { status, body, json }.
28
+ // Never rejects on HTTP/transport errors — surfaces them via status 0 so callers
29
+ // can format a single error path.
30
+ function clusterRequest(operation, params = {}) {
31
+ return new Promise((resolve) => {
32
+ if (!brokerConfigured()) {
33
+ resolve({
34
+ status: 0,
35
+ body: "AgentSpace broker not configured (AGENTSPACE_API_URL / AGENTSPACE_POD_TOKEN missing). This only works inside a pod provisioned by AgentSpace.",
36
+ json: null,
37
+ });
38
+ return;
39
+ }
40
+
41
+ let u;
42
+ try {
43
+ u = endpoint("pods/self/cluster");
44
+ } catch (e) {
45
+ resolve({ status: 0, body: `Bad AGENTSPACE_API_URL: ${e.message || e}`, json: null });
46
+ return;
47
+ }
48
+
49
+ const payload = JSON.stringify({ operation, ...params });
50
+ const lib = u.protocol === "https:" ? require("https") : require("http");
51
+ const req = lib.request(
52
+ {
53
+ method: "POST",
54
+ hostname: u.hostname,
55
+ port: u.port || (u.protocol === "https:" ? 443 : 80),
56
+ path: u.pathname + u.search,
57
+ headers: {
58
+ Authorization: `Bearer ${process.env.AGENTSPACE_POD_TOKEN}`,
59
+ "Content-Type": "application/json",
60
+ "Content-Length": Buffer.byteLength(payload),
61
+ },
62
+ },
63
+ (res) => {
64
+ let data = "";
65
+ res.on("data", (c) => { data += c; });
66
+ res.on("end", () => {
67
+ let json = null;
68
+ try { json = JSON.parse(data); } catch (e) {}
69
+ resolve({ status: res.statusCode, body: data, json });
70
+ });
71
+ }
72
+ );
73
+ req.on("error", (e) => resolve({ status: 0, body: String(e.message || e), json: null }));
74
+ req.setTimeout(20000, () => { req.destroy(new Error("timeout")); });
75
+ req.end(payload);
76
+ });
77
+ }
78
+
79
+ module.exports = { clusterRequest, brokerConfigured, CLUSTER_OPERATIONS };
package/core/handlers.js CHANGED
@@ -38,7 +38,7 @@ const {
38
38
  sendClaudeAuthStatusSummary,
39
39
  runClaudeAuthCommand, clearPendingClaudeAuth,
40
40
  sendCodexAuthStatusSummary, runCodexDeviceLogin, clearPendingCodexAuth,
41
- saveCodexApiKeyWithCli,
41
+ saveCodexApiKeyWithCli, providerAuthStatus,
42
42
  } = require("./auth-flow");
43
43
 
44
44
  const CURRENT_VERSION = require(path.join(__dirname, "..", "package.json")).version;
@@ -126,7 +126,8 @@ register({
126
126
  "Identity: /whoami /link",
127
127
  "Team: /people /intros /auth (owner)",
128
128
  "Automation: /cron /vault /soul /dreamsummary",
129
- "Claude auth: /auth_status /login /setup_token /use_oauth_token /clear_oauth_token",
129
+ "Connect: /login (pick model + sign in)",
130
+ "Claude auth: /auth_status /setup_token /use_oauth_token /clear_oauth_token",
130
131
  "Codex auth: /codex_auth_status /codex_login /codex_setup_token",
131
132
  "System: /doctor /requirements /restart /upgrade",
132
133
  "",
@@ -525,6 +526,52 @@ register({
525
526
  },
526
527
  });
527
528
 
529
+ register({
530
+ name: "cluster", description: "Control this bot's own deployment (status/logs/restart/start/stop/scale/sync)",
531
+ args: "<status|logs|restart|start|stop|scale 0|1|sync>", ownerOnly: true,
532
+ handler: async (env, { tail }) => {
533
+ if (!ownerEnv(env)) return;
534
+ const { clusterRequest, brokerConfigured } = require("./cluster-client");
535
+ if (!brokerConfigured()) {
536
+ return send("Cluster control isn't available — this bot isn't running under AgentSpace (no broker credentials).");
537
+ }
538
+ const parts = (tail || "").trim().split(/\s+/).filter(Boolean);
539
+ const op = (parts[0] || "").toLowerCase();
540
+ const allowed = ["status", "logs", "restart", "start", "stop", "scale", "sync"];
541
+ if (!op || !allowed.includes(op)) {
542
+ return send("Usage: /cluster <status | logs [n] | restart | start | stop | scale 0|1 | sync>");
543
+ }
544
+ const params = {};
545
+ if (op === "logs" && parts[1]) {
546
+ const n = parseInt(parts[1], 10);
547
+ if (!Number.isNaN(n)) params.tailLines = n;
548
+ }
549
+ if (op === "scale") {
550
+ const n = parts[1];
551
+ if (n !== "0" && n !== "1") return send("Usage: /cluster scale <0|1>");
552
+ params.replicas = parseInt(n, 10);
553
+ }
554
+ try {
555
+ const res = await clusterRequest(op, params);
556
+ if (res.status >= 200 && res.status < 300) {
557
+ const j = res.json || {};
558
+ if (op === "status") {
559
+ return send(`Deployment: ${j.phase || "?"}${j.ready ? " (ready)" : ""}${j.message ? ` — ${j.message}` : ""}`);
560
+ }
561
+ if (op === "logs") {
562
+ const logs = j.logs || "(no logs)";
563
+ return send(logs.length > 3500 ? logs.slice(-3500) : logs);
564
+ }
565
+ return send(j.message || "Done.");
566
+ }
567
+ const detail = (res.json && res.json.error) ? res.json.error : (res.body || "").slice(0, 300);
568
+ return send(res.status === 0 ? `Cluster request failed: ${detail}` : `Cluster ${res.status}: ${detail}`);
569
+ } catch (e) {
570
+ return send(`Cluster error: ${e.message || e}`);
571
+ }
572
+ },
573
+ });
574
+
528
575
  register({
529
576
  name: "projects", description: "Browse all workspace projects",
530
577
  handler: async (env) => { if (authorized(env)) send("Pick:", { keyboard: projectKeyboard() }); },
@@ -559,6 +606,57 @@ register({
559
606
  },
560
607
  });
561
608
 
609
+ // Shared model picker used by /model and the unified /login. When authAware is
610
+ // set, the provider section headers show connection status so the user knows a
611
+ // tap on an unconnected provider will start its auth flow (handled in the mb:
612
+ // callback in actions.js).
613
+ function buildModelPicker({ authAware = false } = {}) {
614
+ const { settings } = currentState();
615
+ const auth = authAware ? providerAuthStatus() : null;
616
+ const mark = (ok) => authAware ? (ok ? " · connected" : " · tap to connect") : "";
617
+ const rows = [];
618
+ rows.push([{ text: `── Claude${mark(auth && auth.claude)} ──`, callback_data: "noop" }]);
619
+ rows.push([
620
+ { text: "Fable 5", callback_data: "mb:claude:claude-fable-5" },
621
+ { text: "Opus 4.8", callback_data: "mb:claude:claude-opus-4-8" },
622
+ ]);
623
+ rows.push([
624
+ { text: "Opus 4.7", callback_data: "mb:claude:claude-opus-4-7" },
625
+ { text: "Opus 4.6", callback_data: "mb:claude:claude-opus-4-6" },
626
+ ]);
627
+ rows.push([
628
+ { text: "Sonnet 4.6", callback_data: "mb:claude:claude-sonnet-4-6" },
629
+ { text: "Haiku", callback_data: "mb:claude:claude-haiku-4-5-20251001" },
630
+ ]);
631
+ if (resolvedCursorPath) {
632
+ rows.push([{ text: "── Cursor ──", callback_data: "noop" }]);
633
+ rows.push([
634
+ { text: "Composer 2", callback_data: "mb:cursor:composer-2" },
635
+ { text: "Composer 2 Fast", callback_data: "mb:cursor:composer-2-fast" },
636
+ { text: "Auto", callback_data: "mb:cursor:auto" },
637
+ ]);
638
+ rows.push([
639
+ { text: "Opus 4.6 Thinking", callback_data: "mb:cursor:claude-4.6-opus-high-thinking" },
640
+ { text: "GPT-5.4", callback_data: "mb:cursor:gpt-5.4-medium" },
641
+ ]);
642
+ }
643
+ if (resolvedCodexPath) {
644
+ rows.push([{ text: `── Codex${mark(auth && auth.codex)} ──`, callback_data: "noop" }]);
645
+ rows.push([
646
+ { text: "GPT-5.5", callback_data: "mb:codex:gpt-5.5" },
647
+ { text: "gpt-5", callback_data: "mb:codex:gpt-5" },
648
+ { text: "gpt-5-codex", callback_data: "mb:codex:gpt-5-codex" },
649
+ ]);
650
+ rows.push([
651
+ { text: "o3", callback_data: "mb:codex:o3" },
652
+ { text: "o4-mini", callback_data: "mb:codex:o4-mini" },
653
+ ]);
654
+ }
655
+ rows.push([{ text: `Default (Claude: ${DEFAULT_CLAUDE_MODEL})`, callback_data: "m:default" }]);
656
+ const beLabel = settings.backend === "cursor" ? "Cursor" : settings.backend === "codex" ? "Codex" : "Claude";
657
+ return { rows, beLabel, model: settings.model };
658
+ }
659
+
562
660
  register({
563
661
  name: "model", description: "Switch model (opus/sonnet/haiku)", args: "[<model>]",
564
662
  handler: async (env, { tail }) => {
@@ -569,48 +667,8 @@ register({
569
667
  if (settings.model === "default") settings.model = null;
570
668
  return send(`Model: ${settings.model || "default"}`);
571
669
  }
572
- const { settings } = currentState();
573
- const rows = [];
574
- rows.push([{ text: "── Claude ──", callback_data: "noop" }]);
575
- rows.push([
576
- { text: "Fable 5", callback_data: "mb:claude:claude-fable-5" },
577
- { text: "Opus 4.8", callback_data: "mb:claude:claude-opus-4-8" },
578
- ]);
579
- rows.push([
580
- { text: "Opus 4.7", callback_data: "mb:claude:claude-opus-4-7" },
581
- { text: "Opus 4.6", callback_data: "mb:claude:claude-opus-4-6" },
582
- ]);
583
- rows.push([
584
- { text: "Sonnet 4.6", callback_data: "mb:claude:claude-sonnet-4-6" },
585
- { text: "Haiku", callback_data: "mb:claude:claude-haiku-4-5-20251001" },
586
- ]);
587
- if (resolvedCursorPath) {
588
- rows.push([{ text: "── Cursor ──", callback_data: "noop" }]);
589
- rows.push([
590
- { text: "Composer 2", callback_data: "mb:cursor:composer-2" },
591
- { text: "Composer 2 Fast", callback_data: "mb:cursor:composer-2-fast" },
592
- { text: "Auto", callback_data: "mb:cursor:auto" },
593
- ]);
594
- rows.push([
595
- { text: "Opus 4.6 Thinking", callback_data: "mb:cursor:claude-4.6-opus-high-thinking" },
596
- { text: "GPT-5.4", callback_data: "mb:cursor:gpt-5.4-medium" },
597
- ]);
598
- }
599
- if (resolvedCodexPath) {
600
- rows.push([{ text: "── Codex ──", callback_data: "noop" }]);
601
- rows.push([
602
- { text: "GPT-5.5", callback_data: "mb:codex:gpt-5.5" },
603
- { text: "gpt-5", callback_data: "mb:codex:gpt-5" },
604
- { text: "gpt-5-codex", callback_data: "mb:codex:gpt-5-codex" },
605
- ]);
606
- rows.push([
607
- { text: "o3", callback_data: "mb:codex:o3" },
608
- { text: "o4-mini", callback_data: "mb:codex:o4-mini" },
609
- ]);
610
- }
611
- rows.push([{ text: `Default (Claude: ${DEFAULT_CLAUDE_MODEL})`, callback_data: "m:default" }]);
612
- const beLabel = settings.backend === "cursor" ? "Cursor" : settings.backend === "codex" ? "Codex" : "Claude";
613
- send(`Current: ${beLabel} · ${settings.model || "default"}\n\nPick a model — backend switches automatically.\nOr type /model <name>.`, { keyboard: { inline_keyboard: rows } });
670
+ const { rows, beLabel, model } = buildModelPicker();
671
+ send(`Current: ${beLabel} · ${model || "default"}\n\nPick a model — backend switches automatically.\nOr type /model <name>.`, { keyboard: { inline_keyboard: rows } });
614
672
  },
615
673
  });
616
674
 
@@ -1102,10 +1160,12 @@ register({
1102
1160
  });
1103
1161
 
1104
1162
  register({
1105
- name: "login", description: "Start Claude Code login", ownerOnly: true,
1163
+ name: "login", description: "Pick a model and connect its provider", ownerOnly: true,
1106
1164
  handler: async (env) => {
1107
- if (!ownerEnv(env)) return send("Owner only — Claude auth is shared across users.");
1108
- await runClaudeAuthCommand(["auth", "login", "--claudeai", "--email", "sumeet@inet.africa"], "Claude login");
1165
+ if (!ownerEnv(env)) return send("Owner only — provider auth is shared across users.");
1166
+ await send("Connecting… checking which providers are signed in.");
1167
+ const { rows, beLabel, model } = buildModelPicker({ authAware: true });
1168
+ send(`Current: ${beLabel} · ${model || "default"}\n\nPick a model. If its provider isn't connected yet, tapping it starts the login flow — then you're on that model.`, { keyboard: { inline_keyboard: rows } });
1109
1169
  },
1110
1170
  });
1111
1171
 
@@ -29,8 +29,15 @@ const OVERRIDE_PATTERNS = [
29
29
  // Exfiltration: a send/post/upload verb pointed at a URL or address. Scanned in
30
30
  // every section, including Journal.
31
31
  const EXFIL_PATTERNS = [
32
- /\b(send|post|upload|exfiltrate|forward|transmit|leak|email|curl|wget|fetch)\b[^.\n]{0,60}\b(to|at|into)\b[^.\n]{0,40}(https?:\/\/|ftp:\/\/|[\w.-]+@[\w.-]+)/i,
33
- /\b(https?:\/\/)[^\s)]+[^\s)]*\b.*\b(api\s*key|token|password|secret|credential|env)/i,
32
+ // A send/store/upload verb pointed at a URL or address.
33
+ /\b(send|post|upload|exfiltrate|forward|transmit|leak|email|curl|wget|fetch|store|save|drop|put|copy)\b[^.\n]{0,60}\b(to|at|into)\b[^.\n]{0,40}(https?:\/\/|ftp:\/\/|[\w.-]+@[\w.-]+)/i,
34
+ // A credential embedded directly in a URL as a query/fragment param
35
+ // (e.g. https://evil/?token=…): the classic verbless exfil carrier. Prose that
36
+ // merely mentions a URL near the word "token" no longer trips — the credential
37
+ // must actually be attached to the URL with =/:, which legitimate infra/ops
38
+ // notes don't do. This replaces a broad URL+keyword co-occurrence rule that
39
+ // false-positived on benign sentences like "the API at <url> uses a token".
40
+ /(https?:\/\/)[^\s)]*?\b(api_?key|access_?token|token|password|secret|credential)\b\s*[=:]/i,
34
41
  /\bcurl\b[^\n]*\b(https?:\/\/)/i,
35
42
  ];
36
43
 
package/core/router.js CHANGED
@@ -228,10 +228,17 @@ async function handleText(envelope) {
228
228
  if (state.pendingClaudeAuthProcess && state.pendingClaudeAuthLabel !== "manual OAuth token save") {
229
229
  const text = (envelope.text || "").trim();
230
230
  if (looksLikeClaudeAuthReply(text)) {
231
- await send("That looks like a Claude login code/callback. I did not delete it or send it to Claude as a prompt. Please resend it as `/auth_code YOUR_CODE`, or use /cancel_auth to stop the login flow.");
231
+ await deleteMessage(envelope.messageId);
232
+ try {
233
+ state.pendingClaudeAuthProcess.stdin.write(text + "\n");
234
+ await send("Got it — sent the code to Claude. I'll confirm with an auth-status check when it finishes.");
235
+ } catch (e) {
236
+ clearPendingClaudeAuth(state);
237
+ await send(`Could not send the auth code to Claude: ${redactSensitive(e.message)}`);
238
+ }
232
239
  return;
233
240
  }
234
- await send("Claude login is still waiting. I will not delete normal messages. If Claude gave you a code, send it as `/auth_code YOUR_CODE`, or use /cancel_auth.");
241
+ await send("Claude login is still waiting. Paste the code/callback Claude gave you and I'll submit it, or send /cancel_auth to stop.");
235
242
  }
236
243
 
237
244
  if (!isOnboarded() && state.onboardingStep) {
@@ -0,0 +1,160 @@
1
+ // Web-driven Claude/Codex authentication. Runs the same interactive CLIs
2
+ // the Telegram /login flow uses, but captures the URL/output into in-memory
3
+ // state that the web dashboard polls, instead of pushing it to a chat.
4
+ // A single admin owns the dashboard, so one module-level slot per provider
5
+ // is enough. Lives in-process with the bot (web.js is started by bot.js).
6
+
7
+ const { spawn } = require("child_process");
8
+ const os = require("os");
9
+ const { CLAUDE_PATH, resolvedCodexPath, botSubprocessEnv } = require("./config");
10
+ const { redactSensitive, stripTerminalControls, extractUrls } = require("./redact");
11
+ const {
12
+ claudeSubprocessEnv, saveClaudeOAuthToken, getClaudeOAuthToken,
13
+ extractClaudeToken, isClaudeAuthUrl, looksLikeClaudeToken, looksLikeOpenAIKey,
14
+ runClaudeAuthStatusDiagnostic, isClaudeAuthErrorText,
15
+ runCodexLoginStatus, saveCodexApiKeyWithCli,
16
+ } = require("./auth-flow");
17
+
18
+ const HOME = process.env.HOME || os.homedir();
19
+
20
+ // ── Claude interactive browser login ────────────────────────────────
21
+ let claude = null;
22
+
23
+ function startClaudeLogin() {
24
+ if (claude && claude.proc && !claude.done) return { ok: true, already: true };
25
+ const proc = spawn(CLAUDE_PATH, ["auth", "login", "--claudeai"], {
26
+ cwd: HOME, env: claudeSubprocessEnv(), stdio: ["pipe", "pipe", "pipe"],
27
+ });
28
+ claude = { proc, url: null, log: "", awaitingCode: false, done: false, ok: false, error: null, tokenStored: false };
29
+ const onChunk = (buf) => {
30
+ const chunk = buf.toString();
31
+ claude.log = (claude.log + chunk).slice(-4000);
32
+ const token = extractClaudeToken(chunk) || extractClaudeToken(claude.log);
33
+ if (token && !claude.tokenStored) claude.tokenStored = saveClaudeOAuthToken(token);
34
+ for (const u of extractUrls(chunk)) {
35
+ if (isClaudeAuthUrl(u) && !claude.url) { claude.url = u; claude.awaitingCode = true; }
36
+ }
37
+ };
38
+ proc.stdout.on("data", onChunk);
39
+ proc.stderr.on("data", onChunk);
40
+ proc.on("close", (code) => {
41
+ claude.done = true;
42
+ claude.awaitingCode = false;
43
+ const tty = /raw mode is not supported|process\.stdin|not a tty|inappropriate ioctl/i.test(claude.log);
44
+ claude.ok = claude.tokenStored || code === 0;
45
+ if (!claude.ok) claude.error = tty ? "tty" : "failed";
46
+ });
47
+ proc.on("error", (err) => { claude.done = true; claude.ok = false; claude.error = redactSensitive(err.message); });
48
+ return { ok: true };
49
+ }
50
+
51
+ function submitClaudeCode(code) {
52
+ if (!claude || !claude.proc || claude.done) return { ok: false, error: "No Claude login is in progress." };
53
+ try {
54
+ claude.proc.stdin.write(String(code || "").trim() + "\n");
55
+ claude.awaitingCode = false;
56
+ return { ok: true };
57
+ } catch (e) {
58
+ return { ok: false, error: redactSensitive(e.message) };
59
+ }
60
+ }
61
+
62
+ function cancelClaude() {
63
+ if (claude && claude.proc && claude.proc.kill) { try { claude.proc.kill("SIGTERM"); } catch (e) {} }
64
+ claude = null;
65
+ return { ok: true };
66
+ }
67
+
68
+ function claudeState() {
69
+ if (!claude) return { running: false };
70
+ return {
71
+ running: !claude.done,
72
+ url: claude.url,
73
+ awaitingCode: claude.awaitingCode,
74
+ done: claude.done,
75
+ ok: claude.ok,
76
+ error: claude.error,
77
+ log: redactSensitive(stripTerminalControls(claude.log)).slice(-600),
78
+ };
79
+ }
80
+
81
+ // Reliable path: save a pasted Claude OAuth token directly.
82
+ function saveClaudeToken(token) {
83
+ const t = String(token || "").trim();
84
+ if (!looksLikeClaudeToken(t)) return { ok: false, error: "That does not look like a Claude OAuth token." };
85
+ return saveClaudeOAuthToken(t) ? { ok: true } : { ok: false, error: "Could not store the token." };
86
+ }
87
+
88
+ // ── Codex device login ──────────────────────────────────────────────
89
+ let codex = null;
90
+
91
+ function startCodexLogin() {
92
+ if (!resolvedCodexPath) return { ok: false, error: "Codex CLI not found." };
93
+ if (codex && codex.proc && !codex.done) return { ok: true, already: true };
94
+ const proc = spawn(resolvedCodexPath, ["login", "--device-auth"], {
95
+ cwd: HOME, env: botSubprocessEnv(), stdio: ["pipe", "pipe", "pipe"],
96
+ });
97
+ codex = { proc, url: null, code: null, log: "", done: false, ok: false, error: null };
98
+ const onChunk = (buf) => {
99
+ const clean = redactSensitive(stripTerminalControls(buf.toString()));
100
+ codex.log = (codex.log + clean).slice(-4000);
101
+ for (const u of extractUrls(clean)) { if (!codex.url) codex.url = u; }
102
+ const m = clean.match(/(?:code|device code)[:\s]+([A-Z0-9-]{6,})/i);
103
+ if (m && !codex.code) codex.code = m[1];
104
+ };
105
+ proc.stdout.on("data", onChunk);
106
+ proc.stderr.on("data", onChunk);
107
+ proc.on("close", (code) => {
108
+ codex.done = true;
109
+ const tty = /raw mode is not supported|not a tty|inappropriate ioctl/i.test(codex.log);
110
+ codex.ok = code === 0;
111
+ if (!codex.ok) codex.error = tty ? "tty" : "failed";
112
+ });
113
+ proc.on("error", (err) => { codex.done = true; codex.ok = false; codex.error = redactSensitive(err.message); });
114
+ return { ok: true };
115
+ }
116
+
117
+ function cancelCodex() {
118
+ if (codex && codex.proc && codex.proc.kill) { try { codex.proc.kill("SIGTERM"); } catch (e) {} }
119
+ codex = null;
120
+ return { ok: true };
121
+ }
122
+
123
+ function codexState() {
124
+ if (!codex) return { running: false };
125
+ return {
126
+ running: !codex.done,
127
+ url: codex.url,
128
+ code: codex.code,
129
+ done: codex.done,
130
+ ok: codex.ok,
131
+ error: codex.error,
132
+ log: codex.log.slice(-600),
133
+ };
134
+ }
135
+
136
+ // Reliable path: save a pasted OpenAI API key via the Codex CLI.
137
+ async function saveCodexApiKey(key) {
138
+ if (!resolvedCodexPath) return { ok: false, error: "Codex CLI not found." };
139
+ const k = String(key || "").trim();
140
+ if (!looksLikeOpenAIKey(k)) return { ok: false, error: "That does not look like an OpenAI API key." };
141
+ const result = await saveCodexApiKeyWithCli(k);
142
+ return result.ok ? { ok: true } : { ok: false, error: redactSensitive(result.output).slice(-300) };
143
+ }
144
+
145
+ // ── Combined status ─────────────────────────────────────────────────
146
+ function authStatus() {
147
+ const tokenInfo = getClaudeOAuthToken();
148
+ const diag = runClaudeAuthStatusDiagnostic();
149
+ const codexStatus = resolvedCodexPath ? runCodexLoginStatus() : { ok: false };
150
+ return {
151
+ claude: { configured: !!tokenInfo.value, source: tokenInfo.source, loggedIn: !isClaudeAuthErrorText(diag) },
152
+ codex: { cliFound: !!resolvedCodexPath, loggedIn: !!codexStatus.ok },
153
+ };
154
+ }
155
+
156
+ module.exports = {
157
+ startClaudeLogin, submitClaudeCode, cancelClaude, claudeState, saveClaudeToken,
158
+ startCodexLogin, cancelCodex, codexState, saveCodexApiKey,
159
+ authStatus,
160
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.29",
3
+ "version": "2.6.31",
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": {
package/web.js CHANGED
@@ -15,6 +15,7 @@ const ENV_FILE = path.join(CONFIG_DIR, ".env");
15
15
  const AUTH_FILE = path.join(CONFIG_DIR, "auth.json");
16
16
  const SOUL_FILE = path.join(CONFIG_DIR, "soul.md");
17
17
  const CRONS_FILE = path.join(CONFIG_DIR, "crons.json");
18
+ const JOBS_FILE = path.join(CONFIG_DIR, "jobs.json");
18
19
  const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
19
20
  const WEB_PASSWORD_FILE = path.join(CONFIG_DIR, ".web-password");
20
21
  const PORT = parseInt(process.env.WEB_PORT || "8080", 10);
@@ -173,6 +174,13 @@ function loadSoul() {
173
174
  }
174
175
 
175
176
  function loadCrons() {
177
+ // Crons now live in the unified jobs store (jobs.json), tagged kind:"cron".
178
+ // The legacy crons.json was migrated and renamed, so read jobs first and
179
+ // only fall back to the old file for un-migrated installs.
180
+ try {
181
+ const jobs = JSON.parse(fs.readFileSync(JOBS_FILE, "utf-8"));
182
+ if (Array.isArray(jobs)) return jobs.filter((j) => j && j.kind === "cron");
183
+ } catch (e) { /* fall through to legacy */ }
176
184
  try { return JSON.parse(fs.readFileSync(CRONS_FILE, "utf-8")); } catch (e) { return []; }
177
185
  }
178
186
 
@@ -261,6 +269,24 @@ async function handleAPI(req, res, body) {
261
269
  return res.end(JSON.stringify({ error: "Unauthorized" }));
262
270
  }
263
271
 
272
+ // ── Authentication (Claude / Codex) ──
273
+ if (url.startsWith("/api/auth/")) {
274
+ const webAuth = require("./core/web-auth");
275
+ const json = (obj, c = 200) => { res.writeHead(c, { "Content-Type": "application/json" }); return res.end(JSON.stringify(obj)); };
276
+ const post = req.method === "POST";
277
+ if (url === "/api/auth/status") return json(webAuth.authStatus());
278
+ if (url === "/api/auth/claude/start" && post) return json(webAuth.startClaudeLogin());
279
+ if (url === "/api/auth/claude/state") return json(webAuth.claudeState());
280
+ if (url === "/api/auth/claude/code" && post) return json(webAuth.submitClaudeCode(JSON.parse(body || "{}").code));
281
+ if (url === "/api/auth/claude/token" && post) return json(webAuth.saveClaudeToken(JSON.parse(body || "{}").token));
282
+ if (url === "/api/auth/claude/cancel" && post) return json(webAuth.cancelClaude());
283
+ if (url === "/api/auth/codex/start" && post) return json(webAuth.startCodexLogin());
284
+ if (url === "/api/auth/codex/state") return json(webAuth.codexState());
285
+ if (url === "/api/auth/codex/key" && post) return json(await webAuth.saveCodexApiKey(JSON.parse(body || "{}").key));
286
+ if (url === "/api/auth/codex/cancel" && post) return json(webAuth.cancelCodex());
287
+ return json({ error: "Unknown auth route" }, 404);
288
+ }
289
+
264
290
  // Status
265
291
  if (url === "/api/status") {
266
292
  const env = loadEnv();
@@ -489,13 +515,30 @@ async function handleAPI(req, res, body) {
489
515
 
490
516
  // ── HTML UI ────────────────────────────────────────────────────────
491
517
 
518
+ // The dashboard is generic "Open Claudia" by default, but each pod is its
519
+ // own named bot (e.g. euvy). Prefer an explicit BOT_NAME, else derive the
520
+ // pod name from its public hostname, else fall back to the generic brand.
521
+ function botDisplayName() {
522
+ if (process.env.BOT_NAME && process.env.BOT_NAME.trim()) return process.env.BOT_NAME.trim();
523
+ try {
524
+ const u = process.env.WEB_PUBLIC_URL;
525
+ if (u) {
526
+ const host = new URL(u).hostname;
527
+ const label = host.split(".")[0];
528
+ if (label) return label;
529
+ }
530
+ } catch (e) { /* ignore */ }
531
+ return "Open Claudia";
532
+ }
533
+
492
534
  function getHTML() {
535
+ const botName = botDisplayName();
493
536
  return `<!DOCTYPE html>
494
537
  <html lang="en">
495
538
  <head>
496
539
  <meta charset="UTF-8">
497
540
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
498
- <title>Open Claudia</title>
541
+ <title>${botName}</title>
499
542
  <style>
500
543
  * { margin: 0; padding: 0; box-sizing: border-box; }
501
544
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f0f0f; color: #e0e0e0; min-height: 100vh; }
@@ -570,7 +613,7 @@ async function init() {
570
613
  function showLogin() {
571
614
  $("#app").innerHTML = \`
572
615
  <div class="login-page"><div class="login-box">
573
- <h1>Open Claudia</h1>
616
+ <h1>${botName}</h1>
574
617
  <p class="subtitle">Enter your admin password</p>
575
618
  <div id="login-msg"></div>
576
619
  <div class="card">
@@ -625,6 +668,7 @@ function render() {
625
668
  { id: "dashboard", label: "Dashboard" },
626
669
  { id: "usage", label: "Usage" },
627
670
  { id: "auth", label: "Users" },
671
+ { id: "credentials", label: "Credentials" },
628
672
  { id: "soul", label: "Soul" },
629
673
  { id: "crons", label: "Crons" },
630
674
  { id: "sessions", label: "Sessions" },
@@ -637,7 +681,7 @@ function render() {
637
681
  }
638
682
  $("#app").innerHTML = \`
639
683
  <div class="container">
640
- <h1>Open Claudia</h1>
684
+ <h1>${botName}</h1>
641
685
  <p class="subtitle">v\${status.version}</p>
642
686
  <div class="tabs">
643
687
  \${tabs.map(t => \`<div class="tab \${currentTab===t.id?'active':''}" onclick="switchTab('\${t.id}')">\${t.label}</div>\`).join("")}
@@ -724,6 +768,34 @@ async function loadTab() {
724
768
  </div>\`).join("")}
725
769
  </div>\` : ""}\`;
726
770
  }
771
+ else if (currentTab === "credentials") {
772
+ el.innerHTML = \`
773
+ <div class="card" id="cred-status"><h2>Credentials</h2><p class="subtitle">Loading status…</p></div>
774
+ <div class="card">
775
+ <h2>Claude</h2>
776
+ <button onclick="startClaudeAuth()">Authenticate via browser</button>
777
+ <div id="claude-flow" style="margin-top:12px;"></div>
778
+ <div style="margin-top:16px;border-top:1px solid #2a2a2a;padding-top:16px;">
779
+ <label>Or paste a Claude OAuth token</label>
780
+ <div style="color:#777;font-size:11px;margin:2px 0 6px;">Generate with <code>claude setup-token</code> in a terminal if the browser flow can't finish here.</div>
781
+ <input id="claude-token" type="password" placeholder="sk-ant-...">
782
+ <button onclick="saveClaudeTokenWeb()" style="margin-top:8px;">Save token</button>
783
+ <span id="claude-token-msg" style="margin-left:10px;font-size:12px;color:#888;"></span>
784
+ </div>
785
+ </div>
786
+ <div class="card">
787
+ <h2>Codex</h2>
788
+ <button onclick="startCodexAuth()">Authenticate via device</button>
789
+ <div id="codex-flow" style="margin-top:12px;"></div>
790
+ <div style="margin-top:16px;border-top:1px solid #2a2a2a;padding-top:16px;">
791
+ <label>Or paste an OpenAI API key</label>
792
+ <input id="codex-key" type="password" placeholder="sk-...">
793
+ <button onclick="saveCodexKeyWeb()" style="margin-top:8px;">Save key</button>
794
+ <span id="codex-key-msg" style="margin-left:10px;font-size:12px;color:#888;"></span>
795
+ </div>
796
+ </div>\`;
797
+ loadCredStatus();
798
+ }
727
799
  else if (currentTab === "soul") {
728
800
  const soul = await api("/api/soul");
729
801
  if (!soul) return;
@@ -789,23 +861,44 @@ async function loadTab() {
789
861
  </div>
790
862
  <div class="card">
791
863
  <h2>Configuration</h2>
792
- <p style="color:#888;font-size:13px;margin-bottom:12px;">Edit ~/.open-claudia/.env values</p>
864
+ <p style="color:#888;font-size:13px;margin-bottom:16px;">Changes are saved to the bot's environment and take effect after the next restart.</p>
793
865
  <div id="config-fields"></div>
794
866
  <button onclick="saveConfig()">Save</button>
795
867
  <span id="config-msg" style="margin-left:12px;font-size:13px;color:#888;"></span>
796
868
  </div>\`;
797
869
  const config = await api("/api/config");
798
870
  if (!config) return;
799
- const safe = [
800
- "WORKSPACE", "CLAUDE_PATH", "WHISPER_CLI", "FFMPEG", "WHISPER_MODEL",
801
- "MEMORY_RECALL_MAX_CHARS",
802
- "USAGE_ALERT_CONTEXT_TOKENS", "USAGE_ALERT_RATE_MULTIPLIER",
803
- "USAGE_ALERT_BASELINE_TURNS", "USAGE_ALERT_MIN_BASELINE_TURNS",
804
- "USAGE_ALERT_COOLDOWN_MS",
871
+ const groups = [
872
+ { title: "General", fields: [
873
+ { k: "WORKSPACE", label: "Workspace folder", desc: "Root directory where the bot reads and writes project files.", ph: "/data/Workspace" },
874
+ { k: "CLAUDE_PATH", label: "Claude CLI path", desc: "Path or command name for the Claude Code CLI.", ph: "claude" },
875
+ ]},
876
+ { title: "Voice transcription", fields: [
877
+ { k: "WHISPER_CLI", label: "Whisper CLI", desc: "Path to the whisper binary for transcribing voice notes. Leave blank to disable voice.", ph: "whisper" },
878
+ { k: "FFMPEG", label: "ffmpeg path", desc: "Path to ffmpeg, used to convert incoming voice audio.", ph: "ffmpeg" },
879
+ { k: "WHISPER_MODEL", label: "Whisper model", desc: "Transcription model size, e.g. base / small / medium.", ph: "base" },
880
+ ]},
881
+ { title: "Memory", fields: [
882
+ { k: "MEMORY_RECALL_MAX_CHARS", label: "Memory recall limit", desc: "Max characters of memory injected into context per turn.", ph: "e.g. 6000" },
883
+ ]},
884
+ { title: "Usage alerts (advanced)", fields: [
885
+ { k: "USAGE_ALERT_CONTEXT_TOKENS", label: "Context-token threshold", desc: "Warn when a turn's context exceeds this many tokens.", ph: "e.g. 150000" },
886
+ { k: "USAGE_ALERT_RATE_MULTIPLIER", label: "Rate multiplier", desc: "Warn when usage exceeds the recent baseline by this factor.", ph: "e.g. 2" },
887
+ { k: "USAGE_ALERT_BASELINE_TURNS", label: "Baseline window (turns)", desc: "How many recent turns form the usage baseline.", ph: "e.g. 20" },
888
+ { k: "USAGE_ALERT_MIN_BASELINE_TURNS", label: "Min baseline turns", desc: "Minimum turns required before alerts activate.", ph: "e.g. 5" },
889
+ { k: "USAGE_ALERT_COOLDOWN_MS", label: "Alert cooldown (ms)", desc: "Minimum gap between repeated usage alerts.", ph: "e.g. 300000" },
890
+ ]},
805
891
  ];
806
- $("#config-fields").innerHTML = safe.map(k => \`
807
- <div class="form-group"><label>\${k}</label><input id="cfg-\${k}" value="\${config[k] || ""}"></div>
808
- \`).join("");
892
+ $("#config-fields").innerHTML = groups.map(g => \`
893
+ <div style="margin-bottom:20px;">
894
+ <div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:#9a9aff;border-bottom:1px solid #2a2a2a;padding-bottom:6px;margin-bottom:12px;">\${g.title}</div>
895
+ \${g.fields.map(f => \`
896
+ <div class="form-group">
897
+ <label>\${f.label}</label>
898
+ <div style="color:#777;font-size:11px;margin:2px 0 6px;">\${f.desc}</div>
899
+ <input id="cfg-\${f.k}" value="\${config[f.k] || ""}" placeholder="\${f.ph || ""}">
900
+ </div>\`).join("")}
901
+ </div>\`).join("");
809
902
  }
810
903
  }
811
904
 
@@ -878,6 +971,88 @@ async function removeAuth(chatId) { await api("/api/auth/remove", { method: "POS
878
971
  async function approveAuth(chatId) { await api("/api/auth/approve", { method: "POST", body: { chatId } }); loadTab(); }
879
972
  async function denyAuth(chatId) { await api("/api/auth/deny", { method: "POST", body: { chatId } }); loadTab(); }
880
973
 
974
+ async function loadCredStatus() {
975
+ const s = await api("/api/auth/status");
976
+ if (!s) return;
977
+ const yn = (b) => b ? "<span style='color:#22c55e'>yes</span>" : "<span style='color:#dc2626'>no</span>";
978
+ $("#cred-status").innerHTML = \`<h2>Credentials</h2>
979
+ <div class="list-item"><span>Claude OAuth token saved</span><span>\${yn(s.claude.configured)}</span></div>
980
+ <div class="list-item"><span>Claude logged in</span><span>\${yn(s.claude.loggedIn)}</span></div>
981
+ <div class="list-item"><span>Codex CLI present</span><span>\${yn(s.codex.cliFound)}</span></div>
982
+ <div class="list-item"><span>Codex logged in</span><span>\${yn(s.codex.loggedIn)}</span></div>\`;
983
+ }
984
+ async function startClaudeAuth() {
985
+ $("#claude-flow").innerHTML = "<p class='subtitle'>Starting…</p>";
986
+ await api("/api/auth/claude/start", { method: "POST" });
987
+ pollClaudeAuth();
988
+ }
989
+ async function pollClaudeAuth() {
990
+ const st = await api("/api/auth/claude/state");
991
+ if (!st) return;
992
+ const box = $("#claude-flow");
993
+ if (st.done) {
994
+ box.innerHTML = st.ok ? "<div class='msg ok'>Claude authenticated.</div>"
995
+ : (st.error === "tty" ? "<div class='msg err'>The Claude CLI needs a real terminal here. Paste an OAuth token below instead.</div>"
996
+ : "<div class='msg err'>Login didn't complete. Paste a token below instead.</div>");
997
+ loadCredStatus();
998
+ return;
999
+ }
1000
+ if (st.url) {
1001
+ box.innerHTML = \`<p style="font-size:13px;">Open this URL, sign in, then paste the code Claude gives you:</p>
1002
+ <p style="word-break:break-all;"><a href="\${st.url}" target="_blank">\${st.url}</a></p>
1003
+ <input id="claude-code" placeholder="Paste code here">
1004
+ <button onclick="submitClaudeCodeWeb()" style="margin-top:8px;">Submit code</button>\`;
1005
+ } else {
1006
+ box.innerHTML = "<p class='subtitle'>Starting…</p>";
1007
+ }
1008
+ setTimeout(pollClaudeAuth, 2000);
1009
+ }
1010
+ async function submitClaudeCodeWeb() {
1011
+ const code = $("#claude-code").value;
1012
+ await api("/api/auth/claude/code", { method: "POST", body: { code } });
1013
+ $("#claude-flow").innerHTML = "<p class='subtitle'>Submitting…</p>";
1014
+ setTimeout(pollClaudeAuth, 1500);
1015
+ }
1016
+ async function saveClaudeTokenWeb() {
1017
+ const token = $("#claude-token").value;
1018
+ const r = await api("/api/auth/claude/token", { method: "POST", body: { token } });
1019
+ $("#claude-token-msg").textContent = r && r.ok ? "Saved — restart to apply." : ((r && r.error) || "Failed");
1020
+ if (r && r.ok) { $("#claude-token").value = ""; loadCredStatus(); }
1021
+ }
1022
+ async function startCodexAuth() {
1023
+ $("#codex-flow").innerHTML = "<p class='subtitle'>Starting…</p>";
1024
+ const r = await api("/api/auth/codex/start", { method: "POST" });
1025
+ if (r && r.error) { $("#codex-flow").innerHTML = "<div class='msg err'>" + r.error + "</div>"; return; }
1026
+ pollCodexAuth();
1027
+ }
1028
+ async function pollCodexAuth() {
1029
+ const st = await api("/api/auth/codex/state");
1030
+ if (!st) return;
1031
+ const box = $("#codex-flow");
1032
+ if (st.done) {
1033
+ box.innerHTML = st.ok ? "<div class='msg ok'>Codex authenticated.</div>"
1034
+ : (st.error === "tty" ? "<div class='msg err'>The Codex CLI needs a real terminal here. Paste an API key below instead.</div>"
1035
+ : "<div class='msg err'>Login didn't complete. Paste an API key below instead.</div>");
1036
+ loadCredStatus();
1037
+ return;
1038
+ }
1039
+ if (st.url) {
1040
+ box.innerHTML = \`<p style="font-size:13px;">Open this URL and enter the code:</p>
1041
+ <p style="word-break:break-all;"><a href="\${st.url}" target="_blank">\${st.url}</a></p>
1042
+ \${st.code ? "<p>Device code: <b>" + st.code + "</b></p>" : ""}
1043
+ <p class='subtitle'>Waiting for you to finish in the browser…</p>\`;
1044
+ } else {
1045
+ box.innerHTML = "<p class='subtitle'>Starting…</p>";
1046
+ }
1047
+ setTimeout(pollCodexAuth, 2000);
1048
+ }
1049
+ async function saveCodexKeyWeb() {
1050
+ const key = $("#codex-key").value;
1051
+ const r = await api("/api/auth/codex/key", { method: "POST", body: { key } });
1052
+ $("#codex-key-msg").textContent = r && r.ok ? "Saved." : ((r && r.error) || "Failed");
1053
+ if (r && r.ok) { $("#codex-key").value = ""; loadCredStatus(); }
1054
+ }
1055
+
881
1056
  async function saveSoul() {
882
1057
  await api("/api/soul", { method: "POST", body: { content: $("#soul-content").value } });
883
1058
  $("#soul-msg").textContent = "Saved";
@@ -903,7 +1078,7 @@ async function saveConfig() {
903
1078
  function renderSetup() {
904
1079
  $("#app").innerHTML = \`
905
1080
  <div class="container">
906
- <h1>Open Claudia Setup</h1>
1081
+ <h1>${botName} Setup</h1>
907
1082
  <p class="subtitle">Configure your AI coding assistant</p>
908
1083
  <div id="setup-msg"></div>
909
1084
  <div class="card">