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.
- package/.context/README.md +2 -0
- package/.context/automations.md +87 -0
- package/.context/runtime.md +1 -40
- package/.env.example +1 -1
- package/.env.example.full +1 -19
- package/dist/cli/init-wizard.js +1 -1
- package/dist/cli/init-wizard.test.js +3 -3
- package/dist/config/safe-key.js +38 -0
- package/dist/config/safe-key.test.js +60 -0
- package/dist/config.js +12 -2
- package/dist/config.test.js +2 -2
- package/dist/cron/executor.js +1 -1
- package/dist/cron/executor.test.js +20 -0
- package/dist/dashboard/page.js +69 -23
- package/dist/discord/abort-registry.js +39 -0
- package/dist/discord/action-categories.js +2 -0
- package/dist/discord/action-dispatcher.js +20 -0
- package/dist/discord/action-flags.js +19 -0
- package/dist/discord/actions-archive.js +126 -0
- package/dist/discord/actions-config.js +13 -0
- package/dist/discord/actions-config.test.js +3 -3
- package/dist/discord/actions-guild.js +2 -2
- package/dist/discord/actions-imagegen.js +15 -0
- package/dist/discord/actions-imagegen.test.js +113 -1
- package/dist/discord/actions-messaging.js +62 -4
- package/dist/discord/actions-messaging.test.js +209 -1
- package/dist/discord/actions-spawn.js +49 -4
- package/dist/discord/actions-spawn.test.js +69 -1
- package/dist/discord/actions.js +89 -11
- package/dist/discord/actions.test.js +3 -1
- package/dist/discord/allowlist.js +11 -0
- package/dist/discord/deferred-runner.js +1 -0
- package/dist/discord/file-download.js +120 -7
- package/dist/discord/file-download.test.js +121 -8
- package/dist/discord/forge-commands.js +147 -832
- package/dist/discord/forge-commands.test.js +40 -560
- package/dist/discord/message-coordinator.followup-lifecycle.test.js +1 -0
- package/dist/discord/message-coordinator.js +104 -18
- package/dist/discord/message-coordinator.test.js +17 -0
- package/dist/discord/plan-manager.js +60 -0
- package/dist/discord/plan-manager.test.js +87 -1
- package/dist/discord/prompt-common.test.js +1 -2
- package/dist/discord/reaction-handler.js +40 -6
- package/dist/discord/reaction-handler.test.js +12 -0
- package/dist/discord/replies.js +5 -0
- package/dist/discord/status-channel.js +0 -9
- package/dist/discord/status-channel.test.js +0 -32
- package/dist/discord/stop-summary.js +61 -0
- package/dist/discord/stop-summary.test.js +98 -0
- package/dist/forge-phase.js +1 -4
- package/dist/gemini-model-validation.js +20 -0
- package/dist/health/config-doctor.js +0 -164
- package/dist/health/config-doctor.test.js +1 -156
- package/dist/index.js +6 -15
- package/dist/index.post-connect.js +2 -2
- package/dist/pipeline/engine.js +0 -1
- package/dist/runtime/anthropic-rest.test.js +22 -22
- package/dist/runtime/claude-code-cli.test.js +106 -0
- package/dist/runtime/cli-adapter.js +29 -1
- package/dist/runtime/cli-adapter.test.js +2 -0
- package/dist/runtime/cli-shared.test.js +2 -13
- package/dist/runtime/cli-strategy.js +5 -11
- package/dist/runtime/codex-cli.js +5 -130
- package/dist/runtime/codex-cli.test.js +1 -556
- package/dist/runtime/gemini-rest.js +22 -0
- package/dist/runtime/gemini-rest.test.js +64 -0
- package/dist/runtime/global-supervisor.test.js +0 -28
- package/dist/runtime/openai-compat.js +22 -1
- package/dist/runtime/runtime-failure.js +0 -12
- package/dist/runtime/runtime-failure.test.js +0 -8
- package/dist/runtime/strategies/claude-strategy.js +5 -1
- package/dist/runtime/tool-capabilities.js +0 -7
- package/dist/runtime/tool-capabilities.test.js +0 -5
- package/dist/server/routes/is-reserved-object-key.js +17 -0
- package/dist/util/pdf-extract.js +41 -0
- package/dist/util/pdf-extract.test.js +91 -0
- package/dist/webhook/server.js +6 -1
- package/dist/webhook/server.test.js +15 -0
- package/package.json +6 -2
- package/templates/instructions/SYSTEM_DEFAULTS.md +19 -0
- package/templates/instructions/SYSTEM_DEFAULTS.md.ledger.json +5 -0
- package/dist/runtime/codex-app-server.js +0 -1706
- package/dist/runtime/codex-app-server.test.js +0 -2220
package/.context/README.md
CHANGED
|
@@ -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.
|
package/.context/runtime.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
package/dist/cli/init-wizard.js
CHANGED
|
@@ -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,
|
package/dist/config.test.js
CHANGED
|
@@ -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 }));
|
package/dist/cron/executor.js
CHANGED
|
@@ -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 === '
|
|
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();
|
package/dist/dashboard/page.js
CHANGED
|
@@ -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
|
|
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.
|
|
953
|
-
} else if (
|
|
954
|
-
doctorHelper.textContent = '
|
|
978
|
+
+ ' available.';
|
|
979
|
+
} else if (criticalFindings.length > 0) {
|
|
980
|
+
doctorHelper.textContent = 'Review the critical items below.';
|
|
955
981
|
} else {
|
|
956
|
-
doctorHelper.textContent = '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1054
|
+
sectionFindings.forEach((finding) => renderFinding(section, finding));
|
|
1055
|
+
parent.append(section);
|
|
1029
1056
|
};
|
|
1030
1057
|
|
|
1031
|
-
renderSection('Needs attention',
|
|
1032
|
-
|
|
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
|
}
|