@inetafrica/open-claudia 3.0.18 → 3.0.20
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 +14 -0
- package/README.md +9 -8
- package/channels/spaces/adapter.js +8 -2
- package/core/actions.js +4 -9
- package/core/handlers.js +36 -61
- package/core/identity.js +4 -0
- package/core/onboarding.js +1 -3
- package/core/router.js +0 -9
- package/core/state.js +12 -3
- package/package.json +1 -1
- package/test-provider-language.js +8 -9
- package/test-provider-session-commands.js +1 -1
- package/core/projects.js +0 -43
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.0.20 — Spaces answers tags, and knows the owner
|
|
4
|
+
|
|
5
|
+
- **A tag now actually gets a reply.** The Spaces server labels notifications with **plural** categories (`mentions`, `comments`, `reactions`, `task_updated`), but the conversational plane's engage set only matched the singular `mention` — so every real `@`-tag was silently downgraded to awareness-only and the bot never spoke. `ENGAGE_TYPES` now matches the plural forms (`mentions`/`replies`, singular aliases kept for safety). Plain comments, reactions and task updates stay awareness-only, so the "untagged comments hit the radar without auto-answering" rule is unchanged. Confirmed live: a real `mentions` notification was arriving and being dropped.
|
|
6
|
+
- **The owner is recognised on Spaces.** Spaces principals are central/Kazee user ids, but `isConfiguredOwnerChannel` had no `spaces` branch, so the owner's own comments resolved to a fresh `spaces:<id>` canonical instead of their owner identity — classing the owner as **external** (default-deny) on every Spaces task. Added a `spaces` branch mapping to `KAZEE_OWNER_USER_ID` (the same id Spaces/Kazee/central share), so the owner is recognised, their Spaces turns unify into their one canonical brain, and the relationship guardrail no longer misfires on them.
|
|
7
|
+
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically (FROM the pre-baked `:base`). Existing pods pick this up on their next approved upgrade.
|
|
8
|
+
|
|
9
|
+
## v3.0.19 — flattened to the top level (the folder-project model is gone)
|
|
10
|
+
|
|
11
|
+
- **The bot always runs at the top-level workspace now — no more "Pick a project first."** The per-folder session model (pick a subfolder of `WORKSPACE`, scope the conversation to it) has been removed. `currentSession` defaults to the workspace root (`{ name: "Workspace", dir: WORKSPACE }`) at state creation and on every migration/load, so it is never null. This directly fixes Spaces (and any non-Telegram channel that never picked a folder) replying with a dead "Pick a project first:" project-picker instead of engaging — the picker gate was the only thing blocking it.
|
|
12
|
+
- **Removed the folder-picker surface.** Gone: `/projects`, `/session`, the project-selection inline keyboard, and the `show:projects` / `s:<name>` button callbacks. Also removed the four media "Pick a project first." gates (voice/audio/photo/document) and the text-handler gate in the router — all dead once a session always exists. `core/projects.js` is deleted.
|
|
13
|
+
- **Conversation-level controls are unchanged.** `/sessions` (list past conversations), `/new` (fresh conversation), `/continue` (resume), `/end` (end current conversation — now resets to the root instead of clearing to null) all work as before, just always scoped to the one top-level workspace.
|
|
14
|
+
- **`/cron add` no longer takes a `<project>` argument** — it schedules at the workspace root: `/cron add "<schedule>" "<prompt>"`. Existing crons with a stored project still resolve via the scheduler's descriptor.
|
|
15
|
+
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically (FROM the pre-baked `:base`). Existing pods pick this up on their next approved upgrade.
|
|
16
|
+
|
|
3
17
|
## v3.0.18 — Spaces reconciles its task list (assigned tasks no longer silently missed)
|
|
4
18
|
|
|
5
19
|
- **Assigning a task to the bot now actually engages it.** An assignment creates the task but emits **no notification**, and the conversational plane only woke on a socket "notification" push — so a task assigned to the bot could sit there while the bot never noticed (confirmed live: bot could `listTasks` its assigned task, but had 0 notifications and never engaged). The adapter now runs a self-contained **reconciliation poll**: on its own timer it calls `listTasks()` (bot-scoped to its assigned tasks) and synthesises a `task_assigned` engage for any active task it hasn't engaged yet.
|
package/README.md
CHANGED
|
@@ -145,15 +145,16 @@ Each provider keeps its own persistent project session. Switching doesn't lose y
|
|
|
145
145
|
|
|
146
146
|
### Session management
|
|
147
147
|
|
|
148
|
+
The bot runs at the top-level workspace; there is no per-folder project selection.
|
|
149
|
+
|
|
148
150
|
| Command | Description |
|
|
149
151
|
|---------|-------------|
|
|
150
|
-
| `/
|
|
151
|
-
| `/sessions` | List past conversations
|
|
152
|
-
| `/projects` | Browse all workspace projects |
|
|
152
|
+
| `/new` | Start a fresh conversation |
|
|
153
|
+
| `/sessions` | List past conversations |
|
|
153
154
|
| `/continue` | Resume last conversation explicitly |
|
|
154
155
|
| `/compact` | Summarize conversation context now |
|
|
155
156
|
| `/compactwindow [<tokens> \| off \| default]` | Set the auto-compact token threshold |
|
|
156
|
-
| `/end` | End current
|
|
157
|
+
| `/end` | End the current conversation |
|
|
157
158
|
|
|
158
159
|
When you select a project, the last conversation is automatically resumed. Tap "New conversation" to start fresh.
|
|
159
160
|
|
|
@@ -352,7 +353,7 @@ Foreground turns never silently fall back to a different provider. Utility work
|
|
|
352
353
|
|
|
353
354
|
Conversation identity is the tuple of canonical user, project, provider, and native session ID. Claude Code and Codex histories and active pointers stay separate: switching providers restores only that provider's project session and never passes one provider's native session ID to another. Session-history entries are provider-tagged; ambiguous legacy records remain visible but non-selectable.
|
|
354
355
|
|
|
355
|
-
Model, effort, budget, permission mode, and worktree settings are stored per provider. `/new` clears only the
|
|
356
|
+
Model, effort, budget, permission mode, and worktree settings are stored per provider. `/new` clears only the active provider's conversation pointer, while `/end` ends the current conversation (resetting to the top-level workspace) without deleting history. Compaction and scheduled jobs capture an immutable provider/session tuple; an explicitly configured scheduled fallback starts fresh from a provider-neutral archived brief.
|
|
356
357
|
|
|
357
358
|
## Provider migration and rollback
|
|
358
359
|
|
|
@@ -517,9 +518,9 @@ For direct npm installs, `/upgrade` updates Open Claudia itself and does not ins
|
|
|
517
518
|
Schedule recurring tasks:
|
|
518
519
|
|
|
519
520
|
```
|
|
520
|
-
/cron add "0 9 * * 1-5"
|
|
521
|
-
/cron add "0 18 * * *"
|
|
522
|
-
/cron add "*/30 * * * *"
|
|
521
|
+
/cron add "0 9 * * 1-5" "Morning standup: summarize git changes since yesterday"
|
|
522
|
+
/cron add "0 18 * * *" "Git digest: what changed today?"
|
|
523
|
+
/cron add "*/30 * * * *" "Health check: verify the API is responding"
|
|
523
524
|
```
|
|
524
525
|
|
|
525
526
|
Presets available via `/cron` menu. The agent can also schedule its own jobs with `open-claudia cron-add` / `schedule-wakeup` (see Background Work).
|
|
@@ -21,8 +21,14 @@ const { SpacesClient, contentText } = require("./client");
|
|
|
21
21
|
const { createSpacesSocket } = require("./socket");
|
|
22
22
|
const spacesState = require("./state");
|
|
23
23
|
|
|
24
|
-
// Notification `type`/`category` values that warrant a full agent turn.
|
|
25
|
-
|
|
24
|
+
// Notification `type`/`category` values that warrant a full agent turn. The
|
|
25
|
+
// Spaces server emits PLURAL categories (mentions, comments, reactions,
|
|
26
|
+
// task_updated), so the engage set must match the plural forms — matching only
|
|
27
|
+
// the singular "mention" silently dropped every real tag. Singular aliases are
|
|
28
|
+
// kept in case a socket payload ever uses them. Awareness-only types (comments,
|
|
29
|
+
// reactions, task_updated) are deliberately absent: they update the cursor but
|
|
30
|
+
// never auto-reply.
|
|
31
|
+
const ENGAGE_TYPES = new Set(["mention", "mentions", "reply", "replies", "task_assigned"]);
|
|
26
32
|
|
|
27
33
|
class SpacesAdapter {
|
|
28
34
|
constructor({ id = "spaces", url = "https://api.spaces.kazee.africa", token, ownerUserId = "", taskLoopMode = "advisory", pollMs }) {
|
package/core/actions.js
CHANGED
|
@@ -12,7 +12,6 @@ const {
|
|
|
12
12
|
userOwnsProviderSession,
|
|
13
13
|
} = require("./state");
|
|
14
14
|
const { admitRunContext, runAgent } = require("./runner");
|
|
15
|
-
const { listProjects, projectKeyboard } = require("./projects");
|
|
16
15
|
const { isChatOwner, approveAuthRequest, denyAuthRequest, authRequestLabel } = require("./access");
|
|
17
16
|
const accessStore = require("./access");
|
|
18
17
|
const peopleStore = require("./people");
|
|
@@ -282,8 +281,6 @@ async function handleAction(envelope) {
|
|
|
282
281
|
|
|
283
282
|
if (d.startsWith("ob:")) { finishOnboarding(d.slice(3)); return; }
|
|
284
283
|
|
|
285
|
-
if (d === "show:projects") { await send("Pick:", { keyboard: projectKeyboard() }); return; }
|
|
286
|
-
if (d.startsWith("s:")) { startSession(d.slice(2)); return; }
|
|
287
284
|
if (d.startsWith("ss:")) {
|
|
288
285
|
const resolved = resolveSessionCallbackAction(d.slice(3), state);
|
|
289
286
|
if (resolved.status !== "ok") {
|
|
@@ -299,7 +296,7 @@ async function handleAction(envelope) {
|
|
|
299
296
|
const expected = proj === "__workspace__" ? "Workspace" : proj;
|
|
300
297
|
const result = startNewConversation({ state, projectName: expected });
|
|
301
298
|
if (!result.ok) return send("That new-conversation button is stale. Open /sessions again.");
|
|
302
|
-
await send(
|
|
299
|
+
await send("New conversation\n\nSend text, voice, or images.\n\n/sessions — switch conversation");
|
|
303
300
|
return;
|
|
304
301
|
}
|
|
305
302
|
if (d === "a:continue") {
|
|
@@ -310,11 +307,9 @@ async function handleAction(envelope) {
|
|
|
310
307
|
});
|
|
311
308
|
}
|
|
312
309
|
if (d === "a:end") {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
await send(`Ended: ${result.project}`, { keyboard: { inline_keyboard: [[{ text: "New", callback_data: "show:projects" }]] } });
|
|
317
|
-
}
|
|
310
|
+
const result = endCurrentSession({ state });
|
|
311
|
+
if (!result.ok) return send(`Could not end conversation: ${result.error.message}`);
|
|
312
|
+
await send("Ended. Send anything to start a fresh conversation.");
|
|
318
313
|
return;
|
|
319
314
|
}
|
|
320
315
|
if (d === "noop") return;
|
package/core/handlers.js
CHANGED
|
@@ -14,7 +14,7 @@ const {
|
|
|
14
14
|
const { register } = require("./commands");
|
|
15
15
|
const { send, deleteMessage } = require("./io");
|
|
16
16
|
const {
|
|
17
|
-
currentState, saveState,
|
|
17
|
+
currentState, saveState, rootSession,
|
|
18
18
|
freshUsage, getActiveProvider, getProviderSession, setProviderSession, clearProviderSession,
|
|
19
19
|
getProviderSettings,
|
|
20
20
|
getProjectSessions, getLastProjectSession, userOwnsProviderSession,
|
|
@@ -23,7 +23,6 @@ const {
|
|
|
23
23
|
const { canonicalForChannel, canonicalForTelegram, normalizeCanonicalUserId, channelKey, identities, isConfiguredOwnerChannel, removeIdentityMapping } = require("./identity");
|
|
24
24
|
const { isChatAuthorized, isChatOwner, recordPendingAuthRequest, authRequestLabel, hasOwner, bootstrapOwner } = require("./access");
|
|
25
25
|
const { isOnboarded, startOnboarding } = require("./onboarding");
|
|
26
|
-
const { listProjects, findProject, projectKeyboard } = require("./projects");
|
|
27
26
|
const { vault } = require("./vault-store");
|
|
28
27
|
const keyring = require("./keyring");
|
|
29
28
|
const { redactSensitive, registerSecrets } = require("./redact");
|
|
@@ -60,10 +59,13 @@ function ownerEnv(envelope) {
|
|
|
60
59
|
return isChatOwner(envelope.channelId);
|
|
61
60
|
}
|
|
62
61
|
|
|
62
|
+
// Everything runs at the top-level workspace now, so there is nothing to gate
|
|
63
|
+
// on: ensure a root session exists and always proceed.
|
|
63
64
|
function requireSession() {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
const state = currentState();
|
|
66
|
+
if (!state.currentSession) {
|
|
67
|
+
state.currentSession = rootSession();
|
|
68
|
+
saveState();
|
|
67
69
|
}
|
|
68
70
|
return true;
|
|
69
71
|
}
|
|
@@ -274,11 +276,10 @@ function startNewConversation(options = {}) {
|
|
|
274
276
|
|
|
275
277
|
function endCurrentSession(options = {}) {
|
|
276
278
|
const state = options.state || currentState();
|
|
277
|
-
if (!state.currentSession) return { ok: false, error: Object.assign(new Error("No session"), { code: "NO_SESSION" }) };
|
|
278
279
|
const previous = { currentSession: state.currentSession, isFirstMessage: state.isFirstMessage };
|
|
279
|
-
const project = state.currentSession
|
|
280
|
-
clearTransientQueue(state, "The
|
|
281
|
-
state.currentSession =
|
|
280
|
+
const project = state.currentSession?.name || "Workspace";
|
|
281
|
+
clearTransientQueue(state, "The conversation ended before the queued run started");
|
|
282
|
+
state.currentSession = rootSession();
|
|
282
283
|
state.isFirstMessage = true;
|
|
283
284
|
state.cancelRequested = false;
|
|
284
285
|
const persisted = persistSessionControl(state, options, () => {
|
|
@@ -295,29 +296,21 @@ function activeContinueOptions(state = currentState()) {
|
|
|
295
296
|
return sessionId ? { provider, resumeSessionId: sessionId } : null;
|
|
296
297
|
}
|
|
297
298
|
|
|
299
|
+
// Resume a past conversation at the top-level workspace. Folder selection is
|
|
300
|
+
// gone; `name` is retained for the recorded project key (always "Workspace").
|
|
298
301
|
function startSession(name, resumeSessionId, options = {}) {
|
|
299
302
|
const state = currentState();
|
|
300
303
|
const provider = options.provider || getActiveProvider(state);
|
|
301
|
-
|
|
302
|
-
if (name === "__workspace__") {
|
|
303
|
-
projectName = "Workspace";
|
|
304
|
-
projectDir = WORKSPACE;
|
|
305
|
-
} else {
|
|
306
|
-
const result = findProject(name);
|
|
307
|
-
if (!result) return send(`No match for "${name}".`, { keyboard: projectKeyboard() });
|
|
308
|
-
if (Array.isArray(result)) return send("Multiple matches:", { keyboard: { inline_keyboard: result.map((p) => [{ text: p, callback_data: `s:${p}` }]) } });
|
|
309
|
-
projectName = result;
|
|
310
|
-
projectDir = path.join(WORKSPACE, result);
|
|
311
|
-
}
|
|
304
|
+
const projectName = "Workspace";
|
|
312
305
|
|
|
313
|
-
if (!provider) return send("Choose Claude Code or OpenAI Codex with /backend before
|
|
306
|
+
if (!provider) return send("Choose Claude Code or OpenAI Codex with /backend before starting a conversation.");
|
|
314
307
|
|
|
315
308
|
if (resumeSessionId && !userOwnsProviderSession(state.userId, projectName, provider, resumeSessionId)) {
|
|
316
|
-
return send("That conversation is unavailable for this
|
|
309
|
+
return send("That conversation is unavailable for this provider. Open /sessions again.");
|
|
317
310
|
}
|
|
318
311
|
|
|
319
|
-
const selected = selectProjectState(
|
|
320
|
-
if (!selected.ok) return send(`Could not
|
|
312
|
+
const selected = selectProjectState(rootSession(), { state, persist: false });
|
|
313
|
+
if (!selected.ok) return send(`Could not start conversation: ${selected.error.message}`);
|
|
321
314
|
|
|
322
315
|
if (resumeSessionId) {
|
|
323
316
|
setProviderSession(state, provider, projectName, resumeSessionId);
|
|
@@ -326,21 +319,21 @@ function startSession(name, resumeSessionId, options = {}) {
|
|
|
326
319
|
const title = s ? s.title : "";
|
|
327
320
|
state.isFirstMessage = false;
|
|
328
321
|
saveState();
|
|
329
|
-
send(`
|
|
322
|
+
send(`Resumed: ${title || resumeSessionId.slice(0, 8)}\n\nSend text, voice, or images.\n\n/sessions — switch conversation`);
|
|
330
323
|
} else {
|
|
331
324
|
const last = getLastProjectSession(state.userId, projectName, provider);
|
|
332
325
|
if (last) {
|
|
333
326
|
setProviderSession(state, provider, projectName, last.id);
|
|
334
327
|
state.isFirstMessage = false;
|
|
335
328
|
saveState();
|
|
336
|
-
send(`
|
|
329
|
+
send(`Resumed: ${last.title}\n\nSend text, voice, or images.\n\n/sessions — switch conversation`, {
|
|
337
330
|
keyboard: { inline_keyboard: [[{ text: "New conversation", callback_data: `new:${projectName}` }]] },
|
|
338
331
|
});
|
|
339
332
|
} else {
|
|
340
333
|
const existing = getProviderSession(state, provider, projectName);
|
|
341
334
|
state.isFirstMessage = !existing;
|
|
342
335
|
saveState();
|
|
343
|
-
send(`
|
|
336
|
+
send(`Ready.${existing ? `\nResumed: ${existing.slice(0, 8)}` : ""}\n\nSend text, voice, or images.\n\n/sessions — switch conversation`);
|
|
344
337
|
}
|
|
345
338
|
}
|
|
346
339
|
}
|
|
@@ -361,7 +354,8 @@ register({
|
|
|
361
354
|
handler: async (env) => {
|
|
362
355
|
if (!authorized(env)) return;
|
|
363
356
|
if (!isOnboarded()) return startOnboarding();
|
|
364
|
-
|
|
357
|
+
requireSession();
|
|
358
|
+
send("Ready. Send text, voice, or images.\n\n/sessions — switch conversation · /new — fresh conversation");
|
|
365
359
|
},
|
|
366
360
|
});
|
|
367
361
|
|
|
@@ -370,7 +364,7 @@ register({
|
|
|
370
364
|
handler: async (env) => {
|
|
371
365
|
if (!authorized(env)) return;
|
|
372
366
|
send([
|
|
373
|
-
"Session: /
|
|
367
|
+
"Session: /new /sessions /continue /status /stop /end",
|
|
374
368
|
"Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode /engine /recall /tooltrace /toolmode /verbose",
|
|
375
369
|
"Identity: /whoami /link",
|
|
376
370
|
"Team: /people /intros /auth (owner)",
|
|
@@ -890,23 +884,7 @@ register({
|
|
|
890
884
|
});
|
|
891
885
|
|
|
892
886
|
register({
|
|
893
|
-
name: "
|
|
894
|
-
handler: async (env) => { if (authorized(env)) send("Pick:", { keyboard: projectKeyboard() }); },
|
|
895
|
-
});
|
|
896
|
-
|
|
897
|
-
register({
|
|
898
|
-
name: "session", description: "Pick a project to work on", args: "[<project>]",
|
|
899
|
-
handler: async (env, { tail }) => {
|
|
900
|
-
if (!authorized(env)) return;
|
|
901
|
-
if (tail) return startSession(tail);
|
|
902
|
-
const cs = currentState().currentSession;
|
|
903
|
-
if (cs) send(`Active: ${cs.name}\n\nSwitch?`, { keyboard: projectKeyboard() });
|
|
904
|
-
else send("Pick:", { keyboard: projectKeyboard() });
|
|
905
|
-
},
|
|
906
|
-
});
|
|
907
|
-
|
|
908
|
-
register({
|
|
909
|
-
name: "sessions", description: "List conversations for this project",
|
|
887
|
+
name: "sessions", description: "List past conversations",
|
|
910
888
|
handler: async (env) => {
|
|
911
889
|
if (!authorized(env)) return;
|
|
912
890
|
if (!requireSession()) return;
|
|
@@ -1452,15 +1430,13 @@ register({
|
|
|
1452
1430
|
});
|
|
1453
1431
|
|
|
1454
1432
|
register({
|
|
1455
|
-
name: "end", description: "End current
|
|
1433
|
+
name: "end", description: "End the current conversation",
|
|
1456
1434
|
handler: async (env) => {
|
|
1457
1435
|
if (!authorized(env)) return;
|
|
1458
1436
|
const state = currentState();
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
send(`Ended: ${result.project}`, { keyboard: { inline_keyboard: [[{ text: "New session", callback_data: "show:projects" }]] } });
|
|
1463
|
-
} else send("No session.");
|
|
1437
|
+
const result = endCurrentSession({ state });
|
|
1438
|
+
if (!result.ok) return send(`Could not end conversation: ${result.error.message}`);
|
|
1439
|
+
send("Ended. Send anything to start a fresh conversation.");
|
|
1464
1440
|
},
|
|
1465
1441
|
});
|
|
1466
1442
|
|
|
@@ -1980,7 +1956,7 @@ register({
|
|
|
1980
1956
|
if (!tail) {
|
|
1981
1957
|
const list = channelJobs();
|
|
1982
1958
|
if (list.length === 0) {
|
|
1983
|
-
return send("No crons." + archivedNote() + "\n\nAdd: /cron add \"<schedule>\"
|
|
1959
|
+
return send("No crons." + archivedNote() + "\n\nAdd: /cron add \"<schedule>\" \"<prompt>\"\n\nOr pick a preset:", {
|
|
1984
1960
|
keyboard: { inline_keyboard: [
|
|
1985
1961
|
[{ text: "Standup 9am", callback_data: "cp:standup" }, { text: "Git digest 6pm", callback_data: "cp:git" }],
|
|
1986
1962
|
[{ text: "Dep check Mon", callback_data: "cp:deps" }, { text: "Health 30min", callback_data: "cp:health" }],
|
|
@@ -1990,24 +1966,23 @@ register({
|
|
|
1990
1966
|
return send("Crons:\n\n" + list.map((c, i) => `${i + 1}. ${c.label} (${c.schedule}) — ${c.project || ""}`).join("\n") + archivedNote() + "\n\nRemove: /cron remove <#>");
|
|
1991
1967
|
}
|
|
1992
1968
|
|
|
1993
|
-
const addMatch = tail.match(/^add\s+"(.+)"\s+
|
|
1969
|
+
const addMatch = tail.match(/^add\s+"(.+)"\s+"(.+)"$/);
|
|
1994
1970
|
if (addMatch) {
|
|
1995
1971
|
if (!cronLib.validate(addMatch[1])) return send("Invalid cron schedule.");
|
|
1996
|
-
const proj = findProject(addMatch[2]);
|
|
1997
|
-
if (!proj || Array.isArray(proj)) return send("Project not found.");
|
|
1998
1972
|
scheduler.addJob({
|
|
1999
1973
|
kind: "cron",
|
|
2000
1974
|
adapter: env.adapter.id,
|
|
2001
1975
|
adapterType: env.adapter.type,
|
|
2002
1976
|
channelId: String(env.channelId),
|
|
2003
1977
|
canonicalUserId: env.canonicalUserId,
|
|
2004
|
-
project:
|
|
2005
|
-
|
|
2006
|
-
|
|
1978
|
+
project: "Workspace",
|
|
1979
|
+
projectDir: WORKSPACE,
|
|
1980
|
+
prompt: addMatch[2],
|
|
1981
|
+
label: addMatch[2].slice(0, 50),
|
|
2007
1982
|
source: "user",
|
|
2008
1983
|
schedule: addMatch[1],
|
|
2009
1984
|
});
|
|
2010
|
-
return send(`Added: ${addMatch[
|
|
1985
|
+
return send(`Added: ${addMatch[2].slice(0, 50)} (${addMatch[1]})`);
|
|
2011
1986
|
}
|
|
2012
1987
|
|
|
2013
1988
|
const removeMatch = tail.match(/^remove\s+(\d+)$/);
|
|
@@ -2019,7 +1994,7 @@ register({
|
|
|
2019
1994
|
return send(`Removed: ${removed ? removed.label : "(unknown)"}`);
|
|
2020
1995
|
}
|
|
2021
1996
|
|
|
2022
|
-
send('Usage: /cron | /cron add "<schedule>"
|
|
1997
|
+
send('Usage: /cron | /cron add "<schedule>" "<prompt>" | /cron remove <#>');
|
|
2023
1998
|
},
|
|
2024
1999
|
});
|
|
2025
2000
|
|
package/core/identity.js
CHANGED
|
@@ -46,6 +46,10 @@ function isConfiguredOwnerChannel(transport, channelId) {
|
|
|
46
46
|
try { cfg = require("./config"); } catch (e) { return false; }
|
|
47
47
|
if (t === "telegram") return (cfg.CHAT_IDS || []).map(String).includes(c);
|
|
48
48
|
if (t === "kazee") return !!cfg.config.KAZEE_OWNER_USER_ID && c === String(cfg.config.KAZEE_OWNER_USER_ID);
|
|
49
|
+
// Spaces principals are central/Kazee user ids, so the owner's Spaces actor id
|
|
50
|
+
// is the same configured Kazee owner id — recognise them on Spaces too, else
|
|
51
|
+
// the owner's own comments are classed external (default-deny).
|
|
52
|
+
if (t === "spaces") return !!cfg.config.KAZEE_OWNER_USER_ID && c === String(cfg.config.KAZEE_OWNER_USER_ID);
|
|
49
53
|
if (t === "voice") return c === String(cfg.config.VOICE_OWNER_USER_ID || "voice-owner");
|
|
50
54
|
// Web dashboard chat: fixed single-owner channel gated by the dashboard
|
|
51
55
|
// session, so its identity is operator-implied rather than env-declared.
|
package/core/onboarding.js
CHANGED
|
@@ -89,9 +89,7 @@ You are ${data.name}'s personal AI coding assistant, running 24/7 across chat ch
|
|
|
89
89
|
saveEnvKey("ONBOARDED", "true");
|
|
90
90
|
config.ONBOARDED = "true";
|
|
91
91
|
|
|
92
|
-
send(`All set, ${data.name}! I'm configured and ready.\n\
|
|
93
|
-
keyboard: { inline_keyboard: [[{ text: "Pick a project", callback_data: "show:projects" }]] },
|
|
94
|
-
});
|
|
92
|
+
send(`All set, ${data.name}! I'm configured and ready.\n\nSend text, voice, or images to get started. /help lists everything.`);
|
|
95
93
|
}
|
|
96
94
|
|
|
97
95
|
module.exports = { isOnboarded, startOnboarding, handleOnboarding, finishOnboarding };
|
package/core/router.js
CHANGED
|
@@ -155,7 +155,6 @@ async function onAction(envelope) {
|
|
|
155
155
|
async function handleVoice(envelope) {
|
|
156
156
|
if (isDuplicate(envelope)) return;
|
|
157
157
|
const state = currentState();
|
|
158
|
-
if (!state.currentSession) return send("Pick a project first.");
|
|
159
158
|
try {
|
|
160
159
|
const media = envelope.media?.[0];
|
|
161
160
|
if (!media) return;
|
|
@@ -190,7 +189,6 @@ async function handleVoice(envelope) {
|
|
|
190
189
|
async function handleAudio(envelope) {
|
|
191
190
|
if (isDuplicate(envelope)) return;
|
|
192
191
|
const state = currentState();
|
|
193
|
-
if (!state.currentSession) return send("Pick a project first.");
|
|
194
192
|
try {
|
|
195
193
|
const media = envelope.media?.[0];
|
|
196
194
|
if (!media) return;
|
|
@@ -214,7 +212,6 @@ async function handleAudio(envelope) {
|
|
|
214
212
|
async function handlePhoto(envelope) {
|
|
215
213
|
if (isDuplicate(envelope)) return;
|
|
216
214
|
const state = currentState();
|
|
217
|
-
if (!state.currentSession) return send("Pick a project first.");
|
|
218
215
|
try {
|
|
219
216
|
const items = envelope.media || [];
|
|
220
217
|
if (!items.length) return;
|
|
@@ -244,7 +241,6 @@ async function handlePhoto(envelope) {
|
|
|
244
241
|
async function handleDocument(envelope) {
|
|
245
242
|
if (isDuplicate(envelope)) return;
|
|
246
243
|
const state = currentState();
|
|
247
|
-
if (!state.currentSession) return send("Pick a project first.");
|
|
248
244
|
try {
|
|
249
245
|
const media = envelope.media?.[0];
|
|
250
246
|
if (!media) return;
|
|
@@ -399,11 +395,6 @@ async function handleText(envelope) {
|
|
|
399
395
|
return;
|
|
400
396
|
}
|
|
401
397
|
|
|
402
|
-
if (!state.currentSession) {
|
|
403
|
-
const { projectKeyboard } = require("./projects");
|
|
404
|
-
return send("Pick a project first:", { keyboard: projectKeyboard() });
|
|
405
|
-
}
|
|
406
|
-
|
|
407
398
|
const text = envelope.text || "";
|
|
408
399
|
const runContext = admitRunContext(text, state.currentSession.dir, envelope.messageId, { purpose: "foreground" });
|
|
409
400
|
const attachments = [];
|
package/core/state.js
CHANGED
|
@@ -7,7 +7,7 @@ const path = require("path");
|
|
|
7
7
|
const crypto = require("crypto");
|
|
8
8
|
const {
|
|
9
9
|
STATE_FILE, SESSIONS_FILE, IDENTITIES_FILE, CONFIG_DIR, CHAT_ID, UNIFIED_IDENTITY,
|
|
10
|
-
JOBS_FILE, CRONS_FILE,
|
|
10
|
+
JOBS_FILE, CRONS_FILE, WORKSPACE,
|
|
11
11
|
} = require("./config");
|
|
12
12
|
const {
|
|
13
13
|
normalizeCanonicalUserId,
|
|
@@ -292,7 +292,7 @@ function migrateSavedUser(value) {
|
|
|
292
292
|
|
|
293
293
|
const result = {
|
|
294
294
|
...source,
|
|
295
|
-
currentSession: isRecord(source.currentSession) ? { ...source.currentSession } :
|
|
295
|
+
currentSession: isRecord(source.currentSession) ? { ...source.currentSession } : rootSession(),
|
|
296
296
|
settings,
|
|
297
297
|
activeSessions,
|
|
298
298
|
providerSettings,
|
|
@@ -496,6 +496,14 @@ function attachRuntimeAccessors(state) {
|
|
|
496
496
|
return state;
|
|
497
497
|
}
|
|
498
498
|
|
|
499
|
+
// The bot runs at a single top-level working directory. There is no per-folder
|
|
500
|
+
// project picker anymore, so every state starts already "in" the workspace
|
|
501
|
+
// root and currentSession is never null — readers (cwd, transcripts, usage) can
|
|
502
|
+
// rely on it being present on every channel, including the Spaces poller.
|
|
503
|
+
function rootSession() {
|
|
504
|
+
return { name: "Workspace", dir: WORKSPACE || process.cwd() };
|
|
505
|
+
}
|
|
506
|
+
|
|
499
507
|
function createUserState(userId) {
|
|
500
508
|
const saved = (savedState.users && savedState.users[userId]) || {};
|
|
501
509
|
const hasSavedUser = hadPersistedStateFile
|
|
@@ -510,7 +518,7 @@ function createUserState(userId) {
|
|
|
510
518
|
const state = {
|
|
511
519
|
userId: String(userId),
|
|
512
520
|
channelId: currentChannelId(),
|
|
513
|
-
currentSession: saved.currentSession ||
|
|
521
|
+
currentSession: saved.currentSession || rootSession(),
|
|
514
522
|
runningProcess: null,
|
|
515
523
|
preparingRun: false,
|
|
516
524
|
cancelRequested: false,
|
|
@@ -1296,6 +1304,7 @@ module.exports = {
|
|
|
1296
1304
|
getActiveUsage,
|
|
1297
1305
|
getUsageForSession,
|
|
1298
1306
|
usageSessionKey,
|
|
1307
|
+
rootSession,
|
|
1299
1308
|
createUserState,
|
|
1300
1309
|
getUserState,
|
|
1301
1310
|
currentState,
|
package/package.json
CHANGED
|
@@ -32,15 +32,14 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
|
|
|
32
32
|
|
|
33
33
|
const pkg = JSON.parse(read("package.json"));
|
|
34
34
|
assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
35
|
-
// 3.0.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
assert.strictEqual(pkg.version, "3.0.18", "release version must remain unchanged without explicit approval");
|
|
35
|
+
// 3.0.20 approved by Sumeet 2026-07-14 ("Yeh push it"): two Spaces fixes. (1)
|
|
36
|
+
// The Spaces server emits PLURAL notification categories (mentions/comments/
|
|
37
|
+
// reactions), so ENGAGE_TYPES now matches "mentions"/"replies" — matching only
|
|
38
|
+
// singular "mention" silently dropped every real tag. (2) isConfiguredOwnerChannel
|
|
39
|
+
// gained a "spaces" branch mapping to KAZEE_OWNER_USER_ID (Spaces principals are
|
|
40
|
+
// central/Kazee ids), so the owner is no longer classed external (default-deny)
|
|
41
|
+
// on their own Spaces comments. 3.0.19 flattened the folder-project model.
|
|
42
|
+
assert.strictEqual(pkg.version, "3.0.20", "release version must remain unchanged without explicit approval");
|
|
44
43
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
45
44
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
46
45
|
}
|
|
@@ -96,7 +96,7 @@ function newEndAndContinueProbe() {
|
|
|
96
96
|
|
|
97
97
|
const ended = handlers.endCurrentSession({ state, persist: false });
|
|
98
98
|
assert.strictEqual(ended.ok, true);
|
|
99
|
-
assert.strictEqual(state.currentSession, null);
|
|
99
|
+
assert.strictEqual(state.currentSession.name, "Workspace", "ending resets to the top-level workspace, never null");
|
|
100
100
|
assert.strictEqual(stateApi.getProviderSession(state, "codex", "alpha"), "codex-alpha");
|
|
101
101
|
assert.strictEqual(stateApi.getProviderSession(state, "claude", "alpha"), "claude-alpha");
|
|
102
102
|
assert.strictEqual(stateApi.userOwnsProviderSession(state.userId, "alpha", "codex", "codex-alpha"), true);
|
package/core/projects.js
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
// Workspace project utilities. Reads filesystem; no IO dependencies.
|
|
2
|
-
|
|
3
|
-
const fs = require("fs");
|
|
4
|
-
const path = require("path");
|
|
5
|
-
const { WORKSPACE } = require("./config");
|
|
6
|
-
|
|
7
|
-
function listProjects() {
|
|
8
|
-
try {
|
|
9
|
-
return fs.readdirSync(WORKSPACE, { withFileTypes: true })
|
|
10
|
-
.filter((d) => d.isDirectory())
|
|
11
|
-
.map((d) => d.name)
|
|
12
|
-
.filter((n) => !n.startsWith("."));
|
|
13
|
-
} catch (e) { return []; }
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function findProject(query) {
|
|
17
|
-
const projects = listProjects();
|
|
18
|
-
const q = query.toLowerCase();
|
|
19
|
-
const exact = projects.find((p) => p.toLowerCase() === q);
|
|
20
|
-
if (exact) return exact;
|
|
21
|
-
const startsWith = projects.filter((p) => p.toLowerCase().startsWith(q));
|
|
22
|
-
if (startsWith.length === 1) return startsWith[0];
|
|
23
|
-
const contains = projects.filter((p) => p.toLowerCase().includes(q));
|
|
24
|
-
if (contains.length === 1) return contains[0];
|
|
25
|
-
return contains.length > 0 ? contains : null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function projectKeyboard() {
|
|
29
|
-
const projects = listProjects();
|
|
30
|
-
const rows = [[{ text: "\u{1F4C1} Workspace (root)", callback_data: "s:__workspace__" }]];
|
|
31
|
-
for (let i = 0; i < projects.length; i += 2) {
|
|
32
|
-
const row = [{ text: projects[i], callback_data: `s:${projects[i]}` }];
|
|
33
|
-
if (projects[i + 1]) row.push({ text: projects[i + 1], callback_data: `s:${projects[i + 1]}` });
|
|
34
|
-
rows.push(row);
|
|
35
|
-
}
|
|
36
|
-
return { inline_keyboard: rows };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function workspacePath(name) {
|
|
40
|
-
return name === "__workspace__" ? WORKSPACE : path.join(WORKSPACE, name);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
module.exports = { listProjects, findProject, projectKeyboard, workspacePath };
|