@inetafrica/open-claudia 3.5.0 → 3.5.4

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +32 -2
  2. package/bin/cli.js +13 -5
  3. package/bin/loopback-client.js +23 -5
  4. package/bin/owner-gate.js +2 -1
  5. package/bin/schedule.js +7 -2
  6. package/bin/tool.js +2 -2
  7. package/bot.js +6 -2
  8. package/channels/kazee/adapter.js +462 -64
  9. package/channels/kazee/state.js +78 -2
  10. package/channels/types.js +11 -5
  11. package/core/actions.js +6 -0
  12. package/core/approvals.js +9 -0
  13. package/core/bot-endpoint.js +194 -0
  14. package/core/context.js +11 -2
  15. package/core/handlers.js +161 -59
  16. package/core/identity.js +33 -3
  17. package/core/io.js +21 -8
  18. package/core/jobs.js +3 -0
  19. package/core/loopback.js +55 -7
  20. package/core/providers/claude.js +6 -2
  21. package/core/router.js +25 -13
  22. package/core/run-context.js +49 -8
  23. package/core/runner.js +313 -126
  24. package/core/scheduler.js +64 -32
  25. package/core/side-chat.js +40 -11
  26. package/core/speaker-attest.js +2 -0
  27. package/core/state.js +317 -50
  28. package/core/system-prompt.js +61 -14
  29. package/core/tool-guard.js +15 -0
  30. package/core/transcripts.js +23 -6
  31. package/package.json +3 -3
  32. package/test-approval-cards.js +24 -0
  33. package/test-call-speech.js +5 -3
  34. package/test-fixtures/fake-agent-cli.js +52 -1
  35. package/test-provider-compaction.js +50 -11
  36. package/test-provider-gateway.js +31 -10
  37. package/test-provider-language.js +15 -1
  38. package/test-provider-parity-e2e.js +8 -2
  39. package/test-provider-scheduler.js +77 -20
  40. package/test-provider-session-commands.js +11 -2
  41. package/test-provider-side-chat.js +30 -2
  42. package/test-run-lock.js +122 -39
  43. package/test-thread-lanes.js +530 -0
  44. package/core/queue-drain.js +0 -25
  45. package/test-queue-routing.js +0 -52
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");
@@ -74,15 +76,17 @@ function uniqueId(prefix = "occ") {
74
76
  }
75
77
 
76
78
  // ── watch-check environment ────────────────────────────────────────────────
77
- // A watch's `check` runs a shell command. It used to inherit `env:
78
- // process.env` — every secret the bot process holds (provider credentials,
79
- // bot tokens, the loopback token), so a staff member's check could read them
80
- // all. Build the env explicitly instead: keep the harmless shell essentials
81
- // and the Open Claudia config dir (so `open-claudia ...` resolves its config),
82
- // then drop anything that looks like a credential (a broad denylist pattern)
83
- // plus the known-secret names the provider env-isolation layer already
84
- // strips. A short-lived, creator-scoped run is stamped on top (below) so the
85
- // check's reusable tools still attribute to whoever created the watch.
79
+ // A watch's `check` runs a shell command in the bot's process tree. Whose
80
+ // environment it gets depends on who set the watch:
81
+ // • a staff member's check gets an ALLOWLIST — the shell essentials and the
82
+ // Open Claudia config dir, nothing else — plus OC_GUARDED_RUN=1 so hooks
83
+ // and CLIs below it know they run for a non-owner. A denylist was not
84
+ // enough: any credential whose name matched no pattern (DATABASE_URL,
85
+ // MONGO_URI, …) reached a staff check (Phase 0 review, MEDIUM);
86
+ // • the owner's check keeps the bot's environment as it always had, so an
87
+ // owner watch that reads a credential from env keeps working.
88
+ // Either way a short-lived, creator-scoped run is stamped on top (below) so
89
+ // the check's reusable tools attribute to whoever created the watch.
86
90
  const WATCH_CHECK_ENV_KEEP = new Set([
87
91
  "PATH", "HOME", "USER", "LOGNAME", "SHELL",
88
92
  "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ",
@@ -91,34 +95,29 @@ const WATCH_CHECK_ENV_KEEP = new Set([
91
95
  "OPEN_CLAUDIA_CONFIG_DIR",
92
96
  "SystemRoot", "ComSpec", "PATHEXT", "WINDIR",
93
97
  ]);
94
- const WATCH_CHECK_SECRET_PATTERN = /(TOKEN|SECRET|PASSWORD|PASSWD|API[_-]?KEY|APIKEY|OAUTH|CREDENTIAL|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|CLIENT[_-]?SECRET|SESSION[_-]?KEY|BEARER)/i;
95
- let KNOWN_SECRET_ENV_KEYS = null;
96
- function knownSecretEnvKeys() {
97
- if (KNOWN_SECRET_ENV_KEYS) return KNOWN_SECRET_ENV_KEYS;
98
- const set = new Set();
99
- try { for (const key of require("./providers/env").ALWAYS_STRIPPED_KEYS) set.add(key); } catch (_) {}
100
- KNOWN_SECRET_ENV_KEYS = set;
101
- return set;
102
- }
103
- function isSecretCheckEnvKey(key) {
104
- if (WATCH_CHECK_ENV_KEEP.has(key)) return false;
105
- if (knownSecretEnvKeys().has(key)) return true;
106
- return WATCH_CHECK_SECRET_PATTERN.test(key);
107
- }
108
- function buildWatchCheckEnv(source, stamp = {}) {
98
+ function buildWatchCheckEnv(source, stamp = {}, { owner = false } = {}) {
109
99
  const env = {};
110
100
  for (const [key, value] of Object.entries(source || {})) {
111
101
  if (value == null) continue;
112
- if (isSecretCheckEnvKey(key)) continue;
102
+ if (!owner && !WATCH_CHECK_ENV_KEEP.has(key)) continue;
113
103
  env[key] = String(value);
114
104
  }
115
105
  for (const [key, value] of Object.entries(stamp)) {
116
106
  if (value == null || value === "") continue;
117
107
  env[key] = String(value);
118
108
  }
109
+ if (owner) delete env.OC_GUARDED_RUN;
110
+ else env.OC_GUARDED_RUN = "1";
119
111
  return env;
120
112
  }
121
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
+
122
121
  // The principal a watch-check's short-lived run acts for: the job's creator,
123
122
  // resolved from its stored channel/user the same way runPrincipal resolves a
124
123
  // turn's speaker. Owner → the check attributes as the owner (unchanged); a
@@ -146,6 +145,7 @@ function watchCheckPrincipal(job) {
146
145
  adapterId: job && job.adapter ? String(job.adapter) : null,
147
146
  adapterType,
148
147
  channelId,
148
+ threadId: jobThreadId(job),
149
149
  userId,
150
150
  room,
151
151
  };
@@ -248,7 +248,7 @@ function createSchedulerService(dependencies = {}) {
248
248
  }
249
249
  // Poll a watch's `check`: exit 0 → the watched condition is met, wake the
250
250
  // agent; non-zero → still idle, skip silently. Runs in the job's project
251
- // dir with a HARDENED env — the bot's secrets are dropped (buildWatchCheckEnv)
251
+ // dir with its creator's env (buildWatchCheckEnv: an allowlist for staff)
252
252
  // and a short-lived run is registered for the job's creator so reusable
253
253
  // tools attribute correctly — output discarded (exit code is the contract),
254
254
  // hard-capped by WATCH_CHECK_TIMEOUT_MS. Injectable so tests stay hermetic.
@@ -256,13 +256,14 @@ function createSchedulerService(dependencies = {}) {
256
256
  const cwd = (job && projectDescriptor(job).dir) || workspace || process.cwd();
257
257
  const attest = require("./speaker-attest");
258
258
  const checkRunId = uniqueId("watch-check");
259
+ const principal = watchCheckPrincipal(job);
259
260
  let runAttest = "";
260
- try { runAttest = attest.registerRun(checkRunId, watchCheckPrincipal(job)); } catch (_) { runAttest = ""; }
261
+ try { runAttest = attest.registerRun(checkRunId, principal); } catch (_) { runAttest = ""; }
261
262
  let lb = null;
262
263
  try { lb = require("./loopback").info(); } catch (_) { lb = null; }
263
264
  const stamp = { OC_RUN_ID: checkRunId, OC_RUN_ATTEST: runAttest };
264
265
  if (lb && lb.url) { stamp.OC_SEND_URL = lb.url; stamp.OC_SEND_TOKEN = lb.token; }
265
- const env = buildWatchCheckEnv(process.env, stamp);
266
+ const env = buildWatchCheckEnv(process.env, stamp, { owner: principal.owner === true });
266
267
  let settled = false;
267
268
  const finish = (code, timedOut) => {
268
269
  if (settled) return;
@@ -308,8 +309,26 @@ function createSchedulerService(dependencies = {}) {
308
309
  return { name, dir: dir || workspace || process.cwd() };
309
310
  }
310
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
+
311
321
  function resolveCompactionLineage(state, job) {
312
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
+ }
313
332
  if (!sessionId) return { sessionId: null, attachSession: true };
314
333
  const seen = new Set();
315
334
  while (sessionId && !seen.has(sessionId)) {
@@ -716,6 +735,9 @@ function createSchedulerService(dependencies = {}) {
716
735
  // shared session. Matches captured project.name (projectDescriptor(job).name)
717
736
  // so sameCapturedProject / getProjectKey resolve the right thread mid-run.
718
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),
719
741
  // …but the thread is not the room. A job belongs to the room it was
720
742
  // created in and must answer under THAT room's provider; without this the
721
743
  // room overlay can't resolve and the run silently falls back to the
@@ -730,7 +752,15 @@ function createSchedulerService(dependencies = {}) {
730
752
  return runInChat(chat, async () => {
731
753
  noteLegacyCreatorOnce(job);
732
754
  const state = stateForUser(canonicalUserId);
733
- if (state.runningProcess || state.preparingRun || state.isCompacting) {
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)) {
734
764
  try { return markBusy(job); }
735
765
  catch (error) { return keepRetrying(job, error, busyDelayMs); }
736
766
  }
@@ -954,7 +984,9 @@ function createSchedulerService(dependencies = {}) {
954
984
  const sessionId = Object.prototype.hasOwnProperty.call(spec, "sessionId")
955
985
  ? spec.sessionId
956
986
  : getProviderSession(state, provider, project);
957
- const originRunId = spec.originRunId || state.activeRunContext?.runId || null;
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;
958
990
  const providerSettings = spec.providerSettings || getProviderSettings(state, provider);
959
991
  return {
960
992
  ...spec,
@@ -1080,8 +1112,8 @@ module.exports = {
1080
1112
  parseFallbacks,
1081
1113
  terminalRunSucceeded,
1082
1114
  buildWatchCheckEnv,
1083
- isSecretCheckEnvKey,
1084
1115
  watchCheckPrincipal,
1116
+ jobThreadId,
1085
1117
  setAdapters,
1086
1118
  initScheduler,
1087
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.has(String(canonicalUserId));
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(String(canonicalUserId), { proc, done });
194
+ this.processByUser.set(sideChatKey(canonicalUserId, laneKey), { proc, done });
178
195
  }
179
196
 
180
- detachProcess(canonicalUserId, proc) {
181
- const key = String(canonicalUserId);
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
- async cancel(canonicalUserId) {
187
- const key = String(canonicalUserId);
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 users = new Set([...this.activeByUser.keys(), ...this.processByUser.keys()]);
212
- const outcomes = await Promise.all([...users].map((canonicalUserId) => this.cancel(canonicalUserId)));
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 = String(request.canonicalUserId);
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
  };
@@ -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),