@inetafrica/open-claudia 3.5.1 → 3.5.7
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 +13 -0
- package/CHANGELOG.md +36 -0
- package/bin/cli.js +13 -5
- package/bin/loopback-client.js +4 -0
- package/bin/schedule.js +7 -2
- package/bot.js +6 -2
- package/channels/kazee/adapter.js +519 -64
- package/channels/kazee/state.js +88 -2
- package/channels/types.js +11 -5
- package/core/actions.js +6 -0
- package/core/approvals.js +9 -0
- package/core/context.js +11 -2
- package/core/handlers.js +161 -59
- package/core/identity.js +33 -3
- package/core/io.js +21 -8
- package/core/jobs.js +3 -0
- package/core/loopback.js +55 -7
- package/core/providers/claude.js +6 -2
- package/core/providers/utility-policy.js +3 -1
- package/core/router.js +25 -13
- package/core/run-context.js +49 -8
- package/core/runner.js +318 -122
- package/core/sandbox-probe.js +78 -0
- package/core/scheduler.js +45 -3
- package/core/side-chat.js +40 -11
- package/core/speaker-attest.js +2 -0
- package/core/state.js +317 -50
- package/core/system-prompt.js +61 -14
- package/core/thread-title.js +237 -0
- package/core/transcripts.js +23 -6
- package/package.json +3 -3
- package/test-approval-cards.js +24 -0
- package/test-call-speech.js +5 -3
- package/test-fixtures/fake-agent-cli.js +52 -1
- package/test-provider-compaction.js +50 -11
- package/test-provider-gateway.js +31 -10
- package/test-provider-language.js +7 -1
- package/test-provider-scheduler.js +77 -20
- package/test-provider-session-commands.js +11 -2
- package/test-provider-side-chat.js +30 -2
- package/test-run-lock.js +122 -39
- package/test-thread-lanes.js +530 -0
- package/core/queue-drain.js +0 -25
- package/test-queue-routing.js +0 -52
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Whether this host can run the sandbox Claude Code needs for
|
|
4
|
+
// CLAUDE_CODE_SUBPROCESS_ENV_SCRUB (a staff turn's subprocesses never see the
|
|
5
|
+
// AI service credentials). On Linux that is bubblewrap, which needs
|
|
6
|
+
// unprivileged user namespaces. The fleet's containers have neither: no
|
|
7
|
+
// bubblewrap, and seccomp blocks user namespaces. There, setting the scrub
|
|
8
|
+
// makes Claude Code refuse to start, so every staff turn crashed
|
|
9
|
+
// (network-bot on 3.5.1, 26 Sep 2026). Where the probe fails, a staff turn
|
|
10
|
+
// runs as it did on 3.4.x: its subprocesses can see the AI credentials, as
|
|
11
|
+
// they always could there. macOS has its own sandbox and keeps the scrub.
|
|
12
|
+
//
|
|
13
|
+
// OC_STAFF_ENV_SCRUB=on|off overrides the probe (default auto).
|
|
14
|
+
|
|
15
|
+
const { spawnSync } = require("child_process");
|
|
16
|
+
|
|
17
|
+
let cached = null;
|
|
18
|
+
let logged = false;
|
|
19
|
+
|
|
20
|
+
function firstLine(text) {
|
|
21
|
+
return String(text || "").trim().split("\n")[0].slice(0, 200);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One probe, no caching: { ok, reason }. Injectable for tests. */
|
|
25
|
+
function probeSandbox({ platform = process.platform, env = process.env, spawn = spawnSync } = {}) {
|
|
26
|
+
const mode = String(env.OC_STAFF_ENV_SCRUB || "auto").trim().toLowerCase();
|
|
27
|
+
if (mode === "off") return { ok: false, reason: "OC_STAFF_ENV_SCRUB=off" };
|
|
28
|
+
if (mode === "on") return { ok: true, reason: "OC_STAFF_ENV_SCRUB=on" };
|
|
29
|
+
if (platform === "darwin") return { ok: true, reason: "macOS sandbox" };
|
|
30
|
+
if (platform !== "linux") return { ok: false, reason: `no sandbox on ${platform}` };
|
|
31
|
+
let r;
|
|
32
|
+
try {
|
|
33
|
+
r = spawn("bwrap", ["--ro-bind", "/", "/", "--dev", "/dev", "true"], {
|
|
34
|
+
timeout: 5000,
|
|
35
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
});
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return { ok: false, reason: `bubblewrap could not run: ${e.message}` };
|
|
40
|
+
}
|
|
41
|
+
if (r.error) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
reason: r.error.code === "ENOENT" ? "bubblewrap is not installed" : `bubblewrap could not run: ${r.error.code || r.error.message}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (r.status !== 0) {
|
|
48
|
+
const why = firstLine(r.stderr);
|
|
49
|
+
return { ok: false, reason: `bubblewrap cannot make a sandbox here${why ? `: ${why}` : ` (exit ${r.status})`}` };
|
|
50
|
+
}
|
|
51
|
+
return { ok: true, reason: "bubblewrap works" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The probe's answer for this process, asked once and remembered. The first
|
|
56
|
+
* answer is logged once, so a fleet bot's log says why the scrub is off.
|
|
57
|
+
*/
|
|
58
|
+
function staffEnvScrub(options) {
|
|
59
|
+
if (!cached) cached = probeSandbox(options);
|
|
60
|
+
if (!logged) {
|
|
61
|
+
logged = true;
|
|
62
|
+
const log = (options && options.log) || console.log;
|
|
63
|
+
log(`[guard] staff-turn credential scrub ${cached.ok ? "ON" : "OFF"} on this host: ${cached.reason}`);
|
|
64
|
+
}
|
|
65
|
+
return cached;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function _resetForTest() {
|
|
69
|
+
cached = null;
|
|
70
|
+
logged = false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _setForTest(answer) {
|
|
74
|
+
cached = answer;
|
|
75
|
+
logged = true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { probeSandbox, staffEnvScrub, _resetForTest, _setForTest };
|
package/core/scheduler.js
CHANGED
|
@@ -32,8 +32,10 @@ const {
|
|
|
32
32
|
getProviderSession,
|
|
33
33
|
getProviderSettings,
|
|
34
34
|
getUserState,
|
|
35
|
+
peekLane,
|
|
36
|
+
laneBusy,
|
|
35
37
|
} = require("./state");
|
|
36
|
-
const { canonicalForChannel, conversationKeyFor } = require("./identity");
|
|
38
|
+
const { canonicalForChannel, conversationKeyFor, parseConversationKey } = require("./identity");
|
|
37
39
|
const { send: defaultSend } = require("./io");
|
|
38
40
|
const { providerStatus: defaultProviderStatus } = require("./providers");
|
|
39
41
|
const { redactSensitive } = require("./redact");
|
|
@@ -109,6 +111,13 @@ function buildWatchCheckEnv(source, stamp = {}, { owner = false } = {}) {
|
|
|
109
111
|
return env;
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
// The chat thread a job answers in: stored on the job when it was created in a
|
|
115
|
+
// thread, else read off its conversation key (older jobs, CLI-made jobs).
|
|
116
|
+
function jobThreadId(job) {
|
|
117
|
+
if (!job) return null;
|
|
118
|
+
return job.threadId || parseConversationKey(job.project).threadId || null;
|
|
119
|
+
}
|
|
120
|
+
|
|
112
121
|
// The principal a watch-check's short-lived run acts for: the job's creator,
|
|
113
122
|
// resolved from its stored channel/user the same way runPrincipal resolves a
|
|
114
123
|
// turn's speaker. Owner → the check attributes as the owner (unchanged); a
|
|
@@ -136,6 +145,7 @@ function watchCheckPrincipal(job) {
|
|
|
136
145
|
adapterId: job && job.adapter ? String(job.adapter) : null,
|
|
137
146
|
adapterType,
|
|
138
147
|
channelId,
|
|
148
|
+
threadId: jobThreadId(job),
|
|
139
149
|
userId,
|
|
140
150
|
room,
|
|
141
151
|
};
|
|
@@ -299,8 +309,26 @@ function createSchedulerService(dependencies = {}) {
|
|
|
299
309
|
return { name, dir: dir || workspace || process.cwd() };
|
|
300
310
|
}
|
|
301
311
|
|
|
312
|
+
// The session of the main timeline of the room a thread job belongs to.
|
|
313
|
+
function roomMainSession(state, job) {
|
|
314
|
+
if (!jobThreadId(job)) return null;
|
|
315
|
+
const threadRoom = parseConversationKey(job.project).threadId ? parseConversationKey(job.project).roomKey : null;
|
|
316
|
+
const roomKey = threadRoom || (job.adapterType && job.channelId ? conversationKeyFor(job.adapterType, job.channelId) : null);
|
|
317
|
+
if (!roomKey || roomKey === job.project) return null;
|
|
318
|
+
return getProviderSession(state, job.provider, roomKey) || null;
|
|
319
|
+
}
|
|
320
|
+
|
|
302
321
|
function resolveCompactionLineage(state, job) {
|
|
303
322
|
let sessionId = job.sessionId || null;
|
|
323
|
+
// A thread job never resumes its room's MAIN session: a job made in a
|
|
324
|
+
// thread's first (forked) turn by an older build stored the main id, and
|
|
325
|
+
// resuming it would write the thread's wakeup into the main conversation,
|
|
326
|
+
// possibly beside a live main turn. Use the thread's own session instead.
|
|
327
|
+
const mainSession = sessionId ? roomMainSession(state, job) : null;
|
|
328
|
+
if (mainSession && sessionId === mainSession) {
|
|
329
|
+
const own = getProviderSession(state, job.provider, job.project);
|
|
330
|
+
sessionId = own && own !== mainSession ? own : null;
|
|
331
|
+
}
|
|
304
332
|
if (!sessionId) return { sessionId: null, attachSession: true };
|
|
305
333
|
const seen = new Set();
|
|
306
334
|
while (sessionId && !seen.has(sessionId)) {
|
|
@@ -707,6 +735,9 @@ function createSchedulerService(dependencies = {}) {
|
|
|
707
735
|
// shared session. Matches captured project.name (projectDescriptor(job).name)
|
|
708
736
|
// so sameCapturedProject / getProjectKey resolve the right thread mid-run.
|
|
709
737
|
conversationKey: projectDescriptor(job).name,
|
|
738
|
+
// A job made in a chat thread answers in that thread: its notice, its
|
|
739
|
+
// typing indicator and its reply all go back into the thread.
|
|
740
|
+
threadId: jobThreadId(job),
|
|
710
741
|
// …but the thread is not the room. A job belongs to the room it was
|
|
711
742
|
// created in and must answer under THAT room's provider; without this the
|
|
712
743
|
// room overlay can't resolve and the run silently falls back to the
|
|
@@ -721,7 +752,15 @@ function createSchedulerService(dependencies = {}) {
|
|
|
721
752
|
return runInChat(chat, async () => {
|
|
722
753
|
noteLegacyCreatorOnce(job);
|
|
723
754
|
const state = stateForUser(canonicalUserId);
|
|
724
|
-
|
|
755
|
+
// Busy means THIS job's conversation is running, compacting or has
|
|
756
|
+
// messages waiting; other conversations of the speaker may run meanwhile.
|
|
757
|
+
// The conversation it replies INTO counts too: a job whose project is not
|
|
758
|
+
// its chat's key (--project myrepo, a legacy basename) must not answer in
|
|
759
|
+
// a thread or timeline beside a live turn there.
|
|
760
|
+
const lane = peekLane(state, projectDescriptor(job).name);
|
|
761
|
+
const replyLane = peekLane(state, conversationKeyFor(adapter.type, job.channelId, jobThreadId(job)));
|
|
762
|
+
const occupied = (l) => laneBusy(l) || !!l?.messageQueue?.length;
|
|
763
|
+
if (occupied(lane) || occupied(replyLane)) {
|
|
725
764
|
try { return markBusy(job); }
|
|
726
765
|
catch (error) { return keepRetrying(job, error, busyDelayMs); }
|
|
727
766
|
}
|
|
@@ -945,7 +984,9 @@ function createSchedulerService(dependencies = {}) {
|
|
|
945
984
|
const sessionId = Object.prototype.hasOwnProperty.call(spec, "sessionId")
|
|
946
985
|
? spec.sessionId
|
|
947
986
|
: getProviderSession(state, provider, project);
|
|
948
|
-
|
|
987
|
+
// Fall back to the run of the job's own conversation only: another
|
|
988
|
+
// thread's running turn is not where this job came from.
|
|
989
|
+
const originRunId = spec.originRunId || (project ? peekLane(state, project)?.activeRunContext?.runId : null) || null;
|
|
949
990
|
const providerSettings = spec.providerSettings || getProviderSettings(state, provider);
|
|
950
991
|
return {
|
|
951
992
|
...spec,
|
|
@@ -1072,6 +1113,7 @@ module.exports = {
|
|
|
1072
1113
|
terminalRunSucceeded,
|
|
1073
1114
|
buildWatchCheckEnv,
|
|
1074
1115
|
watchCheckPrincipal,
|
|
1116
|
+
jobThreadId,
|
|
1075
1117
|
setAdapters,
|
|
1076
1118
|
initScheduler,
|
|
1077
1119
|
scheduleJob: service.scheduleJob,
|
package/core/side-chat.js
CHANGED
|
@@ -143,6 +143,23 @@ async function defaultDeliver(payload, store) {
|
|
|
143
143
|
return execute();
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// Side chats are per (speaker, conversation): a speaker's threads each get
|
|
147
|
+
// their own side answer, and /stop in one conversation cancels only its own.
|
|
148
|
+
// A bare canonical id (no lane) addresses every conversation of the speaker.
|
|
149
|
+
const LANE_SEP = "\u0000";
|
|
150
|
+
function sideChatKey(canonicalUserId, laneKey = null) {
|
|
151
|
+
return laneKey ? `${canonicalUserId}${LANE_SEP}${laneKey}` : String(canonicalUserId);
|
|
152
|
+
}
|
|
153
|
+
function keyMatches(key, canonicalUserId, laneKey = null) {
|
|
154
|
+
if (laneKey) return key === sideChatKey(canonicalUserId, laneKey);
|
|
155
|
+
const user = String(canonicalUserId);
|
|
156
|
+
return key === user || key.startsWith(`${user}${LANE_SEP}`);
|
|
157
|
+
}
|
|
158
|
+
function requestLaneKey(request) {
|
|
159
|
+
const runContext = request?.originalRunContext || request?.runContext || null;
|
|
160
|
+
return runContext?.laneKey || runContext?.project?.name || null;
|
|
161
|
+
}
|
|
162
|
+
|
|
146
163
|
class SideChatCoordinator {
|
|
147
164
|
constructor(options = {}) {
|
|
148
165
|
this.runSideChat = options.runSideChat || ((request) => require("./runner").runSideChatRequest(request));
|
|
@@ -163,28 +180,39 @@ class SideChatCoordinator {
|
|
|
163
180
|
return this.modeReader() === "agent";
|
|
164
181
|
}
|
|
165
182
|
|
|
166
|
-
isActive(canonicalUserId) {
|
|
167
|
-
return this.activeByUser.
|
|
183
|
+
isActive(canonicalUserId, laneKey = null) {
|
|
184
|
+
return [...this.activeByUser.keys()].some((key) => keyMatches(key, canonicalUserId, laneKey));
|
|
168
185
|
}
|
|
169
186
|
|
|
170
|
-
attachProcess(canonicalUserId, proc) {
|
|
187
|
+
attachProcess(canonicalUserId, proc, laneKey = null) {
|
|
171
188
|
if (!proc) return;
|
|
172
189
|
let resolveDone;
|
|
173
190
|
const done = new Promise((resolve) => { resolveDone = resolve; });
|
|
174
191
|
const settle = () => resolveDone();
|
|
175
192
|
proc.once("close", settle);
|
|
176
193
|
proc.once("error", settle);
|
|
177
|
-
this.processByUser.set(
|
|
194
|
+
this.processByUser.set(sideChatKey(canonicalUserId, laneKey), { proc, done });
|
|
178
195
|
}
|
|
179
196
|
|
|
180
|
-
detachProcess(canonicalUserId, proc) {
|
|
181
|
-
const key =
|
|
197
|
+
detachProcess(canonicalUserId, proc, laneKey = null) {
|
|
198
|
+
const key = sideChatKey(canonicalUserId, laneKey);
|
|
182
199
|
const entry = this.processByUser.get(key);
|
|
183
200
|
if (!proc || entry?.proc === proc) this.processByUser.delete(key);
|
|
184
201
|
}
|
|
185
202
|
|
|
186
|
-
|
|
187
|
-
|
|
203
|
+
// Cancel the side chat of one conversation (laneKey), or of every
|
|
204
|
+
// conversation of the speaker when no lane is named.
|
|
205
|
+
async cancel(canonicalUserId, laneKey = null) {
|
|
206
|
+
if (!laneKey) {
|
|
207
|
+
const keys = new Set([...this.activeByUser.keys(), ...this.processByUser.keys()]
|
|
208
|
+
.filter((key) => keyMatches(key, canonicalUserId)));
|
|
209
|
+
const outcomes = await Promise.all([...keys].map((key) => this.cancelKey(key)));
|
|
210
|
+
return outcomes.some(Boolean);
|
|
211
|
+
}
|
|
212
|
+
return this.cancelKey(sideChatKey(canonicalUserId, laneKey));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async cancelKey(key) {
|
|
188
216
|
const active = this.activeByUser.get(key);
|
|
189
217
|
if (active) this.cancelledRequests.add(active.token);
|
|
190
218
|
const entry = this.processByUser.get(key);
|
|
@@ -208,8 +236,8 @@ class SideChatCoordinator {
|
|
|
208
236
|
}
|
|
209
237
|
|
|
210
238
|
async cancelAll() {
|
|
211
|
-
const
|
|
212
|
-
const outcomes = await Promise.all([...
|
|
239
|
+
const keys = new Set([...this.activeByUser.keys(), ...this.processByUser.keys()]);
|
|
240
|
+
const outcomes = await Promise.all([...keys].map((key) => this.cancelKey(key)));
|
|
213
241
|
return outcomes.filter(Boolean).length;
|
|
214
242
|
}
|
|
215
243
|
|
|
@@ -258,7 +286,7 @@ class SideChatCoordinator {
|
|
|
258
286
|
|
|
259
287
|
async respond(input = {}) {
|
|
260
288
|
const request = this.makePending(input);
|
|
261
|
-
const userKey =
|
|
289
|
+
const userKey = sideChatKey(request.canonicalUserId, requestLaneKey(request));
|
|
262
290
|
if (this.activeByUser.has(userKey)) {
|
|
263
291
|
try {
|
|
264
292
|
const delivery = await this.deliver({
|
|
@@ -343,4 +371,5 @@ module.exports = {
|
|
|
343
371
|
createSideChatRequest,
|
|
344
372
|
readRuntimeMode,
|
|
345
373
|
sideChatCoordinator,
|
|
374
|
+
sideChatKey,
|
|
346
375
|
};
|
package/core/speaker-attest.js
CHANGED
|
@@ -88,6 +88,8 @@ function principalOf(principal = {}) {
|
|
|
88
88
|
adapterId: text(principal.adapterId),
|
|
89
89
|
adapterType: text(principal.adapterType),
|
|
90
90
|
channelId: text(principal.channelId),
|
|
91
|
+
// The chat thread the run answers in (null: the channel's main timeline).
|
|
92
|
+
threadId: text(principal.threadId),
|
|
91
93
|
userId: text(principal.userId),
|
|
92
94
|
room: text(principal.room),
|
|
93
95
|
purpose: text(principal.purpose),
|