@inetafrica/open-claudia 3.0.17 → 3.0.18
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 +8 -0
- package/channels/spaces/adapter.js +76 -3
- package/channels/spaces/state.js +43 -3
- package/core/config.js +2 -0
- package/core/handlers.js +2 -1
- package/package.json +1 -1
- package/test-provider-language.js +9 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.0.18 — Spaces reconciles its task list (assigned tasks no longer silently missed)
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
- **Robust to socket churn.** The Spaces socket transport reconnects constantly; a push emitted during a gap is lost with no replay. The timer also re-runs the existing notification catch-up `poll()` and, crucially, doesn't depend on the connected-apps poller (which can be disabled) — the Spaces plane drives itself. Cadence is `OC_SPACES_POLL_MS` (default 60s).
|
|
7
|
+
- **Per-task engaged-dedup.** Engagement is now tracked per task (`markTaskEngaged`/`taskEngaged`), not per notification-id, so a task the socket already handled is skipped by reconcile and vice-versa — no double replies. Terminal (completed/archived/cancelled) tasks are skipped.
|
|
8
|
+
- **A successful `listTasks()` is a connection signal** (`markRestConnected`) — the third leg of the hybrid auto-enable, independent of the flaky socket. `/spaces status` now shows a "Last poll" line. Still owner-gated: `/spaces pause` stops replies; reconcile records connection but engages nothing while paused/off.
|
|
9
|
+
- **Agent-space / OpenClaw:** no new deps, no new required env (one optional `OC_SPACES_POLL_MS`), no schema change; Docker image builds identically (FROM the pre-baked `:base`). Existing pods pick this up on their next approved upgrade.
|
|
10
|
+
|
|
3
11
|
## v3.0.17 — Spaces auto-enables on connection; control moves into chat
|
|
4
12
|
|
|
5
13
|
- **The Spaces conversational plane no longer hides behind an env flag.** `OC_SPACES_CONVERSATIONAL` is gone. Auto-replies now enable themselves the moment the bot is **connected** to Spaces, on a hybrid signal: central authoritatively lists Spaces among the bot's connected apps, **or** the socket has actually received a Spaces notification (which only happens for a task the bot is a member of). Either one flips it on — so it works today over the socket even before the central `/api/principal/applications` endpoint is deployed. It stays self-gating: no Spaces membership ⇒ no notifications ⇒ no replies.
|
|
@@ -25,7 +25,7 @@ const spacesState = require("./state");
|
|
|
25
25
|
const ENGAGE_TYPES = new Set(["mention", "reply", "task_assigned"]);
|
|
26
26
|
|
|
27
27
|
class SpacesAdapter {
|
|
28
|
-
constructor({ id = "spaces", url = "https://api.spaces.kazee.africa", token, ownerUserId = "", taskLoopMode = "advisory" }) {
|
|
28
|
+
constructor({ id = "spaces", url = "https://api.spaces.kazee.africa", token, ownerUserId = "", taskLoopMode = "advisory", pollMs }) {
|
|
29
29
|
this.id = id;
|
|
30
30
|
this.type = "spaces";
|
|
31
31
|
this.url = url.replace(/\/$/, "");
|
|
@@ -33,11 +33,17 @@ class SpacesAdapter {
|
|
|
33
33
|
this.ownerUserId = ownerUserId || "";
|
|
34
34
|
// Env default posture; the live value is chat-overridable via spacesState.
|
|
35
35
|
this.taskLoopModeDefault = taskLoopMode || "advisory";
|
|
36
|
+
// Self-driving reconciliation cadence. The connected-apps poller used to be
|
|
37
|
+
// the only thing calling poll(); it can be disabled, so the adapter drives
|
|
38
|
+
// its own timer and never depends on it.
|
|
39
|
+
this.pollMs = Number(pollMs) > 0 ? Number(pollMs) : 60000;
|
|
36
40
|
this.client = new SpacesClient({ baseUrl: this.url, token });
|
|
37
41
|
this._listeners = { message: new Set(), action: new Set() };
|
|
38
42
|
this._socket = null;
|
|
39
43
|
this._seen = new Set(); // notification-id dedup across socket + poll
|
|
40
44
|
this._taskChains = new Map(); // taskId -> tail promise, for per-task serialization
|
|
45
|
+
this._pollTimer = null;
|
|
46
|
+
this._initTimer = null;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
on(event, fn) {
|
|
@@ -62,15 +68,32 @@ class SpacesAdapter {
|
|
|
62
68
|
onDisconnect: (reason) => console.log(`Spaces socket disconnected: ${reason}`),
|
|
63
69
|
onError: (err) => console.error("Spaces socket error:", err.message),
|
|
64
70
|
});
|
|
71
|
+
// Self-contained catch-up loop: reconcile assigned tasks (which emit no
|
|
72
|
+
// notification) and replay any unread notifications the socket missed during
|
|
73
|
+
// a reconnect gap. Runs regardless of the connected-apps poller. unref so it
|
|
74
|
+
// never holds the process open on its own.
|
|
75
|
+
const tick = () => { this._tick(); };
|
|
76
|
+
this._pollTimer = setInterval(tick, this.pollMs);
|
|
77
|
+
if (this._pollTimer.unref) this._pollTimer.unref();
|
|
78
|
+
// Give the socket a moment to settle, then do an immediate first pass.
|
|
79
|
+
this._initTimer = setTimeout(tick, 3000);
|
|
80
|
+
if (this._initTimer.unref) this._initTimer.unref();
|
|
65
81
|
}
|
|
66
82
|
|
|
67
83
|
async stop() {
|
|
68
84
|
try { this._socket?.disconnect?.(); } catch (e) {}
|
|
69
85
|
this._socket = null;
|
|
86
|
+
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
|
|
87
|
+
if (this._initTimer) { clearTimeout(this._initTimer); this._initTimer = null; }
|
|
70
88
|
}
|
|
71
89
|
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
async _tick() {
|
|
91
|
+
await this.reconcile();
|
|
92
|
+
await this.poll();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// REST catch-up for NOTIFICATION-bearing events (mentions, replies) the socket
|
|
96
|
+
// missed during a reconnect gap. Read-only.
|
|
74
97
|
async poll() {
|
|
75
98
|
try {
|
|
76
99
|
const notifications = await this.client.listNotifications({ limit: 40, unreadOnly: true });
|
|
@@ -81,6 +104,53 @@ class SpacesAdapter {
|
|
|
81
104
|
}
|
|
82
105
|
}
|
|
83
106
|
|
|
107
|
+
// Reconcile against the task list. Assigning a task to the bot creates the task
|
|
108
|
+
// but emits NO notification, so poll() (notification-based) can never see it —
|
|
109
|
+
// this is why an assigned task silently failed to engage. listTasks() on the
|
|
110
|
+
// bot's own token returns exactly the tasks assigned to it, so we synthesise a
|
|
111
|
+
// task_assigned event for any active task we haven't engaged yet. Per-task
|
|
112
|
+
// engaged-dedup (not notification-id) means a task the socket already handled
|
|
113
|
+
// is skipped here. Read-only; the only write is the eventual reply, gated as
|
|
114
|
+
// usual. Success is also membership proof independent of the flaky socket.
|
|
115
|
+
async reconcile() {
|
|
116
|
+
let tasks;
|
|
117
|
+
try {
|
|
118
|
+
tasks = await this.client.listTasks();
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.error("Spaces reconcile error:", e.message);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!Array.isArray(tasks)) return;
|
|
124
|
+
// Being able to list our own tasks proves we're connected — the REST leg of
|
|
125
|
+
// the hybrid signal, which survives socket churn.
|
|
126
|
+
try { spacesState.markRestConnected(); } catch (e) {}
|
|
127
|
+
|
|
128
|
+
let active = false;
|
|
129
|
+
try { active = spacesState.conversationalActive(); } catch (e) {}
|
|
130
|
+
if (!active) return; // connection recorded; engagement stays gated (paused/off)
|
|
131
|
+
|
|
132
|
+
const TERMINAL = new Set(["completed", "archived", "cancelled", "done"]);
|
|
133
|
+
for (const task of tasks) {
|
|
134
|
+
const taskId = String(task.id || task._id || "");
|
|
135
|
+
if (!taskId || task.isArchived) continue;
|
|
136
|
+
if (TERMINAL.has(String(task.status || "").toLowerCase())) continue;
|
|
137
|
+
try { if (spacesState.taskEngaged(taskId)) continue; } catch (e) {}
|
|
138
|
+
const spaceId = task.spaceId && typeof task.spaceId === "object"
|
|
139
|
+
? (task.spaceId.id || task.spaceId._id || null)
|
|
140
|
+
: (task.spaceId || null);
|
|
141
|
+
const synthetic = {
|
|
142
|
+
id: `recon:${taskId}`,
|
|
143
|
+
type: "task_assigned",
|
|
144
|
+
targetType: "task",
|
|
145
|
+
targetId: taskId,
|
|
146
|
+
title: task.title || "",
|
|
147
|
+
spaceId,
|
|
148
|
+
actorId: task.creatorId || "",
|
|
149
|
+
};
|
|
150
|
+
this._handleNotification(synthetic, "reconcile");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
84
154
|
_notificationId(payload) {
|
|
85
155
|
return String(payload?.id || payload?._id || "");
|
|
86
156
|
}
|
|
@@ -174,6 +244,9 @@ class SpacesAdapter {
|
|
|
174
244
|
spaces: { taskId: n.taskId, spaceId: n.spaceId, notificationType: n.type, deepLink: n.deepLink, title: n.title, mode: this._taskLoopMode() },
|
|
175
245
|
raw: payload,
|
|
176
246
|
};
|
|
247
|
+
// Record the engagement so the reconciliation poll won't re-synthesise an
|
|
248
|
+
// assignment for a task the socket/poll already handled (per-task dedup).
|
|
249
|
+
try { spacesState.markTaskEngaged(n.taskId); } catch (e) {}
|
|
177
250
|
// Per-task serialization: a task's turns run one at a time so two comments
|
|
178
251
|
// on the same task can't spawn concurrent, conflicting agent runs. Other
|
|
179
252
|
// tasks proceed in parallel.
|
package/channels/spaces/state.js
CHANGED
|
@@ -35,6 +35,11 @@ function load() {
|
|
|
35
35
|
centralAt: rawConn.centralAt || 0,
|
|
36
36
|
socketFirstAt: rawConn.socketFirstAt || 0,
|
|
37
37
|
socketLastAt: rawConn.socketLastAt || 0,
|
|
38
|
+
// REST reconciliation proof: a successful listTasks() means the bot can
|
|
39
|
+
// read its own Spaces tasks, which is membership proof independent of the
|
|
40
|
+
// socket (whose transport churns). Third leg of the hybrid signal.
|
|
41
|
+
restFirstAt: rawConn.restFirstAt || 0,
|
|
42
|
+
restLastAt: rawConn.restLastAt || 0,
|
|
38
43
|
},
|
|
39
44
|
// Owner control, set from chat (/spaces …) — never from env/filesystem, which
|
|
40
45
|
// the owner can't reach on a pod. `muted` is the off-switch for auto-replies;
|
|
@@ -87,6 +92,28 @@ function getCursor(taskId) {
|
|
|
87
92
|
return s.tasks[String(taskId || "")] || null;
|
|
88
93
|
}
|
|
89
94
|
|
|
95
|
+
// Mark that a task has had an agent turn emitted for it (from any source: a
|
|
96
|
+
// socket/poll notification OR the reconciliation poll). The reconciliation poll
|
|
97
|
+
// reads this to avoid re-engaging a task the socket already handled — dedup is
|
|
98
|
+
// per-task, not per-notification-id, because a synthesised assignment has no
|
|
99
|
+
// real notification id to match against.
|
|
100
|
+
function markTaskEngaged(taskId) {
|
|
101
|
+
const id = String(taskId || "");
|
|
102
|
+
if (!id) return;
|
|
103
|
+
const s = load();
|
|
104
|
+
const cur = s.tasks[id] || { count: 0 };
|
|
105
|
+
cur.engaged = true;
|
|
106
|
+
cur.lastEngagedAt = new Date().toISOString();
|
|
107
|
+
s.tasks[id] = cur;
|
|
108
|
+
save();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function taskEngaged(taskId) {
|
|
112
|
+
const s = load();
|
|
113
|
+
const t = s.tasks[String(taskId || "")];
|
|
114
|
+
return !!(t && t.engaged);
|
|
115
|
+
}
|
|
116
|
+
|
|
90
117
|
function snapshot() {
|
|
91
118
|
const s = load();
|
|
92
119
|
return { tasks: s.tasks, processedCount: s.processed.length };
|
|
@@ -104,6 +131,17 @@ function markSocketConnected() {
|
|
|
104
131
|
save();
|
|
105
132
|
}
|
|
106
133
|
|
|
134
|
+
// Called by the adapter's reconciliation poll after a successful listTasks().
|
|
135
|
+
// Being able to list our own tasks is membership proof that survives socket
|
|
136
|
+
// churn — so it's a first-class connection signal, not just a socket fallback.
|
|
137
|
+
function markRestConnected() {
|
|
138
|
+
const s = load();
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
if (!s.connection.restFirstAt) s.connection.restFirstAt = now;
|
|
141
|
+
s.connection.restLastAt = now;
|
|
142
|
+
save();
|
|
143
|
+
}
|
|
144
|
+
|
|
107
145
|
// Called by the connected-apps poller with central's authoritative answer to
|
|
108
146
|
// "is Spaces among my connected apps?". Persisted so a restart keeps the signal
|
|
109
147
|
// until the next poll refreshes it.
|
|
@@ -122,7 +160,7 @@ function setCentralConnected(connected) {
|
|
|
122
160
|
// sticky signal.
|
|
123
161
|
function isConnected() {
|
|
124
162
|
const s = load();
|
|
125
|
-
return !!(s.connection.central || s.connection.socketFirstAt);
|
|
163
|
+
return !!(s.connection.central || s.connection.socketFirstAt || s.connection.restFirstAt);
|
|
126
164
|
}
|
|
127
165
|
|
|
128
166
|
// --- Owner control (from chat) ---------------------------------------------
|
|
@@ -164,6 +202,8 @@ function controlSnapshot() {
|
|
|
164
202
|
central: s.connection.central,
|
|
165
203
|
socketFirstAt: s.connection.socketFirstAt,
|
|
166
204
|
socketLastAt: s.connection.socketLastAt,
|
|
205
|
+
restFirstAt: s.connection.restFirstAt,
|
|
206
|
+
restLastAt: s.connection.restLastAt,
|
|
167
207
|
muted: s.control.muted,
|
|
168
208
|
conversationalActive: conversationalActive(),
|
|
169
209
|
taskLoopMode: taskLoopMode(),
|
|
@@ -172,7 +212,7 @@ function controlSnapshot() {
|
|
|
172
212
|
}
|
|
173
213
|
|
|
174
214
|
module.exports = {
|
|
175
|
-
markProcessed, recordCursor, getCursor, snapshot, STATE_FILE,
|
|
176
|
-
markSocketConnected, setCentralConnected, isConnected,
|
|
215
|
+
markProcessed, recordCursor, getCursor, markTaskEngaged, taskEngaged, snapshot, STATE_FILE,
|
|
216
|
+
markSocketConnected, markRestConnected, setCentralConnected, isConnected,
|
|
177
217
|
setMuted, setTaskLoopMode, taskLoopMode, conversationalActive, controlSnapshot,
|
|
178
218
|
};
|
package/core/config.js
CHANGED
|
@@ -259,6 +259,7 @@ function loadChannels() {
|
|
|
259
259
|
// live. "advisory" = read/reason/recommend; "autonomous" = work to
|
|
260
260
|
// completion (writes still hit the owner-approval gate); "off".
|
|
261
261
|
taskLoopMode: String(config.OC_SPACES_TASK_LOOP || process.env.OC_SPACES_TASK_LOOP || "advisory").toLowerCase(),
|
|
262
|
+
pollMs: Number(config.OC_SPACES_POLL_MS || process.env.OC_SPACES_POLL_MS) || 60000,
|
|
262
263
|
},
|
|
263
264
|
});
|
|
264
265
|
} else if (type === "voice") {
|
|
@@ -295,6 +296,7 @@ function loadChannels() {
|
|
|
295
296
|
token: config.KAZEE_BOT_TOKEN,
|
|
296
297
|
ownerUserId: config.SPACES_OWNER_USER_ID || "",
|
|
297
298
|
taskLoopMode: String(config.OC_SPACES_TASK_LOOP || process.env.OC_SPACES_TASK_LOOP || "advisory").toLowerCase(),
|
|
299
|
+
pollMs: Number(config.OC_SPACES_POLL_MS || process.env.OC_SPACES_POLL_MS) || 60000,
|
|
298
300
|
},
|
|
299
301
|
});
|
|
300
302
|
}
|
package/core/handlers.js
CHANGED
|
@@ -807,7 +807,7 @@ register({
|
|
|
807
807
|
const fmtTime = (ms) => (ms ? new Date(ms).toISOString().replace("T", " ").slice(0, 16) + "Z" : "never");
|
|
808
808
|
const statusText = () => {
|
|
809
809
|
const s = spacesState.controlSnapshot();
|
|
810
|
-
const src = s.central ? "central" : (s.socketFirstAt ? "socket" : "—");
|
|
810
|
+
const src = s.central ? "central" : (s.socketFirstAt ? "socket" : (s.restFirstAt ? "poll" : "—"));
|
|
811
811
|
const replies = s.conversationalActive ? "ON" : (s.connected ? "paused" : "off (not connected)");
|
|
812
812
|
const modeLine = s.taskLoopOverride
|
|
813
813
|
? `${s.taskLoopMode} (override)`
|
|
@@ -818,6 +818,7 @@ register({
|
|
|
818
818
|
`Auto-replies: ${replies}`,
|
|
819
819
|
`Task mode: ${modeLine}`,
|
|
820
820
|
`Last notification: ${fmtTime(s.socketLastAt)}`,
|
|
821
|
+
`Last poll: ${fmtTime(s.restLastAt)}`,
|
|
821
822
|
].join("\n");
|
|
822
823
|
};
|
|
823
824
|
|
package/package.json
CHANGED
|
@@ -32,13 +32,15 @@ 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
|
-
// conversational plane now
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// (
|
|
40
|
-
//
|
|
41
|
-
|
|
35
|
+
// 3.0.18 approved by Sumeet 2026-07-14 ("Yeah, go for it build it push it out"):
|
|
36
|
+
// the Spaces conversational plane now also RECONCILES against its task list. An
|
|
37
|
+
// assignment creates a task but emits NO notification, and the socket-push-only
|
|
38
|
+
// path missed it — so the adapter drives its own timer that calls listTasks()
|
|
39
|
+
// (bot-scoped to assigned tasks) and synthesises a task_assigned engage for any
|
|
40
|
+
// active task it hasn't engaged, with per-task dedup so the socket path isn't
|
|
41
|
+
// doubled. A successful listTasks() is a third connection leg (markRestConnected)
|
|
42
|
+
// that survives socket churn. Builds on 3.0.17's connection-based auto-enable.
|
|
43
|
+
assert.strictEqual(pkg.version, "3.0.18", "release version must remain unchanged without explicit approval");
|
|
42
44
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
43
45
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
44
46
|
}
|