@inetafrica/open-claudia 3.0.35 → 3.0.37

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.0.37 — Silent background watches, and pasted secrets are accepted on any provider
4
+
5
+ - **You can now arm a watch: a background poll that stays out of the chat and only wakes me when its condition is actually met.** A watch is just a cron carrying a shell `check` command — each tick runs the check with output discarded, and *only* an exit 0 wakes the agent (with a `🔔 Watch "<label>" triggered` notice and a normal reply). A non-zero exit is the idle case: nothing hits the chat, the poll's activity goes to an append-only `watch.log`, and the job stays pending for the next tick. Watches default to **one-shot** (fire once, then remove) so a matched condition can't spam you; add `--repeat` to keep polling. Create/manage from the CLI (`open-claudia watch-add "<check>" "<wake-prompt>" [--every "<cron>"] [--repeat]`, `watch-list`, `watch-remove`), and the owner can flip the global master switch with **`/watch on|off`** (watches are owner-gated because the check is raw shell). Rides the migration-verified jobs store untouched — `check`/`repeat` survive as extra fields, zero schema change.
6
+ - **Pasting a credential into the chat now stores it out-of-band regardless of which provider is active.** Codex refuses secrets in its prompt, so a pasted key under the Codex backend used to be rejected — and being told to re-paste it through keyring commands *in the same chat* was no safer and plain annoying. A loose paste is now classified (`core/credential-detect.js`) and captured **before** the turn reaches the model: a Claude OAuth token → `.env`+vault, an OpenAI key → handed to the Codex CLI, a recognised service token → the operational keyring, an ambiguous long token → scrubbed from chat. Every captured secret is registered for redaction and the original message deleted. The capture is provider-agnostic and never forwards the secret to any model.
7
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds FROM the existing `:base`. Covered by new `test-credential-capture.js` and watch probes in `test-provider-scheduler.js`. Existing pods pick this up on their next approved upgrade.
8
+
9
+ ## v3.0.36 — Picking a Claude model no longer forces a re-login
10
+
11
+ - **Selecting any Claude model claimed Claude wasn't connected and started a login — even while Claude was working fine.** The model-button handler (and the `/login` picker) decided "connected?" from a shallow token probe (`getClaudeOAuthToken().value`) that only sees an OAuth token in `.env`, the process env, or an unlocked vault. Anyone whose Claude login lives in the Claude CLI's own credential store (the normal `claude` login, no bot token) therefore read as *disconnected*, so every model tap answered "Claude isn't connected yet. Starting login…". The re-login it launched (`claude auth login --claudeai`) never stores a bot token, so the shallow check stayed false and the prompt returned on the very next tap — an unbreakable loop.
12
+ - **Both surfaces now resolve Claude's real auth state via `claudeProviderAuthStatus()`** — the same probe agent runs rely on, which counts a bot OAuth token *and* a `claude auth status` CLI-store login as authenticated. A model tap only starts a login when Claude is genuinely unauthenticated, mirroring the Codex branch beside it. Codex was never affected (it already checked `codex login status`). Cost: one `claude auth status` (~1s) on a model tap when no bot token exists — fine for a settings screen, and skipped entirely when a bot token is present.
13
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds FROM the existing `:base`. Existing pods pick this up on their next approved upgrade.
14
+
3
15
  ## v3.0.35 — Spaces comments are readable rich text, not a run-on wall
4
16
 
5
17
  - **The bot's Spaces replies rendered as unreadable walls.** `buildCommentContent` posted the entire reply as one text node inside one paragraph. Spaces comments are TipTap/ProseMirror docs: inside a single text node newlines don't render, so paragraphs, numbered lists and `---` separators collapsed into one run-on blob — and the model's markdown (`**bold**`, `*italic*`, backticks) printed its asterisks literally.
package/bin/cli.js CHANGED
@@ -306,6 +306,26 @@ switch (command) {
306
306
  require("./schedule").runCronRemove(args.slice(1));
307
307
  break;
308
308
  }
309
+ case "watch-add": {
310
+ require("./schedule").runWatchAdd(args.slice(1));
311
+ break;
312
+ }
313
+ case "watch-list": {
314
+ require("./schedule").runWatchList();
315
+ break;
316
+ }
317
+ case "watch-remove": {
318
+ require("./schedule").runWatchRemove(args.slice(1));
319
+ break;
320
+ }
321
+ case "watch-on": {
322
+ require("./schedule").runWatchToggle(true);
323
+ break;
324
+ }
325
+ case "watch-off": {
326
+ require("./schedule").runWatchToggle(false);
327
+ break;
328
+ }
309
329
 
310
330
  case "task": {
311
331
  require("./task").run(args.slice(1));
@@ -440,6 +460,10 @@ Background work (only inside an active bot-spawned task):
440
460
  open-claudia cron-add "<sched>" "<prompt>" Recurring job pinned to the selected provider session
441
461
  open-claudia cron-list List all wakeups + crons on this channel
442
462
  open-claudia cron-remove <id> Remove a scheduled job
463
+ open-claudia watch-add "<check>" "<prompt>" Silent monitor: poll a shell check, wake only when it exits 0
464
+ open-claudia watch-list List watches on this channel + master switch state
465
+ open-claudia watch-remove <id> Remove a watch
466
+ open-claudia watch-on | watch-off Global master switch for all watches (owner)
443
467
  open-claudia task add|list|start|done|remove Per-channel todo (survives restarts)
444
468
  open-claudia agent "<prompt>" [--role X] [--provider active|claude|codex] Spawn a provider-isolated sub-agent
445
469
 
package/bin/schedule.js CHANGED
@@ -15,8 +15,19 @@ Crons (recurring):
15
15
  open-claudia cron-list # all wakeups + crons for this channel
16
16
  open-claudia cron-remove <id> # remove by id from cron-list
17
17
 
18
- Each fire wakes the agent in the same channel and resumes the selected provider
19
- session captured when the schedule was created. Provider/project switches made
18
+ Watches (silent background monitor — polls a shell check, wakes you only on a hit):
19
+ open-claudia watch-add "<check-cmd>" "<wake-prompt>" [--label "<label>"] [--every "<cron>"] [--repeat]
20
+ <check-cmd>: a shell command polled on a schedule (default every minute).
21
+ exit 0 = condition met → wake the agent with <wake-prompt>.
22
+ non-zero = still idle → nothing happens, nothing hits the chat.
23
+ --repeat: keep polling after it fires (default: fire once, then remove).
24
+ open-claudia watch-list # watches on this channel + master switch state
25
+ open-claudia watch-remove <id> # remove a watch by id
26
+ open-claudia watch-on / watch-off # global master switch for ALL watches (owner)
27
+
28
+ The poll itself never touches the chat — only a real trigger wakes the agent
29
+ and produces a normal reply. Each fire resumes the selected provider session
30
+ captured when the schedule/watch was created. Provider/project switches made
20
31
  later do not retarget already-scheduled work.
21
32
  `;
22
33
 
@@ -123,10 +134,68 @@ async function runCronRemove(args) {
123
134
  } catch (e) { console.error(e.message); process.exit(1); }
124
135
  }
125
136
 
137
+ async function runWatchAdd(args) {
138
+ if (args.length < 2 || args[0] === "--help") { console.log(HELP); process.exit(args[0] === "--help" ? 0 : 2); }
139
+ const rest = args.slice();
140
+ const label = takeFlag(rest, "label");
141
+ const schedule = takeFlag(rest, "every");
142
+ const project = takeFlag(rest, "project");
143
+ const repeatIdx = rest.indexOf("--repeat");
144
+ const repeat = repeatIdx >= 0;
145
+ if (repeat) rest.splice(repeatIdx, 1);
146
+ const check = rest[0];
147
+ const prompt = rest.slice(1).join(" ");
148
+ if (!check || !prompt) { console.error("Usage: watch-add \"<check-cmd>\" \"<wake-prompt>\" [--every \"<cron>\"] [--repeat]"); process.exit(2); }
149
+ try {
150
+ const request = { check, prompt, repeat, label: label || prompt.slice(0, 60) };
151
+ if (schedule) request.schedule = schedule;
152
+ if (project) request.project = project;
153
+ const res = await postJson("watch-add", request);
154
+ console.log(`Added watch ${res.job.id} (${repeat ? "repeating" : "one-shot"}, polls "${res.job.schedule}"). Silent until the check passes.`);
155
+ process.exit(0);
156
+ } catch (e) { console.error(e.message); process.exit(1); }
157
+ }
158
+
159
+ async function runWatchList() {
160
+ try {
161
+ const res = await postJson("watch-list", {});
162
+ const list = res.watches || [];
163
+ console.log(`Watches: master switch is ${res.enabled ? "ON" : "OFF"}.`);
164
+ if (list.length === 0) { console.log("No watches on this channel."); process.exit(0); }
165
+ for (const w of list) {
166
+ const mode = w.repeat ? "repeating" : "one-shot";
167
+ console.log(`${w.id} ${mode} polls "${w.schedule}" check: ${w.check} → ${w.label || "(no label)"}`);
168
+ }
169
+ process.exit(0);
170
+ } catch (e) { console.error(e.message); process.exit(1); }
171
+ }
172
+
173
+ async function runWatchRemove(args) {
174
+ const id = args[0];
175
+ if (!id) { console.error("Usage: watch-remove <id>"); process.exit(2); }
176
+ try {
177
+ const res = await postJson("watch-remove", { id });
178
+ console.log(res.ok ? `Removed watch ${id}.` : `Not found: ${id}.`);
179
+ process.exit(res.ok ? 0 : 1);
180
+ } catch (e) { console.error(e.message); process.exit(1); }
181
+ }
182
+
183
+ async function runWatchToggle(enabled) {
184
+ try {
185
+ const res = await postJson("watch-toggle", { enabled });
186
+ console.log(`Watches ${res.enabled ? "ENABLED" : "DISABLED"} globally.`);
187
+ process.exit(0);
188
+ } catch (e) { console.error(e.message); process.exit(1); }
189
+ }
190
+
126
191
  module.exports = {
127
192
  HELP,
128
193
  runScheduleWakeup,
129
194
  runCronAdd,
130
195
  runCronList,
131
196
  runCronRemove,
197
+ runWatchAdd,
198
+ runWatchList,
199
+ runWatchRemove,
200
+ runWatchToggle,
132
201
  };
package/core/actions.js CHANGED
@@ -28,7 +28,7 @@ const jobs = require("./jobs");
28
28
  const scheduler = require("./scheduler");
29
29
  const { sideChatCoordinator } = require("./side-chat");
30
30
  const {
31
- getClaudeOAuthToken, runClaudeAuthCommand, runCodexLoginStatus, runCodexDeviceLogin,
31
+ claudeProviderAuthStatus, runClaudeAuthCommand, runCodexLoginStatus, runCodexDeviceLogin,
32
32
  } = require("./auth-flow");
33
33
 
34
34
  const REMOVED_PROVIDER_CALLBACK_MESSAGE = "This button is from an older Open Claudia release. Cursor Agent support has been removed. Open /backend to choose Claude Code or OpenAI Codex.";
@@ -335,7 +335,7 @@ async function handleAction(envelope) {
335
335
  const beLabel = provider.label;
336
336
  // Auth-aware: if the chosen provider isn't connected, start its login flow
337
337
  // now. The model is already set, so once auth completes the user is on it.
338
- if (be === "claude" && !getClaudeOAuthToken().value) {
338
+ if (be === "claude" && claudeProviderAuthStatus().state !== "authenticated") {
339
339
  if (!isChatOwner(envelope.channelId)) { await send("Claude isn't connected and only the owner can sign it in."); return; }
340
340
  await send(`Set to ${model}, but Claude isn't connected yet. Starting login — open the URL I send, then paste the code here.`);
341
341
  await runClaudeAuthCommand(["auth", "login", "--claudeai"], "Claude login");
package/core/auth-flow.js CHANGED
@@ -393,12 +393,14 @@ function codexAuthHelp(reason = "Codex authentication failed") {
393
393
  }
394
394
 
395
395
  // Quick "is this provider usable non-interactively" check for the unified
396
- // /login model picker. Claude is cheap (token presence is what the bot relies
397
- // on). Codex spawns `codex login status` once — fine for a setup screen.
396
+ // /login model picker. Each provider resolves its real auth state once (Claude
397
+ // via a bot token or the CLI credential store; Codex via `codex login status`)
398
+ // — fine for a setup screen, and it stops a CLI-store login from looking
399
+ // disconnected.
398
400
  function providerAuthStatus() {
399
401
  const binary = codexExecutable();
400
402
  return {
401
- claude: !!getClaudeOAuthToken().value,
403
+ claude: claudeProviderAuthStatus().state === "authenticated",
402
404
  codex: binary ? runCodexLoginStatus().ok : false,
403
405
  };
404
406
  }
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+
3
+ // Pure classification of a pasted chat message into a credential disposition.
4
+ // No side effects — the router performs the actual store/delete/confirm. Kept
5
+ // standalone (no requires) so it can be unit-tested without loading the
6
+ // router's dependency graph, and so the ordering rules live in one place.
7
+ //
8
+ // Disposition kinds:
9
+ // claude — a Claude Code OAuth token → saved to .env + vault
10
+ // openai — an OpenAI API key → handed to the Codex CLI
11
+ // keyring — another recognised secret → operational keyring (name provided)
12
+ // scrub — an ambiguous long token → delete from chat but still answer
13
+ // none — ordinary text — leave the message alone
14
+ //
15
+ // A recognised service prefix is claimed BEFORE the generic long-opaque Claude
16
+ // heuristic, otherwise a long GitHub/GitLab token would be mistaken for a
17
+ // Claude token. sk-ant- is claimed before the generic OpenAI sk- shape.
18
+
19
+ function classifyPastedCredential(raw) {
20
+ const text = String(raw || "").trim();
21
+ if (!text) return { kind: "none" };
22
+
23
+ if (/^sk-ant-[A-Za-z0-9._-]+$/.test(text)) return { kind: "claude" };
24
+ if (/^sk-(?:proj-)?[A-Za-z0-9._-]{20,}$/.test(text)) return { kind: "openai" };
25
+
26
+ const byPrefix = [
27
+ [/^glpat-[A-Za-z0-9._-]{8,}$/, "gitlab_token"],
28
+ [/^(?:ghp_|gho_|github_pat_)[A-Za-z0-9._-]{8,}$/, "github_token"],
29
+ [/^(?:xoxb-|xoxp-)[A-Za-z0-9-]{8,}$/, "slack_token"],
30
+ [/^AKIA[0-9A-Z]{8,}$/, "aws_access_key_id"],
31
+ [/^AIza[0-9A-Za-z._-]{10,}$/, "google_api_key"],
32
+ ];
33
+ for (const [re, name] of byPrefix) if (re.test(text)) return { kind: "keyring", name, value: text };
34
+
35
+ // A labelled secret ("token: <value>", "Bearer <value>") — only when the
36
+ // value is a single secret-shaped token, so "key: the answer is 42" is left
37
+ // as ordinary text rather than swallowed.
38
+ const labeled = text.match(/^(Bearer)\s+(\S{12,})$/i)
39
+ || text.match(/^(token|key|secret|password)\s*:\s*(\S{12,})$/i);
40
+ if (labeled) {
41
+ const name = labeled[1].toLowerCase() === "bearer" ? "bearer_token" : labeled[1].toLowerCase();
42
+ return { kind: "keyring", name, value: labeled[2] };
43
+ }
44
+
45
+ // Long opaque token with no sk- prefix → Claude OAuth token shape.
46
+ if (!/^sk-/.test(text) && text.length >= 80 && /^[A-Za-z0-9._=-]+$/.test(text)) return { kind: "claude" };
47
+
48
+ // Ambiguous long single token: scrub it from the chat (it may be a secret)
49
+ // but let the turn proceed so the agent can still respond.
50
+ if (/^[A-Za-z0-9_-]{20,}$/.test(text)) return { kind: "scrub" };
51
+
52
+ return { kind: "none" };
53
+ }
54
+
55
+ module.exports = { classifyPastedCredential };
package/core/handlers.js CHANGED
@@ -2105,6 +2105,79 @@ register({
2105
2105
  },
2106
2106
  });
2107
2107
 
2108
+ // ── Watch (silent background monitor) ──────────────────────────────
2109
+ // A watch is a cron carrying a shell `check`. The scheduler polls the check
2110
+ // silently — nothing reaches the chat — and wakes the agent with `prompt`
2111
+ // only when the check exits 0. Owner-only: the check is arbitrary shell on
2112
+ // the bot host.
2113
+
2114
+ register({
2115
+ name: "watch", description: "Silent background monitor: poll a shell check, ping you only on a hit", args: "[add \"<check>\" \"<prompt>\" [repeat] | remove <#> | on | off]", ownerOnly: true,
2116
+ handler: async (env, { tail }) => {
2117
+ if (!ownerEnv(env)) return send("Owner only — a watch runs shell commands on my host.");
2118
+ const channelWatches = () => jobs.listForChannel(env.adapter.id, env.channelId).filter((j) => !!j.check);
2119
+
2120
+ if (!tail) {
2121
+ const list = channelWatches();
2122
+ const master = scheduler.watchesEnabled() ? "ON" : "OFF";
2123
+ if (list.length === 0) {
2124
+ return send([
2125
+ `No watches. Master switch: ${master}.`,
2126
+ "",
2127
+ "A watch silently polls a shell check and only pings you when it passes — nothing hits the chat until then.",
2128
+ "",
2129
+ 'Add: /watch add "<check-cmd>" "<wake-prompt>"',
2130
+ 'e.g. /watch add "curl -sf https://myapp/health || exit 1" "myapp health check failed — take a look"',
2131
+ "Add `repeat` at the end to keep polling after it fires (default: one-shot).",
2132
+ ].join("\n"));
2133
+ }
2134
+ return send(
2135
+ `Watches (master switch: ${master}):\n\n` +
2136
+ list.map((w, i) => `${i + 1}. ${w.label} — polls "${w.schedule}"${w.repeat ? " (repeating)" : " (one-shot)"}\n check: ${w.check}`).join("\n") +
2137
+ "\n\nRemove: /watch remove <#>\nToggle all: /watch on | /watch off",
2138
+ );
2139
+ }
2140
+
2141
+ if (/^on$/i.test(tail)) {
2142
+ scheduler.setWatchesEnabled(true);
2143
+ return send("✅ Watches ENABLED. Existing watches resume polling.");
2144
+ }
2145
+ if (/^off$/i.test(tail)) {
2146
+ scheduler.setWatchesEnabled(false);
2147
+ return send("⏸️ Watches DISABLED globally. Polling is paused and nothing will fire until /watch on. Your watches are kept.");
2148
+ }
2149
+
2150
+ const addMatch = tail.match(/^add\s+"([^"]+)"\s+"([^"]+)"(?:\s+(repeat))?$/i);
2151
+ if (addMatch) {
2152
+ const job = scheduler.addWatch({
2153
+ adapter: env.adapter.id,
2154
+ adapterType: env.adapter.type,
2155
+ channelId: String(env.channelId),
2156
+ canonicalUserId: env.canonicalUserId,
2157
+ project: "Workspace",
2158
+ projectDir: WORKSPACE,
2159
+ check: addMatch[1],
2160
+ prompt: addMatch[2],
2161
+ label: addMatch[2].slice(0, 50),
2162
+ source: "user",
2163
+ repeat: !!addMatch[3],
2164
+ });
2165
+ return send(`👁️ Watching (${addMatch[3] ? "repeating" : "one-shot"}, polls "${job.schedule}"). I'll stay quiet until the check passes, then ping you.`);
2166
+ }
2167
+
2168
+ const removeMatch = tail.match(/^remove\s+(\d+)$/);
2169
+ if (removeMatch) {
2170
+ const list = channelWatches();
2171
+ const idx = parseInt(removeMatch[1], 10) - 1;
2172
+ if (idx < 0 || idx >= list.length) return send("Invalid number.");
2173
+ const removed = scheduler.removeJob(list[idx].id);
2174
+ return send(`Removed watch: ${removed ? removed.label : "(unknown)"}`);
2175
+ }
2176
+
2177
+ send('Usage: /watch | /watch add "<check-cmd>" "<wake-prompt>" [repeat] | /watch remove <#> | /watch on | /watch off');
2178
+ },
2179
+ });
2180
+
2108
2181
  // ── Channels ───────────────────────────────────────────────────────
2109
2182
 
2110
2183
  register({
package/core/loopback.js CHANGED
@@ -74,6 +74,7 @@ function readBodyToFile(req, dest) {
74
74
  const SEND_KINDS = new Set(["send-file", "send-voice", "send-photo"]);
75
75
  const JSON_KINDS = new Set([
76
76
  "schedule-wakeup", "cron-add", "cron-remove", "job-list",
77
+ "watch-add", "watch-list", "watch-remove", "watch-toggle",
77
78
  "task-add", "task-plan", "task-update", "task-remove", "task-list", "task-get", "task-tree", "task-clear-completed",
78
79
  "people-list", "people-show", "people-add", "people-note", "people-link", "people-unlink",
79
80
  "people-remove", "people-set-primary",
@@ -238,6 +239,57 @@ async function handleJson(req, res, url, kind) {
238
239
  return reply(res, 200, { ok: true, jobs: list, archived });
239
240
  }
240
241
 
242
+ // A watch is a cron carrying a shell `check`; the scheduler polls the check
243
+ // silently and only wakes the agent (with `prompt`) when it exits 0.
244
+ if (kind === "watch-add") {
245
+ if (!payload.check || !payload.prompt) return reply(res, 400, { error: "missing check/prompt" });
246
+ const job = scheduler.addWatch({
247
+ adapter: adapterId,
248
+ adapterType: adapter.type,
249
+ channelId,
250
+ canonicalUserId: payload.canonicalUserId || null,
251
+ userId: payload.userId || null,
252
+ provider: payload.provider || null,
253
+ providerSettings: payload.providerSettings || {},
254
+ project: payload.project || null,
255
+ projectDir: payload.projectDir || null,
256
+ check: String(payload.check),
257
+ prompt: String(payload.prompt),
258
+ label: String(payload.label || payload.prompt).slice(0, 60),
259
+ schedule: payload.schedule ? String(payload.schedule) : undefined,
260
+ repeat: !!payload.repeat,
261
+ source: payload.source || "agent",
262
+ sessionId: payload.sessionId || null,
263
+ previousSessionId: payload.previousSessionId || null,
264
+ originRunId: payload.originRunId || null,
265
+ sessionLineage: {
266
+ originRunId: payload.originRunId || null,
267
+ originSessionId: payload.sessionId || null,
268
+ previousSessionId: payload.previousSessionId || null,
269
+ originProject: payload.originProject || payload.project || null,
270
+ },
271
+ });
272
+ return reply(res, 200, { ok: true, job });
273
+ }
274
+
275
+ if (kind === "watch-list") {
276
+ const list = jobsStore.listForChannel(adapterId, channelId).filter((job) => !!job.check);
277
+ return reply(res, 200, { ok: true, watches: list, enabled: scheduler.watchesEnabled() });
278
+ }
279
+
280
+ if (kind === "watch-remove") {
281
+ const id = payload.id || url.searchParams.get("id");
282
+ if (!id) return reply(res, 400, { error: "missing id" });
283
+ const removed = scheduler.removeJob(id);
284
+ return reply(res, removed ? 200 : 404, { ok: !!removed, removed });
285
+ }
286
+
287
+ if (kind === "watch-toggle") {
288
+ if (typeof payload.enabled !== "boolean") return reply(res, 400, { error: "missing enabled boolean" });
289
+ const enabled = scheduler.setWatchesEnabled(payload.enabled);
290
+ return reply(res, 200, { ok: true, enabled });
291
+ }
292
+
241
293
  if (kind === "task-add") {
242
294
  if (!payload.content) return reply(res, 400, { error: "missing content" });
243
295
  try {
package/core/router.js CHANGED
@@ -22,8 +22,10 @@ const {
22
22
  clearPendingClaudeAuth, clearPendingCodexAuth,
23
23
  sendClaudeAuthStatusSummary, sendCodexAuthStatusSummary,
24
24
  } = require("./auth-flow");
25
- const { redactSensitive } = require("./redact");
25
+ const { redactSensitive, registerSecrets } = require("./redact");
26
26
  const { MAX_FILE_SIZE, MAX_VOICE_SIZE, FILES_DIR } = require("./config");
27
+ const keyring = require("./keyring");
28
+ const { classifyPastedCredential } = require("./credential-detect");
27
29
  const channelWizard = require("./channel-wizard");
28
30
  require("./handlers"); // side-effect: register slash commands
29
31
 
@@ -272,6 +274,51 @@ async function handleDocument(envelope) {
272
274
  } catch (err) { await send(`Failed: ${err.message}`); }
273
275
  }
274
276
 
277
+ // Provider-agnostic capture of a credential pasted into chat with no auth
278
+ // flow active. Before v3.0.37 such a paste was merely deleted and then still
279
+ // forwarded to the model — which, under Codex, refused it as a policy
280
+ // violation. Now the router classifies it and stores it out-of-band (Claude
281
+ // token → .env/vault, OpenAI key → Codex CLI, other recognised secrets →
282
+ // operational keyring), deletes the message, confirms, and stops. The model
283
+ // never sees the secret. Returns true when it fully handled the message
284
+ // (caller must stop), false to let the turn proceed normally.
285
+ async function captureLooseCredential(text, envelope) {
286
+ const disposition = classifyPastedCredential(text);
287
+ switch (disposition.kind) {
288
+ case "claude": {
289
+ await deleteMessage(envelope.messageId);
290
+ registerSecrets(text);
291
+ saveClaudeOAuthToken(text);
292
+ await send(`Saved your Claude token to .env${vault.isUnlocked() ? " and vault" : ""} and deleted your message so it isn't left in chat. Restart with /restart so I pick it up.`);
293
+ return true;
294
+ }
295
+ case "openai": {
296
+ await deleteMessage(envelope.messageId);
297
+ registerSecrets(text);
298
+ const result = await saveCodexApiKeyWithCli(text);
299
+ await send(result.ok
300
+ ? "Saved your OpenAI API key via the Codex CLI and deleted your message. I never printed it."
301
+ : `Deleted your message, but the Codex CLI couldn't store that key: ${redactSensitive(result.output).slice(-400)}`);
302
+ return true;
303
+ }
304
+ case "keyring": {
305
+ await deleteMessage(envelope.messageId);
306
+ registerSecrets(disposition.value);
307
+ keyring.set(disposition.name, disposition.value);
308
+ await send(`Stored that secret in the operational keyring as \`${disposition.name}\` and deleted your message. Manage it with /keyring.`);
309
+ return true;
310
+ }
311
+ case "scrub": {
312
+ // Ambiguous long token: strip it from the chat but let the turn proceed
313
+ // (it may be an id/arg the agent legitimately needs to act on).
314
+ await deleteMessage(envelope.messageId);
315
+ return false;
316
+ }
317
+ default:
318
+ return false;
319
+ }
320
+ }
321
+
275
322
  async function handleText(envelope) {
276
323
  if (isDuplicate(envelope)) return;
277
324
  const state = currentState();
@@ -448,17 +495,14 @@ async function handleText(envelope) {
448
495
  }
449
496
 
450
497
  const text = envelope.text || "";
498
+
499
+ // Loose credential paste with no auth flow active: store it out-of-band and
500
+ // stop, instead of forwarding a secret to the model (Codex refuses those).
501
+ if (await captureLooseCredential(text.trim(), envelope)) return;
502
+
451
503
  const runContext = admitRunContext(text, state.currentSession.dir, envelope.messageId, { purpose: "foreground" });
452
504
  const attachments = [];
453
505
  const imagePaths = [];
454
- const credPatterns = [
455
- /^(sk-ant-|sk-|glpat-|ghp_|gho_|github_pat_|xoxb-|xoxp-|AKIA|AIza)/,
456
- /^[A-Za-z0-9_-]{20,}$/,
457
- /^(Bearer |token:|key:|secret:|password:)/i,
458
- ];
459
- if (credPatterns.some((p) => p.test(text.trim()))) {
460
- await deleteMessage(envelope.messageId);
461
- }
462
506
 
463
507
  let prompt = text;
464
508
  const reply = envelope.reply;
package/core/scheduler.js CHANGED
@@ -10,6 +10,8 @@ const crypto = require("crypto");
10
10
  const fs = require("fs");
11
11
  const path = require("path");
12
12
  const cron = require("node-cron");
13
+ const { spawn } = require("child_process");
14
+ const CONFIG_DIR = require("../config-dir");
13
15
 
14
16
  const {
15
17
  CRONS_FILE,
@@ -34,6 +36,10 @@ const { redactSensitive } = require("./redact");
34
36
  const MISSED_WAKEUP_GRACE_MS = 60 * 60 * 1000;
35
37
  const RETRY_DELAY_MS = 30 * 1000;
36
38
  const BUSY_DELAY_MS = 30 * 1000;
39
+ // A watch's `check` command is capped so a hung poll can't wedge the tick.
40
+ const WATCH_CHECK_TIMEOUT_MS = 30 * 1000;
41
+ const WATCH_LOG_FILE = process.env.OPEN_CLAUDIA_WATCH_LOG_FILE || path.join(CONFIG_DIR, "watch.log");
42
+ const WATCHES_ENABLED_FILE = process.env.OPEN_CLAUDIA_WATCHES_ENABLED_FILE || path.join(CONFIG_DIR, "watches-enabled");
37
43
 
38
44
  function uniqueId(prefix = "occ") {
39
45
  return typeof crypto.randomUUID === "function"
@@ -53,6 +59,13 @@ function terminalRunSucceeded(result) {
53
59
  return true;
54
60
  }
55
61
 
62
+ // A one-shot fires exactly once then is removed after a successful run: a
63
+ // wakeup, or a watch (a cron carrying a shell `check`) created without repeat
64
+ // polling. A repeat watch stays pending and keeps polling like any cron.
65
+ function isOneShot(job) {
66
+ return job.kind === "wakeup" || (!!job.check && !job.repeat);
67
+ }
68
+
56
69
  function providerIsRunnable(status) {
57
70
  if (!status || status.availability?.state !== "available") return false;
58
71
  if (status.compatibility?.state === "incompatible") return false;
@@ -96,6 +109,30 @@ function createSchedulerService(dependencies = {}) {
96
109
  const runInProvider = dependencies.runAgent || ((...args) => require("./runner").runAgent(...args));
97
110
  const admit = dependencies.admitRunContext || ((...args) => require("./runner").admitRunContext(...args));
98
111
  const timers = new Map();
112
+ const checksInFlight = new Set();
113
+ // Poll a watch's `check`: exit 0 → the watched condition is met, wake the
114
+ // agent; non-zero → still idle, skip silently. Runs in the job's project
115
+ // dir with the process env, output discarded (exit code is the contract),
116
+ // hard-capped by WATCH_CHECK_TIMEOUT_MS. Injectable so tests stay hermetic.
117
+ const runWatchCheck = dependencies.runWatchCheck || ((command, job) => new Promise((resolve) => {
118
+ const cwd = (job && projectDescriptor(job).dir) || workspace || process.cwd();
119
+ let settled = false;
120
+ const finish = (code, timedOut) => {
121
+ if (settled) return;
122
+ settled = true;
123
+ try { clearTimer(timer); } catch (_) {}
124
+ resolve({ code, timedOut: !!timedOut });
125
+ };
126
+ let child;
127
+ try {
128
+ child = spawn("sh", ["-c", String(command)], { cwd, env: process.env, stdio: "ignore" });
129
+ } catch (error) {
130
+ return resolve({ code: 127, timedOut: false });
131
+ }
132
+ const timer = setTimer(() => { try { child.kill("SIGKILL"); } catch (_) {} finish(124, true); }, WATCH_CHECK_TIMEOUT_MS);
133
+ child.on("close", (code) => finish(code == null ? 1 : code, false));
134
+ child.on("error", () => finish(127, false));
135
+ }));
99
136
  let adapters = Array.isArray(dependencies.adapters) ? dependencies.adapters.slice() : [];
100
137
 
101
138
  function setAdapters(list) {
@@ -186,7 +223,7 @@ function createSchedulerService(dependencies = {}) {
186
223
 
187
224
  function wrappedPrompt(job, target) {
188
225
  const firedAt = new Date(now()).toISOString();
189
- const header = job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
226
+ const header = job.check ? "Watch triggered" : job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
190
227
  if (target.fallbackFrom) {
191
228
  return [
192
229
  `[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`,
@@ -270,7 +307,7 @@ function createSchedulerService(dependencies = {}) {
270
307
  catch (error) { return { ok: false, status: "failed", error: stableFailure(error), result }; }
271
308
  if (!marked) return { ok: false, status: "failed", error: { code: "JOB_PERSIST_FAILED", message: "Scheduled success marker was not persisted" }, result };
272
309
 
273
- if (job.kind === "wakeup") {
310
+ if (isOneShot(job)) {
274
311
  try {
275
312
  const removed = jobsStore.remove(job.id);
276
313
  if (!removed) throw new Error("successful one-shot could not be removed after its durable success marker");
@@ -308,12 +345,34 @@ function createSchedulerService(dependencies = {}) {
308
345
  if (job.status === "running" && Number(job.nextAttemptAt || 0) > now()) {
309
346
  return { ok: false, status: "running", skipped: true, inFlight: true };
310
347
  }
311
- if (job.status === "succeeded" && job.kind === "wakeup") {
348
+ if (job.status === "succeeded" && isOneShot(job)) {
312
349
  try { jobsStore.remove(job.id); } catch (_) {}
313
350
  unscheduleJob(job.id);
314
351
  return { ok: true, status: "succeeded", finalized: true };
315
352
  }
316
353
 
354
+ // Watch poll gate. A watch is a cron carrying a shell `check`; each tick
355
+ // runs the check and only wakes the agent when it exits 0. A non-zero exit
356
+ // is the normal idle case: skip silently, mark nothing, leave the job
357
+ // pending so the next tick polls again. Never marks failure; never chats.
358
+ if (job.check) {
359
+ if (!watchesEnabled()) {
360
+ logWatch(job, "skipped (watches disabled globally)");
361
+ return { ok: false, status: "watch-disabled", skipped: true };
362
+ }
363
+ if (checksInFlight.has(job.id)) return { ok: false, status: "watch-checking", skipped: true };
364
+ checksInFlight.add(job.id);
365
+ let check;
366
+ try { check = await runWatchCheck(job.check, job); }
367
+ catch (_) { check = { code: 1, timedOut: false }; }
368
+ finally { checksInFlight.delete(job.id); }
369
+ if (!check || check.code !== 0) {
370
+ logWatch(job, check && check.timedOut ? "idle (check timed out)" : `idle (check exit ${check ? check.code : "?"})`);
371
+ return { ok: false, status: "watch-idle", skipped: true };
372
+ }
373
+ logWatch(job, "triggered (check passed) — waking agent");
374
+ }
375
+
317
376
  const adapter = resolveAdapter(job);
318
377
  if (!adapter) {
319
378
  try { return markFailure(job, { code: "SCHEDULED_ADAPTER_UNAVAILABLE", message: "Saved channel adapter is unavailable" }); }
@@ -385,7 +444,7 @@ function createSchedulerService(dependencies = {}) {
385
444
  return { ok: false, status: "failed", error: stableFailure(error) };
386
445
  }
387
446
 
388
- try { await send(`Wakeup: ${job.label}`); } catch (_) { /* advisory only */ }
447
+ try { await send(job.check ? `🔔 Watch "${job.label}" triggered` : `Wakeup: ${job.label}`); } catch (_) { /* advisory only */ }
389
448
  let result;
390
449
  try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
391
450
  catch (error) {
@@ -403,7 +462,7 @@ function createSchedulerService(dependencies = {}) {
403
462
  function scheduleJob(job) {
404
463
  unscheduleJob(job.id);
405
464
  if (!job || job.disabled || ["archived", "dead-letter"].includes(job.status)) return false;
406
- if (job.status === "succeeded" && job.kind === "wakeup") {
465
+ if (job.status === "succeeded" && isOneShot(job)) {
407
466
  try { jobsStore.remove(job.id); } catch (_) {}
408
467
  return false;
409
468
  }
@@ -514,6 +573,41 @@ function createSchedulerService(dependencies = {}) {
514
573
  return job;
515
574
  }
516
575
 
576
+ // Global master switch for all watches (owner's /watch off). Absent file →
577
+ // enabled (default ON). Only the poll gate consults it; wakeups/crons are
578
+ // untouched.
579
+ function watchesEnabled() {
580
+ try { return fs.readFileSync(WATCHES_ENABLED_FILE, "utf8").trim() !== "off"; }
581
+ catch (_) { return true; }
582
+ }
583
+
584
+ function setWatchesEnabled(on) {
585
+ try { fs.writeFileSync(WATCHES_ENABLED_FILE, on ? "on" : "off"); } catch (_) {}
586
+ return !!on;
587
+ }
588
+
589
+ // Append-only audit of watch poll activity. NEVER sent to chat — this is the
590
+ // whole point of a watch: the polling stays out of the conversation, only a
591
+ // real trigger wakes the agent and surfaces in chat.
592
+ function logWatch(job, event) {
593
+ const line = `[${new Date(now()).toISOString()}] job=${job.id} label=${JSON.stringify(job.label || "")} ${event}\n`;
594
+ try { fs.appendFileSync(WATCH_LOG_FILE, line); } catch (_) {}
595
+ }
596
+
597
+ // A watch is a cron carrying a shell `check`; it rides the migration-verified
598
+ // jobs store untouched (check/repeat survive normalizeRunnableJob as extra
599
+ // fields). Default schedule polls every minute; default is one-shot.
600
+ function addWatch(spec) {
601
+ return addJob({
602
+ ...spec,
603
+ kind: "cron",
604
+ schedule: spec.schedule || "* * * * *",
605
+ check: String(spec.check || "").trim(),
606
+ repeat: !!spec.repeat,
607
+ label: spec.label || String(spec.prompt || "watch").slice(0, 60),
608
+ });
609
+ }
610
+
517
611
  function removeJob(id) {
518
612
  unscheduleJob(id);
519
613
  return jobsStore.remove(id);
@@ -529,6 +623,9 @@ function createSchedulerService(dependencies = {}) {
529
623
  scheduleJob,
530
624
  unscheduleJob,
531
625
  addJob,
626
+ addWatch,
627
+ watchesEnabled,
628
+ setWatchesEnabled,
532
629
  removeJob,
533
630
  stopAll,
534
631
  fireJob,
@@ -561,6 +658,9 @@ module.exports = {
561
658
  scheduleJob: service.scheduleJob,
562
659
  unscheduleJob: service.unscheduleJob,
563
660
  addJob: service.addJob,
661
+ addWatch: service.addWatch,
662
+ watchesEnabled: service.watchesEnabled,
663
+ setWatchesEnabled: service.setWatchesEnabled,
564
664
  removeJob: service.removeJob,
565
665
  stopAll: service.stopAll,
566
666
  fireJob: service.fireJob,
@@ -241,6 +241,12 @@ Wake-ups and crons (real schedulers, not hallucinations — use them instead of
241
241
  - \`open-claudia cron-list\` — list all wakeups + crons on this channel.
242
242
  - \`open-claudia cron-remove <id>\` — cancel one.
243
243
 
244
+ Watches (a silent background monitor — poll a condition WITHOUT waking every tick; wake only when it actually happens). Use this instead of a minute-by-minute cron whenever you'd otherwise fire a turn just to check "is it done yet?" and stay quiet most times:
245
+ - \`open-claudia watch-add "<check-cmd>" "<wake-prompt>"\` — the scheduler polls \`<check-cmd>\` as a shell command (default every minute). The poll is SILENT: nothing reaches the chat while the check keeps failing. The instant \`<check-cmd>\` exits 0, you wake in this same conversation with \`<wake-prompt>\`, exactly like a wakeup. Non-zero exit = still waiting = nothing happens. Default is ONE-SHOT (fires once, then removes itself); add \`--repeat\` to keep polling after each hit. \`--every "<5-field cron>"\` changes the poll cadence.
246
+ - \`open-claudia watch-list\` / \`open-claudia watch-remove <id>\` — inspect/cancel watches on this channel.
247
+ - The owner has a global master switch: \`/watch off\` pauses ALL watch polling (and \`/watch on\` resumes). If \`watch-list\` shows the switch OFF, watches won't fire until it's back on.
248
+ - Example: after kicking off a long external job, \`open-claudia watch-add "test -f /tmp/build-done || exit 1" "the build finished — verify and report"\` beats a wakeup guess, because it fires the moment it's actually done, not on a fixed timer. (Never use a watch to poll harness-tracked work you started — that notifies you automatically. Watches are for EXTERNAL state the harness can't see.)
249
+
244
250
  The selected coding-agent provider may expose native scheduling tools. Do NOT use those for Open Claudia wakeups or crons: a provider runs as a per-turn subprocess, so its timers do not belong to the long-running bot. Only the \`open-claudia\` CLI commands above are authoritative and survive.
245
251
 
246
252
  Persistent todo list with plans + subtasks (per channel; survives compaction and restart).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.0.35",
3
+ "version": "3.0.37",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "setup": "node setup.js",
11
11
  "start": "node bot.js",
12
- "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js",
12
+ "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js",
13
13
  "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js"
14
14
  },
15
15
  "files": [
@@ -95,6 +95,7 @@
95
95
  "test-provider-session-commands.js",
96
96
  "test-provider-compaction.js",
97
97
  "test-provider-scheduler.js",
98
+ "test-credential-capture.js",
98
99
  "test-cursor-removal.js",
99
100
  "test-utility-provider-policy.js",
100
101
  "test-provider-subagent.js",
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ // Loose credential capture: a pasted secret must be stored out-of-band and the
6
+ // turn stopped BEFORE the message reaches the model — regardless of which
7
+ // provider is active. This is the Codex complaint: Codex refuses secrets in its
8
+ // prompt, so the harness has to intercept the paste itself rather than forward
9
+ // it. Two layers are covered here:
10
+ // 1. the pure classifier (core/credential-detect.js) — the decision logic
11
+ // 2. the router wiring (core/router.js) — capture precedes admitRunContext and
12
+ // each disposition routes to the right sink, provider-agnostically.
13
+
14
+ const assert = require("assert");
15
+ const fs = require("fs");
16
+ const path = require("path");
17
+
18
+ const root = __dirname;
19
+ const { classifyPastedCredential } = require("./core/credential-detect");
20
+
21
+ function classifierProbe() {
22
+ const kind = (text) => classifyPastedCredential(text).kind;
23
+
24
+ // Claude OAuth: the sk-ant- prefix and the long opaque no-sk shape.
25
+ assert.strictEqual(kind("sk-ant-oat01-" + "A".repeat(40)), "claude");
26
+ assert.strictEqual(kind("x".repeat(80)), "claude", "long opaque token with no sk- prefix reads as a Claude token");
27
+ assert.strictEqual(kind("A1b2C3d4_" + "e".repeat(80)), "claude");
28
+
29
+ // OpenAI API keys go to the Codex CLI — the whole point of the fix.
30
+ assert.strictEqual(kind("sk-" + "a".repeat(40)), "openai");
31
+ assert.strictEqual(kind("sk-proj-" + "b".repeat(40)), "openai");
32
+
33
+ // Recognised service prefixes are claimed before the generic Claude heuristic.
34
+ const keyring = (text) => {
35
+ const d = classifyPastedCredential(text);
36
+ return `${d.kind}:${d.name}`;
37
+ };
38
+ assert.strictEqual(keyring("glpat-" + "x".repeat(20)), "keyring:gitlab_token");
39
+ assert.strictEqual(keyring("ghp_" + "x".repeat(36)), "keyring:github_token");
40
+ assert.strictEqual(keyring("github_pat_" + "x".repeat(40)), "keyring:github_token");
41
+ assert.strictEqual(keyring("xoxb-" + "1".repeat(20)), "keyring:slack_token");
42
+ assert.strictEqual(keyring("AKIA" + "ABCDEFGH1234"), "keyring:aws_access_key_id");
43
+ assert.strictEqual(keyring("AIza" + "a".repeat(30)), "keyring:google_api_key");
44
+
45
+ // A long sk-ant- token must win over the generic long-opaque Claude branch,
46
+ // and over the OpenAI sk- shape.
47
+ assert.strictEqual(kind("sk-ant-" + "z".repeat(90)), "claude");
48
+
49
+ // Labelled single-token secrets land in the keyring with a derived name.
50
+ const labelled = classifyPastedCredential("token: " + "s".repeat(24));
51
+ assert.strictEqual(labelled.kind, "keyring");
52
+ assert.strictEqual(labelled.name, "token");
53
+ assert.strictEqual(labelled.value, "s".repeat(24));
54
+ assert.strictEqual(classifyPastedCredential("Bearer " + "t".repeat(24)).name, "bearer_token");
55
+
56
+ // A label followed by prose is ordinary text, not a swallowed secret.
57
+ assert.strictEqual(kind("key: the answer is 42"), "none");
58
+
59
+ // Ambiguous medium-length single token → scrub from chat, but let the turn run.
60
+ assert.strictEqual(kind("A1b2C3d4E5f6G7h8I9j0"), "scrub");
61
+
62
+ // Ordinary conversation and empties are left completely alone.
63
+ assert.strictEqual(kind("hey can you check the deploy status"), "none");
64
+ assert.strictEqual(kind("what colour are the fish?"), "none");
65
+ assert.strictEqual(kind(""), "none");
66
+ assert.strictEqual(kind(" "), "none");
67
+ assert.strictEqual(kind("short"), "none");
68
+ assert.strictEqual(kind(null), "none");
69
+ }
70
+
71
+ function routerWiringProbe() {
72
+ const source = fs.readFileSync(path.join(root, "core", "router.js"), "utf8");
73
+
74
+ // The classifier is imported and driven through a single capture funnel.
75
+ assert.match(source, /classifyPastedCredential/, "router must use the pure classifier");
76
+ assert.match(source, /async function captureLooseCredential/, "router must define the capture funnel");
77
+
78
+ // Capture must run — and short-circuit the turn — BEFORE the model is admitted,
79
+ // so a pasted secret never becomes part of a provider prompt.
80
+ const captureIdx = source.indexOf("if (await captureLooseCredential(");
81
+ const admitIdx = source.indexOf("admitRunContext(text,");
82
+ assert.ok(captureIdx > 0, "handleText must call captureLooseCredential");
83
+ assert.ok(admitIdx > 0, "handleText must still admit ordinary turns");
84
+ assert.ok(captureIdx < admitIdx, "credential capture must precede admitRunContext so secrets never reach the model");
85
+
86
+ // Capture is provider-agnostic: it must not branch on the active backend/provider.
87
+ const fnStart = source.indexOf("async function captureLooseCredential");
88
+ const fnEnd = source.indexOf("\nasync function handleText", fnStart);
89
+ const fnBody = source.slice(fnStart, fnEnd);
90
+ assert.ok(fnEnd > fnStart, "captureLooseCredential body must be locatable");
91
+ assert.ok(!/settings\.backend|state\.settings|activeProvider|currentProvider/.test(fnBody),
92
+ "credential capture must not depend on which provider is active");
93
+
94
+ // Each disposition routes to its sink, and every stored secret is redaction-
95
+ // registered and deleted from the chat.
96
+ assert.match(fnBody, /saveClaudeOAuthToken\(/, "claude paste → save Claude OAuth token");
97
+ assert.match(fnBody, /saveCodexApiKeyWithCli\(/, "openai paste → hand to Codex CLI");
98
+ assert.match(fnBody, /keyring\.set\(/, "recognised secret → operational keyring");
99
+ assert.match(fnBody, /registerSecrets\(/, "captured secrets are registered for redaction");
100
+ assert.match(fnBody, /deleteMessage\(/, "captured secrets are deleted from the chat");
101
+ }
102
+
103
+ function main() {
104
+ classifierProbe();
105
+ routerWiringProbe();
106
+ console.log("credential capture OK");
107
+ }
108
+
109
+ main();
@@ -32,15 +32,21 @@ 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.35 approved by Sumeet 2026-07-17 ("yes will this also ship the issue of
36
- // new lines not showing properly?"): Spaces comments become real ProseMirror
37
- // docs. buildCommentContent used to post the whole reply as ONE text node in
38
- // ONE paragraph, so TipTap collapsed every newline and printed markdown
39
- // asterisks literally — the unreadable-wall screenshot from the euvy bot
40
- // thread. Now markdown converts to paragraphs/hardBreaks, bold/italic/code
41
- // marks, bullet/ordered lists, headings, code blocks and dividers — all node
42
- // types verified present in the Spaces editor schema.
43
- assert.strictEqual(pkg.version, "3.0.35", "release version must remain unchanged without explicit approval");
35
+ // 3.0.36 approved by Sumeet 2026-07-17 ("One bug is when you change model to a
36
+ // Claude it asks you to sign in again" → "Go ahead"): picking a Claude model no
37
+ // longer forces a re-login. The model-button handler and the /login picker
38
+ // judged "connected?" from a shallow token probe (getClaudeOAuthToken) that
39
+ // only sees .env/process-env/vault tokens, so a Claude CLI-store login read as
40
+ // disconnected and every tap restarted login — a loop, since that login stores
41
+ // no bot token. Both now use claudeProviderAuthStatus() (honours a bot token
42
+ // AND a `claude auth status` CLI-store login), matching the Codex branch.
43
+ // 3.0.37 approved by Sumeet 2026-07-17 ("when done please push out the new
44
+ // version"): adds silent background watches (a cron carrying a shell `check`
45
+ // that only wakes the agent on exit 0, owner-gated via /watch) and
46
+ // provider-agnostic loose-credential capture (a pasted secret is stored
47
+ // out-of-band and the turn stopped before the model — Codex refuses secrets in
48
+ // its prompt, so the harness intercepts the paste itself).
49
+ assert.strictEqual(pkg.version, "3.0.37", "release version must remain unchanged without explicit approval");
44
50
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
45
51
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
46
52
  }
@@ -10,6 +10,14 @@ const path = require("path");
10
10
  const root = __dirname;
11
11
  const fixturesRoot = path.join(root, "test-fixtures", "migrations");
12
12
 
13
+ // Keep watch state off the real ~/.open-claudia during the run. scheduler.js
14
+ // reads these paths from env at require time, so they must be set before main()
15
+ // pulls in ./core/scheduler.
16
+ const watchTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "oc-watch-test-"));
17
+ process.env.OPEN_CLAUDIA_WATCH_LOG_FILE = path.join(watchTmpDir, "watch.log");
18
+ process.env.OPEN_CLAUDIA_WATCHES_ENABLED_FILE = path.join(watchTmpDir, "watches-enabled");
19
+ const watchesEnabledFile = process.env.OPEN_CLAUDIA_WATCHES_ENABLED_FILE;
20
+
13
21
  function readFixture(name, file = "jobs.json") {
14
22
  return JSON.parse(fs.readFileSync(path.join(fixturesRoot, name, file), "utf8"));
15
23
  }
@@ -423,6 +431,7 @@ function schedulerHarness(createSchedulerService, seed, options = {}) {
423
431
  retryDelayMs: 1000,
424
432
  busyDelayMs: 250,
425
433
  missedGraceMs: 60 * 60 * 1000,
434
+ ...(options.runWatchCheck ? { runWatchCheck: options.runWatchCheck } : {}),
426
435
  });
427
436
  return { service, jobsStore, scheduled, admissions, runCalls, sends, state };
428
437
  }
@@ -727,6 +736,78 @@ function loopbackMetadataProbe(runner, loopbackClient) {
727
736
  }
728
737
  }
729
738
 
739
+ async function watchGateProbe(createSchedulerService) {
740
+ const successOutcome = () => ({
741
+ ok: true,
742
+ status: "succeeded",
743
+ persistence: { transcript: { ok: true }, usage: { ok: true }, session: { ok: true } },
744
+ delivery: { ok: true },
745
+ });
746
+ const watchJob = (overrides = {}) => baseJob({
747
+ id: "watch-1",
748
+ kind: "cron",
749
+ schedule: "* * * * *",
750
+ fireAt: undefined,
751
+ occurrenceId: "watch-occurrence",
752
+ check: "true",
753
+ repeat: false,
754
+ label: "disk watch",
755
+ ...overrides,
756
+ });
757
+
758
+ // Failing check → idle skip: agent never wakes, nothing hits chat, job stays pending.
759
+ const idle = schedulerHarness(createSchedulerService, [watchJob({ id: "watch-idle" })], {
760
+ runWatchCheck: async () => ({ code: 1, timedOut: false }),
761
+ outcomes: [successOutcome()],
762
+ });
763
+ const idleResult = await idle.service.fireJob("watch-idle");
764
+ assert.strictEqual(idleResult.status, "watch-idle");
765
+ assert.strictEqual(idleResult.skipped, true);
766
+ assert.strictEqual(idle.runCalls.length, 0, "failed check must not wake the agent");
767
+ assert.strictEqual(idle.sends.length, 0, "idle poll never touches chat");
768
+ const idleJob = idle.jobsStore.get("watch-idle");
769
+ assert.ok(idleJob, "idle watch is left in the store");
770
+ assert.strictEqual(idleJob.status, "pending", "idle watch stays pending for the next tick");
771
+
772
+ // Passing check → wakes agent once with the Watch header; one-shot watch is removed after firing.
773
+ const fired = schedulerHarness(createSchedulerService, [watchJob({ id: "watch-fire" })], {
774
+ runWatchCheck: async () => ({ code: 0, timedOut: false }),
775
+ outcomes: [successOutcome()],
776
+ });
777
+ const firedResult = await fired.service.fireJob("watch-fire");
778
+ assert.strictEqual(firedResult.ok, true);
779
+ assert.strictEqual(firedResult.status, "succeeded");
780
+ assert.strictEqual(fired.runCalls.length, 1, "passing check wakes the agent exactly once");
781
+ assert.match(fired.runCalls[0].prompt, /Watch triggered/, "watch fire carries the Watch triggered header");
782
+ assert.strictEqual(fired.jobsStore.get("watch-fire"), null, "one-shot watch is removed after firing");
783
+
784
+ // Repeat watch → fires but survives with a fresh occurrence so the next tick polls again.
785
+ const repeating = schedulerHarness(createSchedulerService, [watchJob({ id: "watch-repeat", repeat: true })], {
786
+ runWatchCheck: async () => ({ code: 0, timedOut: false }),
787
+ outcomes: [successOutcome()],
788
+ });
789
+ await repeating.service.fireJob("watch-repeat");
790
+ assert.strictEqual(repeating.runCalls.length, 1);
791
+ const repeatJob = repeating.jobsStore.get("watch-repeat");
792
+ assert.ok(repeatJob, "repeat watch survives firing");
793
+ assert.strictEqual(repeatJob.status, "pending");
794
+ assert.strictEqual(repeatJob.occurrenceId, "next-occ-1", "repeat watch advances to a new occurrence");
795
+
796
+ // Global master switch OFF → skip before the check even runs.
797
+ fs.writeFileSync(watchesEnabledFile, "off");
798
+ let checkRan = false;
799
+ const disabled = schedulerHarness(createSchedulerService, [watchJob({ id: "watch-off" })], {
800
+ runWatchCheck: async () => { checkRan = true; return { code: 0, timedOut: false }; },
801
+ outcomes: [successOutcome()],
802
+ });
803
+ const disabledResult = await disabled.service.fireJob("watch-off");
804
+ assert.strictEqual(disabledResult.status, "watch-disabled");
805
+ assert.strictEqual(disabledResult.skipped, true);
806
+ assert.strictEqual(checkRan, false, "disabled watches never run their check");
807
+ assert.strictEqual(disabled.runCalls.length, 0);
808
+ fs.rmSync(watchesEnabledFile, { force: true });
809
+ }
810
+
730
811
  function staticSchedulingProbe() {
731
812
  const files = [
732
813
  "core/jobs.js",
@@ -742,6 +823,13 @@ function staticSchedulingProbe() {
742
823
  assert.ok(!/\bsessionKey\b/.test(source[file]), `${file} must not use the removed sessionKey scheduling field`);
743
824
  }
744
825
  assert.match(source["bin/cli.js"], /selected provider session/i);
826
+
827
+ // Watch feature must be wired end to end: gate → loopback → CLI surfaces.
828
+ assert.match(source["core/scheduler.js"], /job\.check/, "scheduler must gate firing on a watch check");
829
+ assert.match(source["core/scheduler.js"], /watchesEnabled/, "scheduler must consult the global watch switch");
830
+ assert.match(source["core/loopback.js"], /watch-add/, "loopback must route watch-add");
831
+ assert.match(source["bin/schedule.js"], /runWatchAdd/, "schedule CLI must expose watch-add");
832
+ assert.match(source["bin/cli.js"], /watch-add/, "cli must dispatch watch-add");
745
833
  }
746
834
 
747
835
  async function main() {
@@ -762,9 +850,11 @@ async function main() {
762
850
  await busyRestartAndMissedProbe(schedulerApi.createSchedulerService);
763
851
  await unavailableAndFallbackProbe(schedulerApi.createSchedulerService);
764
852
  await cronOccurrenceProbe(schedulerApi.createSchedulerService);
853
+ await watchGateProbe(schedulerApi.createSchedulerService);
765
854
  creationEnrichmentProbe(schedulerApi.createSchedulerService);
766
855
  loopbackMetadataProbe(require("./core/runner"), require("./bin/loopback-client"));
767
856
  staticSchedulingProbe();
857
+ try { fs.rmSync(watchTmpDir, { recursive: true, force: true }); } catch (_) {}
768
858
  console.log("provider scheduler OK");
769
859
  }
770
860