discoclaw 0.8.2 → 0.9.0

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 (83) hide show
  1. package/.context/README.md +2 -0
  2. package/.context/automations.md +87 -0
  3. package/.context/runtime.md +1 -40
  4. package/.env.example +1 -1
  5. package/.env.example.full +1 -19
  6. package/dist/cli/init-wizard.js +1 -1
  7. package/dist/cli/init-wizard.test.js +3 -3
  8. package/dist/config/safe-key.js +38 -0
  9. package/dist/config/safe-key.test.js +60 -0
  10. package/dist/config.js +12 -2
  11. package/dist/config.test.js +2 -2
  12. package/dist/cron/executor.js +1 -1
  13. package/dist/cron/executor.test.js +20 -0
  14. package/dist/dashboard/page.js +69 -23
  15. package/dist/discord/abort-registry.js +39 -0
  16. package/dist/discord/action-categories.js +2 -0
  17. package/dist/discord/action-dispatcher.js +20 -0
  18. package/dist/discord/action-flags.js +19 -0
  19. package/dist/discord/actions-archive.js +126 -0
  20. package/dist/discord/actions-config.js +13 -0
  21. package/dist/discord/actions-config.test.js +3 -3
  22. package/dist/discord/actions-guild.js +2 -2
  23. package/dist/discord/actions-imagegen.js +15 -0
  24. package/dist/discord/actions-imagegen.test.js +113 -1
  25. package/dist/discord/actions-messaging.js +62 -4
  26. package/dist/discord/actions-messaging.test.js +209 -1
  27. package/dist/discord/actions-spawn.js +49 -4
  28. package/dist/discord/actions-spawn.test.js +69 -1
  29. package/dist/discord/actions.js +89 -11
  30. package/dist/discord/actions.test.js +3 -1
  31. package/dist/discord/allowlist.js +11 -0
  32. package/dist/discord/deferred-runner.js +1 -0
  33. package/dist/discord/file-download.js +120 -7
  34. package/dist/discord/file-download.test.js +121 -8
  35. package/dist/discord/forge-commands.js +147 -832
  36. package/dist/discord/forge-commands.test.js +40 -560
  37. package/dist/discord/message-coordinator.followup-lifecycle.test.js +1 -0
  38. package/dist/discord/message-coordinator.js +104 -18
  39. package/dist/discord/message-coordinator.test.js +17 -0
  40. package/dist/discord/plan-manager.js +60 -0
  41. package/dist/discord/plan-manager.test.js +87 -1
  42. package/dist/discord/prompt-common.test.js +1 -2
  43. package/dist/discord/reaction-handler.js +40 -6
  44. package/dist/discord/reaction-handler.test.js +12 -0
  45. package/dist/discord/replies.js +5 -0
  46. package/dist/discord/status-channel.js +0 -9
  47. package/dist/discord/status-channel.test.js +0 -32
  48. package/dist/discord/stop-summary.js +61 -0
  49. package/dist/discord/stop-summary.test.js +98 -0
  50. package/dist/forge-phase.js +1 -4
  51. package/dist/gemini-model-validation.js +20 -0
  52. package/dist/health/config-doctor.js +0 -164
  53. package/dist/health/config-doctor.test.js +1 -156
  54. package/dist/index.js +6 -15
  55. package/dist/index.post-connect.js +2 -2
  56. package/dist/pipeline/engine.js +0 -1
  57. package/dist/runtime/anthropic-rest.test.js +22 -22
  58. package/dist/runtime/claude-code-cli.test.js +106 -0
  59. package/dist/runtime/cli-adapter.js +29 -1
  60. package/dist/runtime/cli-adapter.test.js +2 -0
  61. package/dist/runtime/cli-shared.test.js +2 -13
  62. package/dist/runtime/cli-strategy.js +5 -11
  63. package/dist/runtime/codex-cli.js +5 -130
  64. package/dist/runtime/codex-cli.test.js +1 -556
  65. package/dist/runtime/gemini-rest.js +22 -0
  66. package/dist/runtime/gemini-rest.test.js +64 -0
  67. package/dist/runtime/global-supervisor.test.js +0 -28
  68. package/dist/runtime/openai-compat.js +22 -1
  69. package/dist/runtime/runtime-failure.js +0 -12
  70. package/dist/runtime/runtime-failure.test.js +0 -8
  71. package/dist/runtime/strategies/claude-strategy.js +5 -1
  72. package/dist/runtime/tool-capabilities.js +0 -7
  73. package/dist/runtime/tool-capabilities.test.js +0 -5
  74. package/dist/server/routes/is-reserved-object-key.js +17 -0
  75. package/dist/util/pdf-extract.js +41 -0
  76. package/dist/util/pdf-extract.test.js +91 -0
  77. package/dist/webhook/server.js +6 -1
  78. package/dist/webhook/server.test.js +15 -0
  79. package/package.json +6 -2
  80. package/templates/instructions/SYSTEM_DEFAULTS.md +19 -0
  81. package/templates/instructions/SYSTEM_DEFAULTS.md.ledger.json +5 -0
  82. package/dist/runtime/codex-app-server.js +0 -1706
  83. package/dist/runtime/codex-app-server.test.js +0 -2220
@@ -19,6 +19,7 @@ Core instructions live in `CLAUDE.md` at the repo root.
19
19
  | **Architecture / system overview** | `architecture.md` |
20
20
  | **Tool capabilities / browser automation** | `tools.md` |
21
21
  | **Voice system (STT/TTS, audio pipeline, actions)** | `voice.md` |
22
+ | **Automations / cron / scheduled tasks** | `automations.md` |
22
23
  | **Forge/plan standing constraints** | `project.md` *(auto-loaded by forge)* |
23
24
  | **Plan & Forge commands** | `plan-and-forge.md` *(in docs/, not .context/)* |
24
25
  | **Engineering lessons / compound learnings** | `docs/compound-lessons.md` *(single checked-in durable artifact; lives in `docs/`, not `.context/`; human/developer reference, not auto-loaded into agent context)* |
@@ -41,6 +42,7 @@ Core instructions live in `CLAUDE.md` at the repo root.
41
42
  - **bot-setup.md** — One-time bot creation and invite guide
42
43
  - **tools.md** — Available tools: browser automation (agent-browser), escalation ladder, CDP connect, security guardrails
43
44
  - **voice.md** — Voice subsystem: module map, audio data flow, key patterns (barge-in, allowlist gating), wiring sequence, dependencies, config reference
45
+ - **automations.md** — Cron & scheduled tasks: job lifecycle, trigger types, primitives (state, silent, routing, chaining), safety rails, config reference
44
46
  - **project.md** — Standing constraints auto-loaded by forge drafter and auditor
45
47
  - **docs/plan-and-forge.md** — Canonical reference for `!plan` and `!forge` commands (lives in `docs/`, not `.context/` — human/developer reference, not auto-loaded into agent context)
46
48
  - **docs/compound-lessons.md** — Single checked-in durable artifact for distilled engineering lessons from audits, forge runs, and repeated workflow failures
@@ -0,0 +1,87 @@
1
+ # automations.md — Cron & Scheduled Tasks
2
+
3
+ Quick-reference for automation behavior. For full primitive docs see
4
+ [docs/cron.md](../docs/cron.md); for worked examples and copy-pasteable recipes
5
+ see [docs/cron-patterns.md](../docs/cron-patterns.md).
6
+
7
+ ## System Overview
8
+
9
+ Cron jobs are defined as forum threads in a dedicated Discord forum channel.
10
+ The scheduler registers in-process timers (via `croner`); on each tick the
11
+ executor assembles a prompt, invokes the AI runtime, and posts output to a
12
+ target channel.
13
+
14
+ Source: `src/cron/` — scheduler, executor, parser, forum sync, run stats,
15
+ job lock, chain, tag map.
16
+
17
+ ## Job Lifecycle
18
+
19
+ | Thread state | Job state |
20
+ |--------------|-----------|
21
+ | Active | Registered and running |
22
+ | Archived | Paused (unregistered) |
23
+ | Unarchived | Resumed (re-registered) |
24
+ | Deleted | Removed; stats cleaned on next startup |
25
+
26
+ Jobs must be created through the bot (`cronCreate` action), not by manually
27
+ creating forum threads.
28
+
29
+ ## Trigger Types
30
+
31
+ - **`schedule`** — standard 5-field cron expression
32
+ - **`webhook`** — external HTTP POST (requires `DISCOCLAW_WEBHOOK_ENABLED=true`)
33
+ - **`manual`** — explicit `cronTrigger` action only
34
+
35
+ ## Key Primitives
36
+
37
+ | Primitive | Purpose |
38
+ |-----------|---------|
39
+ | `{{state}}` / `<cron-state>` | Persistent per-job key-value state across runs |
40
+ | `silent` mode | Suppress posting when output is a sentinel (`HEARTBEAT_OK` or `[]`) |
41
+ | `routingMode: "json"` | Multi-channel dispatch via JSON array of `{channel, content}` |
42
+ | `allowedActions` | Restrict which Discord action types a job may emit |
43
+ | `chain` | Fire downstream jobs on success, forwarding state via `__upstream` |
44
+ | `model` | Per-job model tier override (`fast` / `capable` / `deep`) |
45
+
46
+ ## Safety Rails
47
+
48
+ - Cron jobs **cannot emit cron actions** — hard-coded, not configurable.
49
+ - Overlap guard: one execution per job at a time; concurrent ticks are skipped.
50
+ - Chain depth limit: **10** (prevents runaway cascades).
51
+ - Cycle detection at write time (BFS reachability check).
52
+ - `allowedActions` narrows only — cannot grant permissions the global config denies.
53
+
54
+ ## State Essentials
55
+
56
+ - `<cron-state>` **replaces** the full state object (not a merge).
57
+ - `{{state}}` expands to `{}` on first run or after a reset.
58
+ - Reset state: `cronUpdate` with `state: "{}"`.
59
+ - The executor injects a "Persistent State" section capped at 4 000 chars.
60
+
61
+ ## Configuration
62
+
63
+ | Variable | Default | Purpose |
64
+ |----------|---------|---------|
65
+ | `DISCOCLAW_CRON_ENABLED` | `true` | Enable the cron subsystem |
66
+ | `DISCOCLAW_CRON_FORUM` | — | Forum channel ID (auto-created if unset) |
67
+ | `DISCOCLAW_CRON_EXEC_MODEL` | `capable` | Default model tier for execution |
68
+ | `DISCOCLAW_CRON_MODEL` | `fast` | Model tier for definition parsing |
69
+ | `DISCOCLAW_CRON_AUTO_TAG` | `true` | Auto-tag cron forum threads |
70
+ | `DISCOCLAW_CRON_STATS_DIR` | — | Override stats storage directory |
71
+ | `DISCOCLAW_CRON_TAG_MAP` | — | Override tag map file path |
72
+ | `DISCOCLAW_WEBHOOK_ENABLED` | `false` | Enable the webhook server |
73
+ | `DISCOCLAW_WEBHOOK_CONFIG` | — | Path to webhook config JSON |
74
+
75
+ ## Common Patterns (cheat sheet)
76
+
77
+ | Pattern | Key technique |
78
+ |---------|---------------|
79
+ | Stateful polling | `{{state}}` cursor + `<cron-state>` update |
80
+ | Silent monitoring | `silent: true` + `HEARTBEAT_OK` sentinel |
81
+ | Multi-channel fan-out | `routingMode: "json"` |
82
+ | Chained pipelines | `chain` field + `__upstream.state` handoff |
83
+ | Accumulation / rollup | State counter resets on cadence boundary |
84
+ | Webhook-triggered | Config file + HMAC-SHA256 verification |
85
+ | Gated actions | `allowedActions` for least-privilege |
86
+
87
+ See [docs/cron-patterns.md](../docs/cron-patterns.md) for full examples of each.
@@ -80,46 +80,7 @@ Shutdown: `killAllSubprocesses()` from `cli-adapter.ts` kills all tracked subpro
80
80
  ## Codex CLI Runtime
81
81
 
82
82
  - Adapter: `src/runtime/codex-cli.ts` (thin wrapper around `cli-adapter.ts` + `strategies/codex-strategy.ts`)
83
- - Default transport:
84
- - The default Codex path is `codex exec` / `codex exec resume`.
85
- - DiscoClaw currently prefers the CLI route for reliability, especially in forge flows.
86
- - Optional native transport:
87
- - When explicitly enabled, DiscoClaw can use the Codex app-server websocket for `thread/start`, `turn/start`, streaming output, `turn/steer`, and `turn/interrupt`.
88
- - The native path still uses Codex's local app-server auth/session state, so no public API key is required.
89
- - Activation and fallback:
90
- - Native invoke is opt-in via `CODEX_APP_SERVER_NATIVE=1`.
91
- - Native invoke also requires `CODEX_APP_SERVER_URL` to point at a reachable websocket endpoint.
92
- - If the native flag is off, the URL is unset, or the turn hits an images / non-default `cwd` bypass gate, DiscoClaw uses the `codex exec` / `codex exec resume` transport instead.
93
- - If native invoke is selected but the websocket connection cannot be established, DiscoClaw falls back to `codex exec` for that turn rather than failing closed.
94
- - Env vars:
95
- | Var | Default | Purpose |
96
- |-----|---------|---------|
97
- | `CODEX_APP_SERVER_NATIVE` | `0` | Enables the app-server-native invoke path for eligible turns. |
98
- | `CODEX_APP_SERVER_URL` | *(unset)* | Codex app-server websocket URL (for example `ws://127.0.0.1:4321`) used by the native path. |
99
- - Native bypass gates:
100
- - **Images:** turns with `images` bypass native invoke and stay on `codex exec`, because image parity is not guaranteed on the app-server path.
101
- - **Non-default `cwd`:** turns whose `cwd` differs from the process working directory bypass native invoke and stay on `codex exec`, which is the only path that can shape the subprocess working directory per turn.
102
- - **`addDirs`:** native invoke passes extra readable roots through to the app-server sandbox as additional `readableRoots`, so standard Discord turns can still stay on the websocket path.
103
- - **Connection failure:** if the runtime cannot connect/initialize against the app-server websocket, it immediately drops back to `codex exec` for that invocation.
104
- - Capability declaration:
105
- - When native invoke is enabled and the app-server is configured, the runtime adds `mid_turn_steering` and exposes `RuntimeAdapter.steer()` + `RuntimeAdapter.interrupt()`.
106
- - When native invoke is inactive, the Codex adapter behaves like the legacy CLI runtime: no control methods and no extra capability.
107
- - Lifecycle and streaming:
108
- - For native turns, the runtime creates or reuses a thread with `thread/start`, starts the turn with `turn/start`, and tracks the active turn directly from the app-server response/notifications.
109
- - Streaming reply text comes from agent-message delta/completed notifications; tool progress comes from item start/completion notifications; terminal turn notifications clear the active turn and finish the stream.
110
- - Because the runtime receives the live `turnId` from the app-server itself, steering and interrupt can target the active turn reliably instead of depending on `codex exec --json` to surface it mid-turn.
111
- - Session semantics:
112
- - Reusing the same `sessionKey` reuses the same native `threadId`, so repeated turns continue the same Codex thread.
113
- - Omitting `sessionKey` creates an ephemeral native thread for that invocation only.
114
- - `disableSessions` strips `sessionKey` before dispatch, so native turns become ephemeral and never reuse a prior thread.
115
- - Steering / interrupt semantics:
116
- - `steer(sessionKey, message)` is best-effort and returns `false` instead of throwing when there is no tracked active turn or the app-server request fails.
117
- - Successful steering sends `turn/steer` with `threadId` plus `expectedTurnId`; if the server returns a replacement `turnId`, the runtime updates its active-turn pointer.
118
- - `interrupt(sessionKey)` is also best-effort and sends `turn/interrupt` with the active `threadId` + `turnId`, then clears local active-turn state.
119
- - Tradeoffs vs `codex exec`:
120
- - Native invoke is useful when live control matters: it owns thread/turn lifecycle directly, exposes the active `turnId`, and supports steer/interrupt semantics.
121
- - `codex exec` remains the default and preferred path for general reliability, plus any turns that need unsupported invocation shapes or when the app-server is unavailable.
122
- - Keeping both paths preserves local Codex auth and session behavior without forcing the app-server path on day-to-day usage.
83
+ - Transport: `codex exec` / `codex exec resume` exclusively.
123
84
 
124
85
  ## Gemini CLI Runtime
125
86
 
package/.env.example CHANGED
@@ -131,7 +131,7 @@ DISCORD_GUILD_ID=
131
131
  # --- OpenRouter adapter ---
132
132
  #OPENROUTER_API_KEY=
133
133
  #OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
134
- #OPENROUTER_MODEL=anthropic/claude-sonnet-4
134
+ #OPENROUTER_MODEL=anthropic/claude-sonnet-4-20250514
135
135
 
136
136
  # Log level: trace | debug | info | warn | error | fatal
137
137
  #LOG_LEVEL=info
package/.env.example.full CHANGED
@@ -526,13 +526,9 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
526
526
  # ----------------------------------------------------------
527
527
  # Route the forge drafter to a non-Claude runtime.
528
528
  # Valid values: "codex" (Codex CLI), "openai" (OpenAI-compatible HTTP API), "openrouter" (OpenRouter).
529
- # Note: forge currently forces Codex phases onto codex exec by default instead of
530
- # the native app-server path.
531
529
  #FORGE_DRAFTER_RUNTIME=
532
530
  # Route the forge auditor to a non-Claude runtime.
533
531
  # Valid values: "codex" (Codex CLI), "openai" (OpenAI-compatible HTTP API), "openrouter" (OpenRouter).
534
- # Note: forge currently forces Codex phases onto codex exec by default instead of
535
- # the native app-server path.
536
532
  #FORGE_AUDITOR_RUNTIME=
537
533
 
538
534
  # --- Codex CLI adapter ---
@@ -540,20 +536,6 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
540
536
  #CODEX_BIN=codex
541
537
  # Optional: isolate Codex state/sessions from ~/.codex (helps avoid stale rollout DB issues).
542
538
  #CODEX_HOME=/absolute/path/to/.codex-home-discoclaw
543
- # Optional: Codex app-server websocket URL for the native app-server invoke path.
544
- # When combined with CODEX_APP_SERVER_NATIVE=1, DiscoClaw uses the websocket for
545
- # thread/turn lifecycle, streaming output, steering, and interrupts. Unset keeps
546
- # the native path dormant.
547
- #CODEX_APP_SERVER_URL=ws://127.0.0.1:4321
548
- # Enable the optional native Codex app-server transport.
549
- # Set to 1 to allow eligible Codex turns to use the websocket path. The CLI route
550
- # remains the recommended default for normal ops because it has been more reliable.
551
- # Image turns, non-default cwd turns, and websocket bootstrap failures fall back
552
- # to codex exec automatically. Forge currently disables the native app-server
553
- # route for Codex phases by default and uses codex exec instead; CODEX_APP_SERVER_NATIVE=1
554
- # still applies to other eligible Codex turns. Extra readable roots from addDirs
555
- # are passed through to the app-server sandbox.
556
- #CODEX_APP_SERVER_NATIVE=0
557
539
  # Default model for the Codex CLI adapter. Used when FORGE_AUDITOR_MODEL is not set.
558
540
  #CODEX_MODEL=gpt-5.4
559
541
  # WARNING: disables Codex approval prompts and sandbox protections (full-access mode).
@@ -583,7 +565,7 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
583
565
  # Base URL (default: OpenRouter API endpoint).
584
566
  #OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
585
567
  # Default model for the OpenRouter adapter.
586
- #OPENROUTER_MODEL=anthropic/claude-sonnet-4
568
+ #OPENROUTER_MODEL=anthropic/claude-sonnet-4-20250514
587
569
 
588
570
  # ----------------------------------------------------------
589
571
  # Webhooks — inbound HTTP triggers from external services
@@ -334,7 +334,7 @@ export async function runInitWizard() {
334
334
  values.PRIMARY_RUNTIME = 'openrouter';
335
335
  console.log(' Note: the OpenRouter adapter is HTTP-only.');
336
336
  values.OPENROUTER_API_KEY = await askValidated('OpenRouter API key: ', (val) => (val ? null : 'API key is required'));
337
- values.OPENROUTER_MODEL = 'anthropic/claude-sonnet-4';
337
+ values.OPENROUTER_MODEL = 'anthropic/claude-sonnet-4-20250514';
338
338
  }
339
339
  values.DISCOCLAW_DISCORD_ACTIONS = '1';
340
340
  // ── Voice setup ───────────────────────────────────────────────────────────
@@ -96,12 +96,12 @@ describe('init wizard helpers', () => {
96
96
  PRIMARY_RUNTIME: 'openrouter',
97
97
  OPENROUTER_API_KEY: 'sk-or-test-key',
98
98
  OPENROUTER_BASE_URL: 'https://openrouter.ai/api/v1',
99
- OPENROUTER_MODEL: 'anthropic/claude-sonnet-4',
99
+ OPENROUTER_MODEL: 'anthropic/claude-sonnet-4-20250514',
100
100
  }, new Date('2026-02-22T00:00:00.000Z'));
101
101
  expect(content).toContain('PRIMARY_RUNTIME=openrouter');
102
102
  expect(content).toContain('OPENROUTER_API_KEY=sk-or-test-key');
103
103
  expect(content).toContain('OPENROUTER_BASE_URL=https://openrouter.ai/api/v1');
104
- expect(content).toContain('OPENROUTER_MODEL=anthropic/claude-sonnet-4');
104
+ expect(content).toContain('OPENROUTER_MODEL=anthropic/claude-sonnet-4-20250514');
105
105
  expect(content).toContain('# AUTO-DETECTED');
106
106
  expect(content).toContain('DISCOCLAW_TASKS_FORUM=1000000000000000002');
107
107
  expect(content).toContain('DISCOCLAW_CRON_FORUM=1000000000000000003');
@@ -269,7 +269,7 @@ describe('runInitWizard', () => {
269
269
  const newEnv = fs.readFileSync(path.join(tmpDir, '.env'), 'utf8');
270
270
  expect(newEnv).toContain('PRIMARY_RUNTIME=openrouter');
271
271
  expect(newEnv).toContain('OPENROUTER_API_KEY=sk-or-test-key');
272
- expect(newEnv).toContain('OPENROUTER_MODEL=anthropic/claude-sonnet-4');
272
+ expect(newEnv).toContain('OPENROUTER_MODEL=anthropic/claude-sonnet-4-20250514');
273
273
  expect(newEnv).toContain('DISCOCLAW_DISCORD_ACTIONS=1');
274
274
  expect(newEnv).toContain(`DISCOCLAW_DATA_DIR=${path.join(tmpDir, 'data')}`);
275
275
  });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Guards against prototype-pollution via dynamic property names.
3
+ *
4
+ * Any code path that uses an external string (route param, JSON key, etc.)
5
+ * as a plain-object property name should reject these values first.
6
+ */
7
+ const PROTO_POLLUTION_KEYS = new Set([
8
+ '__proto__',
9
+ 'constructor',
10
+ 'prototype',
11
+ ]);
12
+ /** Returns `true` when `key` is a prototype-pollution property name. */
13
+ export function isUnsafeKey(key) {
14
+ return PROTO_POLLUTION_KEYS.has(key);
15
+ }
16
+ /**
17
+ * Look up `key` on `obj` only when the key is safe and is an own property.
18
+ * Returns `undefined` for unsafe keys or missing properties — never walks
19
+ * the prototype chain.
20
+ */
21
+ export function safeGet(obj, key) {
22
+ if (isUnsafeKey(key))
23
+ return undefined;
24
+ return Object.hasOwn(obj, key) ? obj[key] : undefined;
25
+ }
26
+ /**
27
+ * Return a shallow copy of `obj` with any prototype-pollution keys removed.
28
+ * Useful for sanitising parsed JSON before it becomes a lookup table.
29
+ */
30
+ export function stripUnsafeKeys(obj) {
31
+ const clean = Object.create(null);
32
+ for (const key of Object.keys(obj)) {
33
+ if (!isUnsafeKey(key)) {
34
+ clean[key] = obj[key];
35
+ }
36
+ }
37
+ return clean;
38
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isUnsafeKey, safeGet, stripUnsafeKeys } from './safe-key.js';
3
+ describe('isUnsafeKey', () => {
4
+ it.each(['__proto__', 'constructor', 'prototype'])('returns true for %s', (key) => {
5
+ expect(isUnsafeKey(key)).toBe(true);
6
+ });
7
+ it.each(['github', 'alerts', '', 'proto', '__proto', 'CONSTRUCTOR'])('returns false for %s', (key) => {
8
+ expect(isUnsafeKey(key)).toBe(false);
9
+ });
10
+ });
11
+ describe('safeGet', () => {
12
+ const obj = { a: 1, b: 2 };
13
+ it('returns the value for a safe, present key', () => {
14
+ expect(safeGet(obj, 'a')).toBe(1);
15
+ });
16
+ it('returns undefined for a safe but missing key', () => {
17
+ expect(safeGet(obj, 'z')).toBeUndefined();
18
+ });
19
+ it('returns undefined for __proto__ even when set as own property', () => {
20
+ const withProto = Object.create(null);
21
+ withProto['__proto__'] = 42;
22
+ expect(safeGet(withProto, '__proto__')).toBeUndefined();
23
+ });
24
+ it('returns undefined for constructor', () => {
25
+ expect(safeGet(obj, 'constructor')).toBeUndefined();
26
+ });
27
+ it('returns undefined for prototype', () => {
28
+ expect(safeGet(obj, 'prototype')).toBeUndefined();
29
+ });
30
+ it('does not walk the prototype chain', () => {
31
+ const parent = { inherited: 99 };
32
+ const child = Object.create(parent);
33
+ expect(safeGet(child, 'inherited')).toBeUndefined();
34
+ });
35
+ });
36
+ describe('stripUnsafeKeys', () => {
37
+ it('removes __proto__, constructor, and prototype keys', () => {
38
+ const input = {
39
+ github: 'ok',
40
+ __proto__: 'bad',
41
+ constructor: 'bad',
42
+ prototype: 'bad',
43
+ alerts: 'ok',
44
+ };
45
+ // JSON.parse puts __proto__ as own property; replicate with Object.defineProperty
46
+ Object.defineProperty(input, '__proto__', { value: 'bad', enumerable: true, configurable: true });
47
+ const result = stripUnsafeKeys(input);
48
+ expect(Object.keys(result)).toEqual(['github', 'alerts']);
49
+ expect(result['github']).toBe('ok');
50
+ expect(result['alerts']).toBe('ok');
51
+ });
52
+ it('returns a null-prototype object', () => {
53
+ const result = stripUnsafeKeys({ a: 1 });
54
+ expect(Object.getPrototypeOf(result)).toBeNull();
55
+ });
56
+ it('handles an empty object', () => {
57
+ const result = stripUnsafeKeys({});
58
+ expect(Object.keys(result)).toEqual([]);
59
+ });
60
+ });
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { parseAllowBotIds, parseAllowChannelIds, parseAllowUserIds } from './discord/allowlist.js';
1
+ import { isAllowlisted, parseAllowBotIds, parseAllowChannelIds, parseAllowUserIds } from './discord/allowlist.js';
2
2
  import { parseDashboardTrustedHosts } from './dashboard/options.js';
3
3
  export const KNOWN_TOOLS = new Set([
4
4
  'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Pipeline', 'Step',
@@ -9,6 +9,14 @@ export const DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DEPTH = 4;
9
9
  export const DEFAULT_DISCORD_ACTIONS_LOOP_MIN_INTERVAL_SECONDS = 60;
10
10
  export const DEFAULT_DISCORD_ACTIONS_LOOP_MAX_INTERVAL_SECONDS = 86400;
11
11
  export const DEFAULT_DISCORD_ACTIONS_LOOP_MAX_CONCURRENT = 5;
12
+ /**
13
+ * Check whether a Discord user is authorized for config-mutating actions.
14
+ * Uses the DISCORD_ALLOW_USER_IDS allowlist as the sole authorization source.
15
+ * Fails closed: returns false when the allowlist is empty.
16
+ */
17
+ export function isAuthorizedUser(config, userId) {
18
+ return isAllowlisted(config.allowUserIds, userId);
19
+ }
12
20
  function parseBoolean(env, name, defaultValue) {
13
21
  const raw = env[name];
14
22
  if (raw == null || raw.trim() === '')
@@ -184,6 +192,7 @@ export function parseConfig(env) {
184
192
  const discordActionsImagegen = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN', false);
185
193
  const discordActionsVoice = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_VOICE', false);
186
194
  const discordActionsSpawn = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_SPAWN', true);
195
+ const discordActionsArchive = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_ARCHIVE', false);
187
196
  const spawnMaxConcurrent = parsePositiveInt(env, 'DISCOCLAW_DISCORD_ACTIONS_SPAWN_MAX_CONCURRENT', 4);
188
197
  const deferMaxDelaySeconds = parsePositiveNumber(env, 'DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS', DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS);
189
198
  const deferMaxConcurrent = parsePositiveInt(env, 'DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT', DEFAULT_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT);
@@ -350,7 +359,7 @@ export function parseConfig(env) {
350
359
  }
351
360
  const openrouterApiKey = parseTrimmedString(env, 'OPENROUTER_API_KEY');
352
361
  const openrouterBaseUrl = parseTrimmedString(env, 'OPENROUTER_BASE_URL');
353
- const openrouterModel = parseTrimmedString(env, 'OPENROUTER_MODEL') ?? 'anthropic/claude-sonnet-4';
362
+ const openrouterModel = parseTrimmedString(env, 'OPENROUTER_MODEL') ?? 'anthropic/claude-sonnet-4-20250514';
354
363
  if (primaryRuntime === 'openrouter' && !openrouterApiKey) {
355
364
  warnings.push('PRIMARY_RUNTIME=openrouter but OPENROUTER_API_KEY is not set; startup will fail unless another runtime is selected.');
356
365
  }
@@ -442,6 +451,7 @@ export function parseConfig(env) {
442
451
  discordActionsImagegen,
443
452
  discordActionsVoice,
444
453
  discordActionsSpawn,
454
+ discordActionsArchive,
445
455
  deferMaxDelaySeconds,
446
456
  deferMaxDepth,
447
457
  deferMaxConcurrent,
@@ -403,9 +403,9 @@ describe('parseConfig', () => {
403
403
  const { config } = parseConfig(env({ OPENROUTER_API_KEY: 'sk-or-test' }));
404
404
  expect(config.openrouterApiKey).toBe('sk-or-test');
405
405
  });
406
- it('defaults openrouterModel to "anthropic/claude-sonnet-4"', () => {
406
+ it('defaults openrouterModel to "anthropic/claude-sonnet-4-20250514"', () => {
407
407
  const { config } = parseConfig(env());
408
- expect(config.openrouterModel).toBe('anthropic/claude-sonnet-4');
408
+ expect(config.openrouterModel).toBe('anthropic/claude-sonnet-4-20250514');
409
409
  });
410
410
  it('warns when PRIMARY_RUNTIME=openrouter without OPENROUTER_API_KEY', () => {
411
411
  const { warnings } = parseConfig(env({ PRIMARY_RUNTIME: 'openrouter', OPENROUTER_API_KEY: undefined }));
@@ -432,7 +432,7 @@ export async function executeCronJob(job, ctx) {
432
432
  // Suppress sentinel outputs (e.g. crons whose prompts say "output nothing if idle").
433
433
  // Mirrors the reaction handler's logic at reaction-handler.ts:662-674.
434
434
  const strippedText = processedText.replace(/\s+/g, ' ').trim();
435
- const isSuppressible = strippedText === 'HEARTBEAT_OK' || strippedText === '(no output)';
435
+ const isSuppressible = /^heartbeat(_ok)?$/i.test(strippedText) || strippedText === 'HEART' || strippedText === '(no output)' || /^[\u2764\uFE0F]+$/.test(strippedText);
436
436
  if (isSuppressible && collectedImages.length === 0) {
437
437
  ctx.log?.info({ jobId: job.id, name: job.name, sentinel: strippedText }, 'cron:exec sentinel output suppressed');
438
438
  if (ctx.statsStore && job.cronId) {
@@ -436,6 +436,26 @@ describe('executeCronJob', () => {
436
436
  expect(channel.send).not.toHaveBeenCalled();
437
437
  expect(ctx.log?.info).toHaveBeenCalledWith(expect.objectContaining({ jobId: job.id, sentinel: 'HEARTBEAT_OK' }), 'cron:exec sentinel output suppressed');
438
438
  });
439
+ it('suppresses heart emoji output (model interprets HEARTBEAT_OK as emoji)', async () => {
440
+ for (const variant of ['\u2764\uFE0F', '\u2764', '\u2764\uFE0F\u2764\uFE0F']) {
441
+ const ctx = makeCtx({ runtime: makeMockRuntime(variant) });
442
+ const job = makeJob();
443
+ await executeCronJob(job, ctx);
444
+ const guild = ctx.client.guilds.cache.get('guild-1');
445
+ const channel = guild.channels.cache.get('general');
446
+ expect(channel.send).not.toHaveBeenCalled();
447
+ }
448
+ });
449
+ it('suppresses truncated HEARTBEAT variants (e.g. HEART)', async () => {
450
+ for (const variant of ['HEART', 'HEARTBEAT', 'Heartbeat_ok', 'heartbeat_ok']) {
451
+ const ctx = makeCtx({ runtime: makeMockRuntime(variant) });
452
+ const job = makeJob();
453
+ await executeCronJob(job, ctx);
454
+ const guild = ctx.client.guilds.cache.get('guild-1');
455
+ const channel = guild.channels.cache.get('general');
456
+ expect(channel.send).not.toHaveBeenCalled();
457
+ }
458
+ });
439
459
  it('suppresses (no output) output', async () => {
440
460
  const ctx = makeCtx({ runtime: makeMockRuntime('(no output)') });
441
461
  const job = makeJob();
@@ -434,6 +434,30 @@ export function renderDashboardPage() {
434
434
  padding: 8px 0;
435
435
  }
436
436
 
437
+ .findings-collapsible {
438
+ border-radius: 14px;
439
+ border: 1px solid rgba(255, 255, 255, 0.06);
440
+ background: var(--bg-soft);
441
+ }
442
+
443
+ .findings-collapsible summary {
444
+ cursor: pointer;
445
+ padding: 12px 14px;
446
+ color: var(--muted);
447
+ font-size: 13px;
448
+ letter-spacing: 0.06em;
449
+ }
450
+
451
+ .findings-collapsible[open] summary {
452
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
453
+ }
454
+
455
+ .findings-collapsible-inner {
456
+ padding: 10px;
457
+ display: grid;
458
+ gap: 10px;
459
+ }
460
+
437
461
  @media (max-width: 980px) {
438
462
  .span-4,
439
463
  .span-5,
@@ -938,22 +962,24 @@ export function renderDashboardPage() {
938
962
  function renderDoctor(report, summary) {
939
963
  const findings = Array.isArray(report.findings) ? report.findings : [];
940
964
  const autoFixableCount = findings.filter((finding) => finding.autoFixable).length;
941
- const attentionFindings = findings.filter((finding) => finding.severity !== 'info');
965
+ const criticalFindings = findings.filter((finding) => finding.severity === 'error');
966
+ const warnFindings = findings.filter((finding) => finding.severity === 'warn');
942
967
  const cleanupFindings = findings.filter((finding) => finding.severity === 'info');
968
+ const nonCriticalFindings = findings.filter((finding) => finding.severity !== 'error');
943
969
 
944
970
  doctorFixButton.disabled = autoFixableCount === 0;
945
971
  doctorFixButton.dataset.autoFixableCount = String(autoFixableCount);
946
972
 
947
973
  if (findings.length === 0) {
948
974
  doctorHelper.textContent = 'Nothing needs attention. Config looks clean.';
949
- } else if (autoFixableCount > 0) {
975
+ } else if (criticalFindings.length > 0 && autoFixableCount > 0) {
950
976
  doctorHelper.textContent = autoFixableCount + ' safe auto-fix'
951
977
  + (autoFixableCount === 1 ? ' is' : 'es are')
952
- + ' available. Review-only items will stay listed below.';
953
- } else if (attentionFindings.length === 0) {
954
- doctorHelper.textContent = 'These are cleanup suggestions only. Nothing here can be changed automatically.';
978
+ + ' available.';
979
+ } else if (criticalFindings.length > 0) {
980
+ doctorHelper.textContent = 'Review the critical items below.';
955
981
  } else {
956
- doctorHelper.textContent = 'Review the items below. None of them can be changed automatically.';
982
+ doctorHelper.textContent = 'No critical issues. Expand below for details.';
957
983
  }
958
984
 
959
985
  clearNode(doctorFindings);
@@ -963,18 +989,7 @@ export function renderDashboardPage() {
963
989
  empty.textContent = 'No doctor findings.';
964
990
  doctorFindings.append(empty);
965
991
  } else {
966
- const renderSection = (title, sectionFindings) => {
967
- if (!sectionFindings.length) return;
968
-
969
- const section = document.createElement('div');
970
- section.className = 'finding-section';
971
-
972
- const heading = document.createElement('div');
973
- heading.className = 'finding-section-title';
974
- heading.textContent = title;
975
- section.append(heading);
976
-
977
- sectionFindings.forEach((finding) => {
992
+ const renderFinding = (parent, finding) => {
978
993
  const wrapper = document.createElement('div');
979
994
  wrapper.className = 'finding';
980
995
 
@@ -1022,14 +1037,45 @@ export function renderDashboardPage() {
1022
1037
 
1023
1038
  wrapper.append(header, message);
1024
1039
  if (finding.recommendation) wrapper.append(recommendation);
1025
- section.append(wrapper);
1026
- });
1040
+ parent.append(wrapper);
1041
+ };
1042
+
1043
+ const renderSection = (parent, title, sectionFindings) => {
1044
+ if (!sectionFindings.length) return;
1045
+
1046
+ const section = document.createElement('div');
1047
+ section.className = 'finding-section';
1048
+
1049
+ const heading = document.createElement('div');
1050
+ heading.className = 'finding-section-title';
1051
+ heading.textContent = title;
1052
+ section.append(heading);
1027
1053
 
1028
- doctorFindings.append(section);
1054
+ sectionFindings.forEach((finding) => renderFinding(section, finding));
1055
+ parent.append(section);
1029
1056
  };
1030
1057
 
1031
- renderSection('Needs attention', attentionFindings);
1032
- renderSection('Cleanup suggestions', cleanupFindings);
1058
+ renderSection(doctorFindings, 'Needs attention', criticalFindings);
1059
+
1060
+ if (nonCriticalFindings.length > 0) {
1061
+ const details = document.createElement('details');
1062
+ details.className = 'findings-collapsible';
1063
+
1064
+ const summaryEl = document.createElement('summary');
1065
+ const parts = [];
1066
+ if (warnFindings.length > 0) parts.push(warnFindings.length + ' warning' + (warnFindings.length === 1 ? '' : 's'));
1067
+ if (cleanupFindings.length > 0) parts.push(cleanupFindings.length + ' suggestion' + (cleanupFindings.length === 1 ? '' : 's'));
1068
+ summaryEl.textContent = parts.join(', ');
1069
+ details.append(summaryEl);
1070
+
1071
+ const inner = document.createElement('div');
1072
+ inner.className = 'findings-collapsible-inner';
1073
+ renderSection(inner, 'Warnings', warnFindings);
1074
+ renderSection(inner, 'Cleanup suggestions', cleanupFindings);
1075
+ details.append(inner);
1076
+
1077
+ doctorFindings.append(details);
1078
+ }
1033
1079
  }
1034
1080
 
1035
1081
  const tone = findings.some((finding) => finding.severity === 'error')
@@ -4,6 +4,43 @@
4
4
  const COOLDOWN_MS = 15_000;
5
5
  const active = new Map();
6
6
  const cooldown = new Set();
7
+ const metaStore = new Map();
8
+ /** Attach metadata to an active abort entry for stop summary generation. */
9
+ export function setAbortMeta(messageId, m) {
10
+ metaStore.set(messageId, m);
11
+ }
12
+ /** Remove metadata for a message (called alongside dispose). */
13
+ export function clearAbortMeta(messageId) {
14
+ metaStore.delete(messageId);
15
+ }
16
+ /** Snapshot the metadata for a single active stream. Returns null if not found. */
17
+ export function snapshotAbort(messageId) {
18
+ const m = metaStore.get(messageId);
19
+ if (!m)
20
+ return null;
21
+ return {
22
+ messageId,
23
+ channelId: m.channelId,
24
+ userMessage: m.userMessage,
25
+ partialResponse: m.getPartialResponse(),
26
+ activityLabel: m.getActivityLabel(),
27
+ sessionKey: m.sessionKey,
28
+ elapsedMs: Date.now() - m.startedAt,
29
+ };
30
+ }
31
+ /** Snapshot metadata for all actively streaming entries. */
32
+ export function snapshotAllAborts() {
33
+ const snapshots = [];
34
+ for (const messageId of active.keys()) {
35
+ const snap = snapshotAbort(messageId);
36
+ if (snap)
37
+ snapshots.push(snap);
38
+ }
39
+ return snapshots;
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // Core abort registry
43
+ // ---------------------------------------------------------------------------
7
44
  /**
8
45
  * Register an AbortController for a message that is about to start streaming.
9
46
  *
@@ -17,6 +54,7 @@ export function registerAbort(messageId) {
17
54
  active.set(messageId, controller);
18
55
  function dispose() {
19
56
  active.delete(messageId);
57
+ metaStore.delete(messageId);
20
58
  cooldown.add(messageId);
21
59
  setTimeout(() => cooldown.delete(messageId), COOLDOWN_MS);
22
60
  }
@@ -67,4 +105,5 @@ export function tryAbortAll() {
67
105
  export function _resetForTest() {
68
106
  active.clear();
69
107
  cooldown.clear();
108
+ metaStore.clear();
70
109
  }
@@ -39,6 +39,8 @@ export const QUERY_ACTION_TYPES = new Set([
39
39
  'loopList',
40
40
  // Spawn
41
41
  'spawnAgent',
42
+ // Archive
43
+ 'archiveList',
42
44
  ]);
43
45
  export function hasQueryAction(actionTypes) {
44
46
  return actionTypes.some((t) => QUERY_ACTION_TYPES.has(t));