@inetafrica/open-claudia 3.5.19 → 3.5.23

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/.env.example CHANGED
@@ -74,12 +74,17 @@ USAGE_ALERT_COOLDOWN_MS=1800000
74
74
  # when a window's use passes these percentages, once per window (off = never).
75
75
  LIMIT_WARN_PERCENTS=80,95
76
76
  # A message a usage limit stopped is picked up when the limit resets, unless
77
- # the person writes again first: on (default) | off.
77
+ # the person writes again first: on (default) | off. Scheduled runs and the
78
+ # dream's AI step always wait for the reset.
78
79
  LIMIT_PICKUP=on
79
- # Seconds after the reset before it runs (default 120); a reset further out
80
- # than LIMIT_PICKUP_MAX_WAIT_H hours (default 192) is not waited for.
80
+ # Seconds after the reset before waiting work runs (default 120); a reset
81
+ # further out than LIMIT_PICKUP_MAX_WAIT_H hours (default 192) is not waited
82
+ # for. One rule for chat messages, scheduled runs and the dream.
81
83
  LIMIT_PICKUP_DELAY_S=120
82
84
  LIMIT_PICKUP_MAX_WAIT_H=192
85
+ # Messages sent while a conversation is busy run together as one turn when it
86
+ # ends (on, default); off runs them one at a time.
87
+ QUEUE_BATCH=on
83
88
  MEMORY_RECALL_MAX_CHARS=9000
84
89
  # Default recall engine when a chat hasn't picked one with /engine: classic | discoverer
85
90
  RECALL_ENGINE=classic
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.5.23
4
+
5
+ Usage limits as one rule, /stop as a real cancel, the queue's ✕ and run-together, and background checks on Haiku 5.5 and Sonnet 5.5 (Sumeet, 9 Oct 15:56 EAT: "yeh release and upgrade all the bots, include rahil for the updates going forward"). 3.5.20 below, the Browser app, only ever went out as a canary build, so this is the first release that brings it to everyone. 3.5.21 and 3.5.22 were tagged with the same changes but never published: 3.5.21's build stopped on the new /stop and queue test, which needed the fake agent its neighbours run on (CI has no Claude or Codex installed), and 3.5.22's builds stopped on two timing checks that busy build machines pushed past their limits (the boot matrix's 30 s per boot, the /stop test's 3 s and 2.5 s windows); those checks now allow more time.
6
+
7
+ Usage limits as one runtime rule (Sumeet, 9 Oct 2026 after the 07:00 dream failed on his bots: "it should use whatever the active model is … if the Claude model's out of usage … we can't assume that the other will be available … do the standard wake up, the delay until the bot's session has been replenished … the handling of session limits working as a foundational concept rather than just being hacks"). On 9 Oct the 07:00 dream's AI step failed on 18 of 24 fleet bots: Codex bots dreamed on a signed-out Claude, used-up limits failed instead of waiting, and Claude Code's refresh-lock race was final. No chats failed.
8
+
9
+ - **Work a usage limit stops waits for the reset, the same way everywhere.** One rule (`core/usage-limits.js` `limitRetryAt`: 2 minutes after the reset, `LIMIT_PICKUP_DELAY_S`; never a reset more than `LIMIT_PICKUP_MAX_WAIT_H`, 192 h, away) now serves a chat message (the 3.5.18 pick-up), a scheduled run and the dream. Never on the other provider: it may not be signed in, or be used up too.
10
+ - **A scheduled run a limit stops waits for the reset.** A wake-up, watch or cron that hit a limit used to retry every 30 s and give up after its attempts ("gave up and will not fire"). It now waits in the jobs store until the reset ("⏳ Claude's weekly limit is used up, so this scheduled run waits. I'll run it at 11:02, when it resets."), without using up an attempt; a cron's ticks meanwhile start nothing; the run after the reset is told it waited, and a reset that is used up again waits again, at most 5 times.
11
+ - **The dream's AI step waits for the reset.** The automatic filing and graph upkeep run on time; the AI-led merges and self-review run after the reset ("⏳ Codex's weekly limit is used up, so the AI-led merges wait for its reset: I'll run them tomorrow at 11:02."), on a timer kept in the dream state, so a restart keeps it. A sign-in that died says so plainly ("Claude's sign-in on this bot has expired (send /login to sign in again)").
12
+ - **Background work with nobody behind it runs on the bot's own provider.** The dream, introspection and other checks outside any chat now use the provider the owner chats on (`/backend`, `/login`), else the one a new chat starts on. They used to take the first provider that did not look signed out, which on a Codex bot was a signed-out Claude.
13
+ - **A signed-out Claude reads as signed out.** `claude auth status` prints `{"loggedIn": false}` (Claude Code 2.1), which no check matched, so the bot read it as "unknown" and still chose Claude.
14
+ - **One reading of a provider's failure for chats and background calls** (`providerFailureCode`). Background calls now see a plan limit as `PROVIDER_USAGE_LIMIT` with its reset time (it was a plain `UTILITY_PROVIDER_ERROR`). A dead sign-in ("Failed to authenticate: OAuth session expired and could not be refreshed", "OAuth access token is invalid", Codex's "Your authentication token has expired") is a sign-in error in chats and background calls alike, so a chat gets the /login help instead of a raw error. Claude Code's refresh-lock race ("another Claude Code process is refreshing it") gets one more try, 20 s later, in work nobody waits on (the dream, memory review, sub-agents); a chat's own checks go without rather than hold the reply up.
15
+ - **A limit is recorded for every waiter.** Any run a limit stops (a chat turn, a scheduled run, a background call) records the provider as used up until its reset (`usage-limits.json` `blocks`); background calls do not start it until then, and any run on it that works clears the record. Chat turns never read the record: they always try, so the provider stays the judge. A weekly Opus limit stops only Opus calls.
16
+ - **Tests:** `test-limit-waits.js` (new: background calls through the fake Claude CLI, scheduled runs, the dream and its timer, provider choice with nobody behind a call, the signed-out status), `test-usage-limits.js` (the rule and the record), `test-provider-events.js` (Claude Code 2.1.280's own sign-in and refresh-race wording); the fake agent CLI gains a `provider-error` scenario (`FAKE_AGENT_ERROR_TEXT`, `FAKE_AGENT_ERROR_ONCE_FILE`).
17
+
18
+ /stop and the queue (Sumeet, 9 Oct 14:18 EAT, with a screenshot of home pi: "it doesn't seem to understand i cancelled something … one for a queued message a small card with delete queued … i can delete message 2 and 4 arbitrarily from the queue … when the turn finishes … get all the messages that are queued at once? no point of them coming in series is there?").
19
+
20
+ - **A /stop is a cancel, not a cut.** /stop kills the provider mid-turn. On the next message Claude Code resumes the session and writes the stopped turn into the history the way a restart's cut reads ("[Request interrupted by user for tool use]", then "Continue from where you left off."), and the system prompt (since 3.5.11) says to finish work a restart cut off. So the stopped request came back: home pi answered the message after "/stop" on "ok play big bang" with "Big Bang didn't start … Just say if you still want it." /stop now marks the conversation in its turn-journal file (kept across a restart), and the conversation's next turn opens with a note that the person stopped that request and it is cancelled; the system prompt says so too. A stop before the provider started, or of a compaction, leaves no note; `/new`, `/end` and a project change drop it.
21
+ - **Each "Queued." notice has a ✕ Remove from queue button.** It takes that one message out of the queue, so of four queued messages the second and fourth can go while the others stay ("Removed from the queue: "…""). Only the person who sent it, or the owner, can remove it; a message that already started says so.
22
+ - **Queued messages run together.** When a turn ends, the messages the person sent meanwhile run as one turn ("3 messages arrived while you were busy … where a later one changes or cancels an earlier one, the later one wins"), in order, with every image and file, the reply answering the newest. They used to run one turn each, in series, each without the others. Only the same person's plain chat messages in the same session, project, provider and model join (a scheduled run, a side chat's follow-up, a limit pick-up and a live call run on their own), at most 10 per turn. `QUEUE_BATCH=off` runs them one at a time again.
23
+ - **Tests:** `test-stop-queue.js` (new: the note after a /stop, once, kept on disk, not after `/new` or a stop before the provider started; three queued messages as one turn with no journal leftovers; ✕ on two of four; who may press; what joins a batch), `test-turn-journal.js` (the stop survives a restart's boot pass); the fake agent CLI logs every `[[tag:…]]` in a prompt and `FAKE_AGENT_PROMPT_PROBES` matches.
24
+
25
+ Background checks on Claude Haiku 5.5 and Sonnet 5.5 (Sumeet, 9 Oct 14:51 EAT: "check if anything using haiku and have it shifted to the new haiku 5.5", then 15:36: "sonnet should also be just 5.5").
26
+
27
+ - **The low tier is Claude Haiku 5.5** (`claude-haiku-5-5`, was `claude-haiku-4-5-20251001`): the recall router and its warm walker, the recall filter, passive group replies, thread titles, the memory duplicate check and the agency deliberation. List price $0.10 / $0.50 per million tokens for prompts up to 100,000 tokens ($0.50 / $2.50 above), against $1 / $5 for Haiku 4.5. Checked live through the utility agent and Claude Code 2.1.280: a structured duplicate-check reply and a plain thread title, about 5 s each. The /model picker offers Haiku 5.5 instead of 4.5; a typed `/model claude-haiku-4-5-20251001` still works.
28
+ - **The medium tier is Claude Sonnet 5.5** (`claude-sonnet-5-5`, was `claude-sonnet-4-6`): the post-turn memory reviewer, the tool-rule guard and `open-claudia agent` sub-agents. List price $2 / $10 per million tokens, cache reads $0.10, against $3 / $15 and $0.30 for Sonnet 4.6; Sonnet 5.5 uses the newer tokenizer (about 30% more tokens for the same text), so a call costs roughly 15% less on fresh input and output and over half less on cache reads. The /model picker offers Sonnet 5.5 instead of 4.6; a typed `/model claude-sonnet-4-6` still works. A weekly Sonnet limit still stops only Sonnet work.
29
+
30
+ ## 3.5.20
31
+
32
+ The Browser app in Kazee Chat, bot apps v1 (Sumeet, 9 Oct 12:02 EAT: "fine go ahead and push but also ensure you clear the images after they all done"). Kazee Chat opens it through Agent Space, which calls these pod routes with the control token.
33
+
34
+ - **A person can open the bot's browser from their direct chat.** `POST /api/apps/check` lets the Kazee owner or a chat on auth.json's authorized list open the `browser` app. After its slash commands, the Kazee adapter registers the app with chat-central at `AGENTSPACE_API_URL` + `/apps/browser`. A non-https or in-cluster address is skipped. `OC_BROWSER_APP=off` withdraws the app.
35
+ - **Take over and Hand back.** A driver lock (`browser-driver.json`, 0600) lasts 120 s from the last input, and a second person gets 409. Input events are checked field by field and relayed to the agent-browser stream. A hand-back wakes the bot once in that person's chat. While someone drives, the tool guard refuses the bot's own `agent-browser` commands, except reads.
36
+ - **Teach me.** It records clicks (with the element and a picture), typing per field, keys and scrolls. Characters typed into password or one-time-code fields are never kept. On Stop the bot is woken to post the steps, do one watched run, save the skill, and then run `open-claudia teach finish <id>`, which deletes the pictures and keeps the text. `teach cancel` deletes everything. A sweeper runs at boot and hourly: it deletes pictures after 24 h, stops abandoned recordings after 2 h, and removes recordings after 7 days.
37
+ - **Cards** pass `open: { app, label }` through. The system prompt tells bots that offer the app to send an Open Browser card when they need a person, and never to ask for passwords in chat.
38
+ - **Tests:** `test-browser-control.js`, `test-browser-teach.js`, `test-kazee-bot-apps.js` and `test-web-browser-app.js`, all in `npm test`.
39
+
3
40
  ## 3.5.19
4
41
 
5
42
  The memory duplicate-check fix as its own release (Sumeet, 8 Oct 13:08 EAT: "I'd rather just push out another release now rather than waiting for another day or so"). Everything in 3.5.18 below comes with it.
package/README.md CHANGED
@@ -346,7 +346,7 @@ Unsupported optional controls return a provider-specific explanation and do not
346
346
 
347
347
  ## Utility provider policy
348
348
 
349
- Background intelligence—sub-agents, recall, memory review, dream/introspection, and the external-person enforcer—uses one provider-selection policy. A per-purpose override is checked first, then `UTILITY_PROVIDER` (default `active`), the active foreground provider when a chat exists, and `DEFAULT_PROVIDER` for global jobs. Providers translate the neutral `low`, `medium`, and `high` tiers to their own models; exact per-provider model overrides remain available.
349
+ Background intelligence—sub-agents, recall, memory review, dream/introspection, and the external-person enforcer—uses one provider-selection policy. A per-purpose override is checked first, then `UTILITY_PROVIDER` (default `active`): the speaker's provider when a chat exists, and for work with nobody behind it (the dream, introspection) the provider the owner chats on, else `DEFAULT_PROVIDER`, else the one a new chat starts on. A provider a usage limit has used up is not started for background work until its reset; such work waits for the reset (the dream) or goes without (a recall check), and never moves to the other provider. Providers translate the neutral `low`, `medium`, and `high` tiers to their own models; exact per-provider model overrides remain available.
350
350
 
351
351
  Foreground turns never silently fall back to a different provider. Utility work uses `PROVIDER_FALLBACKS` only when the operator explicitly supplies an ordered list, starts a fresh provider session, and records the provider/model used. The relationship enforcer may try that configured list, but total failure always fails closed and asks the owner to decide.
352
352
 
@@ -455,7 +455,8 @@ All stored in `~/.open-claudia/`:
455
455
  | `USAGE_ALERT_BASELINE_TURNS` / `USAGE_ALERT_MIN_BASELINE_TURNS` / `USAGE_ALERT_COOLDOWN_MS` | No | Tune token-rate baseline size, minimum sample size, and alert cooldown |
456
456
  | `LIMIT_WARN_PERCENTS` | No | Plan-limit warnings in the chat: comma-separated percentages of a 5-hour or weekly window (default `80,95`, `off` disables). Each is said once per window, after the reply that crossed it; never to an outside contact |
457
457
  | `LIMIT_PICKUP` | No | `on` (default) or `off`. A message a Claude or Codex usage limit stopped is picked up when the limit resets; the person writing again in that conversation, `/stop`, `/new` or `/end` drops it, and a new limit hit arms it again |
458
- | `LIMIT_PICKUP_DELAY_S` / `LIMIT_PICKUP_MAX_WAIT_H` | No | Seconds after the reset before the pick-up runs (default `120`); resets further out than this many hours (default `192`) are not waited for |
458
+ | `LIMIT_PICKUP_DELAY_S` / `LIMIT_PICKUP_MAX_WAIT_H` | No | Seconds after the reset before waiting work runs (default `120`); resets further out than this many hours (default `192`) are not waited for. One rule for a picked-up message, a scheduled run a limit stopped (it waits instead of failing) and the dream's AI step |
459
+ | `QUEUE_BATCH` | No | `on` (default) or `off`. Messages a person sends while their conversation is busy run together as one turn when it ends (the same person's plain chat messages, at most 10); `off` runs them one at a time. Each "Queued." notice has a ✕ Remove from queue button either way |
459
460
  | `MEMORY_RECALL_MAX_CHARS` | No | Hard cap for auto-injected pack/entity memory per turn (default `9000`, `off` disables auto recall injection) |
460
461
  | `RECALL_ENGINE` | No | Default recall engine when a chat hasn't set one via `/engine` (`classic` or `discoverer`, default `discoverer`) |
461
462
  | `RECALL_GRAPH_DB` / `RECALL_METRICS` | No | Override the discoverer graph DB path; `off` on metrics disables per-turn recall logging |
@@ -468,11 +469,11 @@ All stored in `~/.open-claudia/`:
468
469
  | `TRANSCRIPT_MAX_ENTRY_CHARS` | No | Max chars per transcript entry (default `12000`) |
469
470
  | `TRANSCRIPTS_DIR` / `PACKS_DIR` / `ENTITIES_DIR` | No | Override storage directories |
470
471
  | `PACK_REVIEW` | No | `off` disables the post-turn pack/entity reviewer (default on) |
471
- | `PACK_REVIEW_MODEL` | No | Model for the reviewer (default `haiku`) |
472
+ | `PACK_REVIEW_MODEL` | No | Model for the reviewer (default: the provider's medium tier, `claude-sonnet-5-5` on Claude). Background checks without a model of their own use the low tier: `claude-haiku-5-5` on Claude |
472
473
  | `PACK_MATCH_THRESHOLD` / `ENTITY_MATCH_THRESHOLD` | No | Router match score thresholds (default `2`) |
473
474
  | `DREAM` | No | `off` disables the nightly memory consolidation pass (default on) |
474
475
  | `DREAM_CRON` | No | Schedule for the dream pass (default `0 4 * * *`) |
475
- | `DREAM_PROVIDER` | No | Provider for dream and introspection (`claude` or `codex`); otherwise scheduled work resolves through `DEFAULT_PROVIDER` |
476
+ | `DREAM_PROVIDER` | No | Provider for dream and introspection (`claude` or `codex`); otherwise the provider the owner chats on, then `DEFAULT_PROVIDER` |
476
477
  | `DREAM_MODEL_CLAUDE` / `DREAM_MODEL_CODEX` | No | Exact per-provider dream model override; legacy `DREAM_MODEL` remains a Claude-only compatibility alias |
477
478
  | `DREAM_TIER` | No | Provider-owned model tier for the dream pass: `low`, `medium`, or `high` (default) |
478
479
  | `DREAM_FULL_SWEEP_DAYS` | No | Days between full-corpus dream sweeps; other nights are cheap deltas over recently-touched packs (default `30`) |
package/bin/cli.js CHANGED
@@ -452,6 +452,11 @@ switch (command) {
452
452
  require("./cards").runDecide(args.slice(1));
453
453
  break;
454
454
  }
455
+ case "teach": {
456
+ Promise.resolve(require("./teach").run(args.slice(1)))
457
+ .catch((e) => { console.error(`teach command failed: ${e.message}`); process.exitCode = 1; });
458
+ break;
459
+ }
455
460
  case "schedule-wakeup": {
456
461
  require("./schedule").runScheduleWakeup(args.slice(1));
457
462
  break;
@@ -623,6 +628,7 @@ Send tools (only work inside an active bot-spawned task):
623
628
  open-claudia card send <spec|-> Send a Kazee Card (decision, choice, rating, slider, report); answers wake this conversation
624
629
  open-claudia card update <id> ... Close/open a card you sent, add a note, or replace its spec (card example <kind> for specs)
625
630
  open-claudia decide "<q>" --option id="label" ... Queue a decision card for the owner (one at a time; decide list|cancel)
631
+ open-claudia teach list|show|finish|cancel <id> Browser recordings from Teach me; finish deletes the pictures, cancel the whole recording
626
632
 
627
633
  Memory tools:
628
634
  open-claudia transcript-search <query> Ranked FTS5 search over indexed transcripts
package/bin/teach.js ADDED
@@ -0,0 +1,78 @@
1
+ // `open-claudia teach ...`: the bot's side of Teach me recordings (core/browser-teach.js). The
2
+ // bot is woken with a recording, posts its steps, checks them with the person, saves the skill,
3
+ // and then deletes the pictures with `teach finish`.
4
+ //
5
+ // Whose recordings a call may use is decided by the RUNNING BOT (loopback whoami over the run's
6
+ // attestation), never by this process's env: an owner turn sees every recording, anyone else's
7
+ // turn only their own, and a process with no live run behind it none.
8
+
9
+ const { whoami } = require("./loopback-client");
10
+
11
+ const HELP = `Usage:
12
+ open-claudia teach list Browser recordings (Teach me) you may see, newest first
13
+ open-claudia teach show <id> The steps as text
14
+ open-claudia teach finish <id> Delete the pictures, keep the text steps (once the skill is saved)
15
+ open-claudia teach cancel <id> Delete the whole recording
16
+ An owner turn sees every recording; anyone else's turn sees only their own.`;
17
+
18
+ function fail(message, code = 1) {
19
+ console.error(message);
20
+ process.exitCode = code;
21
+ }
22
+
23
+ async function run(args, { caller = null } = {}) {
24
+ const teach = require("../core/browser-teach");
25
+ const sub = args[0];
26
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
27
+ console.log(HELP);
28
+ return;
29
+ }
30
+ if (!["list", "show", "finish", "cancel"].includes(sub)) return fail(HELP, 2);
31
+ let who = caller;
32
+ if (!who) {
33
+ try { who = await whoami(); } catch (_) { who = null; }
34
+ }
35
+ if (!who || who.attributed !== true) {
36
+ return fail("⛔ teach: this process is not attributed to a chat turn of this bot, so it cannot tell whose recordings it may use. Run it from the bot's turn.", 3);
37
+ }
38
+ const userId = who.owner === true ? null : String(who.userId || "");
39
+ if (userId === "") return fail("⛔ teach: this turn has no person to match recordings to.", 3);
40
+
41
+ if (sub === "list") {
42
+ const list = teach.listRecordings({ userId });
43
+ if (!list.length) {
44
+ console.log("No recordings.");
45
+ return;
46
+ }
47
+ for (const r of list) {
48
+ const pictures = r.picturesDeleted ? "pictures deleted" : "pictures kept";
49
+ console.log(`${r.id} ${String(r.status).padEnd(9)} ${String(r.steps).padStart(3)} steps ${pictures.padEnd(16)} ${r.startedAt} "${teach.cleanText(r.title, 80).replace(/"/g, "'")}"`);
50
+ }
51
+ return;
52
+ }
53
+
54
+ const id = args[1];
55
+ if (!teach.validId(id)) return fail(`teach ${sub}: give a recording id (open-claudia teach list)`, 2);
56
+ const rec = teach.getRecording(id, { userId });
57
+ if (!rec) return fail(`teach ${sub}: no recording ${id} that this turn may use`);
58
+
59
+ if (sub === "show") {
60
+ console.log(teach.formatRecording(rec));
61
+ return;
62
+ }
63
+ if (sub === "finish") {
64
+ const result = teach.finishRecording(id);
65
+ if (!result.ok) {
66
+ return fail(result.error === "still-recording"
67
+ ? `teach finish: ${id} is still recording; it can be finished once it is stopped`
68
+ : `teach finish: no recording ${id}`);
69
+ }
70
+ console.log(`Finished ${id}: ${result.removed} picture${result.removed === 1 ? "" : "s"} deleted, ${result.recording.steps.length} text steps kept.`);
71
+ return;
72
+ }
73
+ const result = teach.cancelRecording(id);
74
+ if (!result.ok) return fail(`teach cancel: no recording ${id}`);
75
+ console.log(`Cancelled ${id}: the recording and its pictures are deleted.`);
76
+ }
77
+
78
+ module.exports = { run, HELP };
package/bot.js CHANGED
@@ -275,6 +275,8 @@ async function safeModeBoot() {
275
275
  console.error(`Adapter ${adapter.id} failed to start in safe mode:`, e.message);
276
276
  }
277
277
  }
278
+ // Teach-me pictures are still deleted on schedule: it only removes files, never runs a turn.
279
+ try { require("./core/browser-teach").startRetentionSweeper(); } catch (e) { console.error("Teach-me retention sweeper start failed:", e.message); }
278
280
  const alert = safeBoot.formatSafeModeAlert({ version: CURRENT_VERSION });
279
281
  console.error(alert);
280
282
  const tg = adapters.find((a) => a.type === "telegram");
@@ -391,6 +393,11 @@ async function safeModeBoot() {
391
393
  // Kazee Cards: snoozed owner decisions are asked again when due.
392
394
  try { require("./core/cards").startDecisionPump(registry); } catch (e) { console.error("Decision pump start failed:", e.message); }
393
395
 
396
+ // Browser app: a driver whose panel went quiet hands the browser back (30 s sweep), and
397
+ // Teach-me pictures and old recordings are deleted on schedule (boot + hourly).
398
+ try { require("./core/browser-control").startSweeper(); } catch (e) { console.error("Browser lock sweeper start failed:", e.message); }
399
+ try { require("./core/browser-teach").startRetentionSweeper(); } catch (e) { console.error("Teach-me retention sweeper start failed:", e.message); }
400
+
394
401
  // This incarnation counts as healthy once it survives the stable window —
395
402
  // resets the crash-loop streak the safe-mode gate counts at boot.
396
403
  setTimeout(() => { try { safeBoot.markBootStable(); } catch (e) {} }, safeBoot.STABLE_UPTIME_MS).unref();
@@ -1750,6 +1750,36 @@ class KazeeAdapter {
1750
1750
  } catch (e) {
1751
1751
  console.error("Kazee registerCommands failed:", e.message);
1752
1752
  }
1753
+ await this.registerApps();
1754
+ }
1755
+
1756
+ // Bot apps (chat-central PUT /bot/:botId/apps, the same shape as commands): with Agent Space
1757
+ // configured, the Browser app, opened from the Apps button of a direct chat with this bot or a
1758
+ // card's Open button (core/bot-apps.js). OC_BROWSER_APP=off sends an empty list. An
1759
+ // AGENTSPACE_API_URL that is not a public https address is skipped with one log line (chat-central
1760
+ // would refuse it). An older chat-central answers 404; that, or any failure, is logged once and
1761
+ // otherwise ignored.
1762
+ async registerApps(env = process.env) {
1763
+ const { apps, skip } = require("../../core/bot-apps").registrationPlan(env);
1764
+ if (skip && !this._appsSkipLogged) {
1765
+ this._appsSkipLogged = true;
1766
+ console.error(`Kazee registerApps skipped: ${skip}. The Browser app stays hidden.`);
1767
+ }
1768
+ if (!apps || !this.botUserId) return null;
1769
+ try {
1770
+ const res = await this._request("PUT", `/bot/${this.botUserId}/apps`, { apps });
1771
+ if (apps.length) console.log(`Kazee bot apps registered: ${apps.map((a) => a.key).join(", ")}`);
1772
+ return res;
1773
+ } catch (e) {
1774
+ if (!this._appsFailureLogged) {
1775
+ this._appsFailureLogged = true;
1776
+ const old = / → 404:/.test(String(e.message || ""));
1777
+ console.error(old
1778
+ ? "Kazee registerApps: this chat-central has no bot apps yet (404); the Browser app stays hidden."
1779
+ : `Kazee registerApps failed (the Browser app stays hidden): ${String(e.message || e).slice(0, 300)}`);
1780
+ }
1781
+ return null;
1782
+ }
1753
1783
  }
1754
1784
 
1755
1785
  // ── HTTP helpers ────────────────────────────────────────────────
package/core/actions.js CHANGED
@@ -918,6 +918,10 @@ async function handleAction(envelope) {
918
918
  if (d.startsWith("rq:")) {
919
919
  return require("./turn-journal").handleRunDropped(d, envelope, { isOwner: isChatOwner(envelope.channelId) });
920
920
  }
921
+ // The ✕ on a "Queued." notice: take that message out of the queue.
922
+ if (d.startsWith("qx:")) {
923
+ return require("./runner").handleQueueRemove(d, envelope, { isOwner: isChatOwner(envelope.channelId) });
924
+ }
921
925
  if (d.startsWith("grd:")) {
922
926
  // Guardrail capture buttons (Track C). Owner-only: standing rules layer
923
927
  // above lessons and are never editable by an external speaker.
package/core/auth-flow.js CHANGED
@@ -109,7 +109,12 @@ function looksLikeOpenAIKey(value) {
109
109
 
110
110
  function isClaudeAuthErrorText(text) {
111
111
  const lower = String(text || "").toLowerCase();
112
- return lower.includes("unauthorized") ||
112
+ // `claude auth status` prints JSON in Claude Code 2.1: signed out it is
113
+ // {"loggedIn": false, "authMethod": "none"} (exit 1), which no phrase below
114
+ // matched, so a signed-out Claude read as "unknown" and was still chosen
115
+ // (9 Oct 2026: the dream of every Codex-only bot ran on Claude).
116
+ return /"loggedin"\s*:\s*false/.test(lower) ||
117
+ lower.includes("unauthorized") ||
113
118
  lower.includes("not logged in") ||
114
119
  lower.includes("login required") ||
115
120
  lower.includes("reauthenticate") ||
@@ -145,7 +150,7 @@ function summarizeAuthOutput(output, ok) {
145
150
  function summarizeClaudeAuthStatus(output, exitCode, tokenInfo) {
146
151
  const text = String(output || "");
147
152
  const lower = text.toLowerCase();
148
- const loggedOut = /not (logged in|authenticated)|unauthenticated|no auth|login required/.test(lower);
153
+ const loggedOut = /not (logged in|authenticated)|unauthenticated|no auth|login required|"loggedin"\s*:\s*false/.test(lower);
149
154
  const loggedIn = !loggedOut && (exitCode === 0 || /logged in|authenticated|claude\.ai|anthropic/.test(lower));
150
155
  const email = (text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i) || [null])[0];
151
156
  const provider = /claude\.ai|claudeai/.test(lower) ? "Claude.ai" : (/anthropic/.test(lower) ? "Anthropic" : "unknown");
@@ -0,0 +1,133 @@
1
+ // Bot apps in Kazee Chat (SPEC "Bot apps v1", contracts C1 and C8).
2
+ //
3
+ // The bot registers the apps it offers with chat-central (PUT /bot/:botId/apps, done by the
4
+ // Kazee adapter after its slash commands), and Kazee Chat shows them behind an Apps button in
5
+ // a direct chat with the bot. Opening one goes through Agent Space (the front door), which asks
6
+ // this bot, over the control token, whether that person may use it: checkAccess() below.
7
+ //
8
+ // v1 has one app, "browser": the bot's own agent-browser, shown live, with Take over and
9
+ // Teach me (core/browser-control.js, core/browser-teach.js). OC_BROWSER_APP=off withdraws it:
10
+ // the next registration sends an empty list and every check answers "not allowed".
11
+
12
+ const BROWSER_APP_KEY = "browser";
13
+ const ID_RE = /^[A-Za-z0-9._:-]{1,100}$/;
14
+
15
+ function browserAppEnabled(env = process.env) {
16
+ return String(env.OC_BROWSER_APP || "").trim().toLowerCase() !== "off";
17
+ }
18
+
19
+ // An address only reachable inside the cluster or this machine (a Service name such as
20
+ // agentspace-backend.<ns>.svc, localhost, an IP): no person's Kazee Chat can open a page there.
21
+ function internalHost(hostname) {
22
+ const host = String(hostname || "").toLowerCase().replace(/\.$/, "");
23
+ if (!host.includes(".") || host.startsWith("[")) return true; // a bare Service name, or IPv6
24
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
25
+ return /(^|\.)(localhost|local|internal|svc|cluster\.local)$/.test(host);
26
+ }
27
+
28
+ // Why AGENTSPACE_API_URL cannot carry the page, or "" when it can. chat-central accepts only
29
+ // https addresses on its allowed origins, so anything else would be refused there anyway.
30
+ function browserAppUrlProblem(apiUrl) {
31
+ let u;
32
+ try { u = new URL(String(apiUrl || "").trim()); } catch (_) { return "it is not a URL"; }
33
+ if (u.protocol !== "https:") return "it is not https";
34
+ if (internalHost(u.hostname)) return "it is an in-cluster address";
35
+ return "";
36
+ }
37
+
38
+ // The page lives on Agent Space's API host: <AGENTSPACE_API_URL without trailing slash> +
39
+ // "/apps/browser". Every live pod has AGENTSPACE_API_URL=https://api.agentspace.kazee.africa/api,
40
+ // so the page is https://api.agentspace.kazee.africa/api/apps/browser. A bare origin means
41
+ // "/api", the same path base the pod's other Agent Space calls use (core/cluster-client.js).
42
+ function browserAppUrl(apiUrl) {
43
+ if (browserAppUrlProblem(apiUrl)) return null;
44
+ const u = new URL(String(apiUrl).trim());
45
+ const base = u.pathname.replace(/\/+$/, "") || "/api";
46
+ return `${u.origin}${base}/apps/browser`;
47
+ }
48
+
49
+ // What to do with chat-central, and why not:
50
+ // { apps: null } nothing (no Agent Space, so no page to open)
51
+ // { apps: null, skip: <why> } nothing: AGENTSPACE_API_URL is not a public https address
52
+ // { apps: [] } withdraw (OC_BROWSER_APP=off)
53
+ // { apps: [<browser>] } offer the Browser app
54
+ function registrationPlan(env = process.env) {
55
+ const raw = String(env.AGENTSPACE_API_URL || "").trim();
56
+ if (!raw) return { apps: null, skip: null };
57
+ if (!browserAppEnabled(env)) return { apps: [], skip: null };
58
+ const problem = browserAppUrlProblem(raw);
59
+ if (problem) {
60
+ let origin = "";
61
+ try { origin = new URL(raw).origin; } catch (_) { origin = ""; }
62
+ const shown = origin && origin !== "null" ? ` (${origin})` : "";
63
+ return { apps: null, skip: `AGENTSPACE_API_URL${shown} cannot carry the Browser page: ${problem}, and chat-central only accepts public https app addresses` };
64
+ }
65
+ return { apps: [{ key: BROWSER_APP_KEY, title: "Browser", url: browserAppUrl(raw), icon: "globe" }], skip: null };
66
+ }
67
+
68
+ // What to register with chat-central: null = do nothing, [] = withdraw, else the Browser app.
69
+ function appsToRegister(env = process.env) {
70
+ return registrationPlan(env).apps;
71
+ }
72
+
73
+ function ownerUserId() {
74
+ try {
75
+ const value = require("./config").config.KAZEE_OWNER_USER_ID;
76
+ if (value) return String(value);
77
+ } catch (_) { /* fall back to the process env */ }
78
+ return String(process.env.KAZEE_OWNER_USER_ID || "");
79
+ }
80
+
81
+ function ownerName() {
82
+ try {
83
+ const owner = (require("./people").owners() || [])[0];
84
+ return owner && owner.name ? String(owner.name) : "";
85
+ } catch (_) {
86
+ return "";
87
+ }
88
+ }
89
+
90
+ // The authorized list of auth.json, read under its store lock so a roster change in flight is
91
+ // never half seen (core/access.js mutateAuth holds the same lock).
92
+ function authorizedEntries() {
93
+ const { loadAuth } = require("./access");
94
+ const { withFileLock } = require("./fsutil");
95
+ const { AUTH_FILE } = require("./config");
96
+ return withFileLock(AUTH_FILE, () => loadAuth().authorized);
97
+ }
98
+
99
+ // C1: may this person open this app in this direct chat? Agent Space has already checked with
100
+ // chat-central that `userId` is the person in the direct chat `chatId` with this bot, so:
101
+ // - the Kazee owner (KAZEE_OWNER_USER_ID) is allowed, role "owner";
102
+ // - a chat in auth.json's authorized list is allowed, role "user" ("owner" when that entry is
103
+ // an owner entry), with the name the roster has for it;
104
+ // - anyone else, and any app other than "browser", is not.
105
+ function checkAccess({ app, chatId, userId } = {}) {
106
+ const no = { allowed: false, role: null, name: null };
107
+ if (app !== BROWSER_APP_KEY || !browserAppEnabled()) return no;
108
+ const chat = String(chatId || "");
109
+ const user = String(userId || "");
110
+ if (!ID_RE.test(chat) || !ID_RE.test(user)) return no;
111
+ let entries = [];
112
+ try { entries = authorizedEntries(); } catch (_) { entries = []; }
113
+ const entry = entries.find((a) => a && String(a.chatId) === chat) || null;
114
+ const owner = ownerUserId();
115
+ if (owner && user === owner) {
116
+ return { allowed: true, role: "owner", name: String((entry && entry.name) || ownerName() || "") };
117
+ }
118
+ if (entry) {
119
+ return { allowed: true, role: entry.isOwner === true ? "owner" : "user", name: String(entry.name || "") };
120
+ }
121
+ return no;
122
+ }
123
+
124
+ module.exports = {
125
+ BROWSER_APP_KEY,
126
+ ID_RE,
127
+ appsToRegister,
128
+ browserAppEnabled,
129
+ browserAppUrl,
130
+ browserAppUrlProblem,
131
+ checkAccess,
132
+ registrationPlan,
133
+ };