release: 0.14.0 — Discord channel, channel targeting, reflection visibility, usage tracking, Enter-to-send

This commit is contained in:
Denis Berekchiyan 2026-06-19 23:22:07 -07:00
parent f2c4454384
commit adb5b33ebd
36 changed files with 2094 additions and 175 deletions

View file

@ -1,5 +1,24 @@
# Changelog
## 0.14.0 — 2026-06-19
New features and fixes across channels, memory, and usage tracking.
**Discord channel**
- New Discord channel adapter (Gateway over WebSocket + REST), matching the Slack/Telegram model: `@agent-name` routing, `/agents` slash command, image attachments, allowlist on the authenticated sender, and reconnect/resume. See `DISCORD_SETUP.md`.
**Per-task & heartbeat channel delivery**
- Any scheduled/manual task can post its full output to a channel via `channel:` (broadcast/DM) and `channel_target:` (a specific Discord/Slack channel id or Telegram chat id). Heartbeats gain the same `channel_target`. New `sendToTarget` on all three transports, with task/agent form fields.
**Reflection visibility**
- Memory reflection now shows live in the overview "Active Agents" card and writes a run log to Recent Activity with its full output, so you can see what it consolidated. Fixed duplicate pinned-preference accumulation (reflection now trusts the model's consolidated pins).
**Comprehensive token & cost tracking**
- The dashboard "Tokens Used" and per-agent "Total Tokens" now include chat and channel turns (a new usage ledger), not just tasks/heartbeats. Cost uses the CLI's reported amount with a per-model pricing-table fallback.
**Chat**
- Enter sends a message; Shift+Enter inserts a newline (IME-safe).
## 0.13.6 — 2026-06-14
Code-quality cleanup from the community review (all non-blocking). No user-facing behavior changes.

101
DISCORD_SETUP.md Normal file
View file

@ -0,0 +1,101 @@
# Discord Setup Guide
Connect your Agent Fleet agents to Discord so you can chat with them from your phone, desktop, or anywhere Discord runs — in servers, channels, threads, or DMs.
---
## Prerequisites
Before you start, make sure you have:
- **Agent Fleet plugin** installed and running in Obsidian
- **At least one agent** configured in your fleet (the default Fleet Orchestrator works)
- **A Discord server** where you have permission to add bots (your own server or admin access)
---
## Part 1: Create the Discord Application
### Step 1 — Create a new application
1. Go to **https://discord.com/developers/applications**
2. Click **New Application**
3. **Name:** `Agent Fleet` (or any name you prefer)
4. Accept the terms and click **Create**
### Step 2 — Create the bot and copy its token
1. In the left sidebar, click **Bot**
2. Click **Reset Token** (or **Add Bot** on older UIs) and confirm
3. **Copy the token** — this is your **Bot Token**. Save it somewhere safe; you won't see it again without resetting.
### Step 3 — Enable the Message Content intent (required)
The bot can't read message text without this privileged intent.
1. Still on the **Bot** page, scroll to **Privileged Gateway Intents**
2. Toggle **Message Content Intent** → ON
3. Save changes
> If you skip this, the bot connects but every message arrives with empty text, and the Gateway may close with error code `4014`. The plugin logs a clear hint in the console when this happens.
### Step 4 — Invite the bot to your server
1. In the left sidebar, open **OAuth2 → URL Generator**
2. Under **Scopes**, check:
- `bot`
- `applications.commands` (for the `/agents` slash command)
3. Under **Bot Permissions**, check:
- **Send Messages**
- **Send Messages in Threads**
- **Read Message History**
- **Use Slash Commands**
4. Copy the generated URL at the bottom, open it in your browser, pick your server, and click **Authorize**.
---
## Part 2: Configure Agent Fleet
### Step 5 — Add the credential
1. In Obsidian, open **Settings → Agent Fleet → Channel Credentials**
2. Under **Add a channel credential**:
- **Reference name:** `discord-creds` (any name; referenced by `credential_ref` in the channel file)
- **Type:** `Discord`
- **Bot token:** paste the token from Step 2
3. Click **Add credential**
### Step 6 — Create the channel
1. Open the Agent Fleet dashboard → **Channels** → **New Channel**
2. Fill in:
- **Name:** e.g. `my-discord`
- **Type:** `discord`
- **Credential:** `discord-creds`
- **Default agent:** the agent to use when no `@agent-name` prefix is given
- **Allowed agents:** (optional) the agents reachable via `@agent-name` or `/agents`
- **Allowed users:** your numeric Discord user ID(s), one per line. Only listed users can reach the bot. (Enable Developer Mode in Discord → right-click your name → **Copy User ID**.)
3. Save. The channel should show **connected** in the dashboard within a few seconds.
---
## Using it
- **Mention or DM the bot.** In a server channel, `@Agent Fleet do the thing` (or just message it in a DM).
- **Pick an agent per conversation.** Prefix a message with `@agent-name ...`, or run the **`/agents`** slash command and click a button. The choice sticks for that channel/thread until you change it.
- Global slash commands can take up to ~1 hour to appear the first time after the bot joins.
- **Threads and channels are isolated.** Each channel or thread keeps its own conversation and agent binding.
- **Images.** Attach an image and the agent receives it (saved into your vault).
- **Long replies** are automatically split to fit Discord's 2000-character limit.
- **Heartbeats.** If an agent's `heartbeatChannel` points at this Discord channel, the heartbeat result is delivered as a DM to the first allowed user.
---
## Troubleshooting
| Symptom | Fix |
|---------|-----|
| Channel stuck on **needs-auth** | Check the bot token; ensure the **Message Content intent** is enabled (Step 3). |
| Bot replies but ignores your text / empty messages | Message Content intent is off — enable it and reconnect. |
| `/agents` command missing | Global commands can take up to ~1h to propagate; ensure the bot was invited with the `applications.commands` scope. |
| Bot doesn't respond at all | Confirm your numeric user ID is in **Allowed users**, and the bot has Send Messages / Read Message History in that channel. |

View file

@ -270,6 +270,33 @@ Chat with your agents from Telegram — simpler setup than Slack, no @mention re
---
### Discord
Chat with your agents from Discord — in servers, channels, threads, or DMs.
> **📖 [Step-by-step Discord setup guide →](DISCORD_SETUP.md)** — complete walkthrough from creating the Discord app to sending your first message.
**Setup:**
1. Create an application at [discord.com/developers](https://discord.com/developers/applications), add a bot, and copy its token
2. **Enable the Message Content privileged intent** (required to read messages)
3. Invite the bot with the `bot` + `applications.commands` scopes
4. Add the token in Settings → Agent Fleet → Channel Credentials (type: Discord)
5. Create a channel via the dashboard or as `_fleet/channels/my-discord.md`
6. Mention or DM the bot in Discord
**Features:**
- **Gateway over WebSocket** — outbound connection, works behind NAT/firewalls, no public URL; hand-rolled over `ws`, no `discord.js` dependency
- **Servers, threads & DMs** — every channel or thread is an isolated conversation with its own agent binding
- **Typing indicator** — native typing dots while the agent works (auto-refreshed)
- **Button agent picker** — the `/agents` slash command shows interactive buttons to switch agents; `@agent-name` prefix routing also works
- **Image attachments** — images are downloaded into your vault and passed to the agent
- **Session persistence** — conversations survive Obsidian restarts via the backend's resume (Claude `--resume` / Codex `exec resume`)
- **Allowlist** — only approved Discord users (by numeric ID) can reach the bot
- **2000-char message splitting** — long replies automatically chunked at paragraph boundaries
- **Auto-reconnect & resume** — Gateway RESUME on transient drops with exponential backoff
---
### Interactive Chat
The chat panel is a first-class Obsidian view — dock it in the sidebar, center, or any split.
@ -549,6 +576,7 @@ An autonomous periodic run — what an agent does on a schedule without user inp
## Links
- [Slack Setup Guide](SLACK_SETUP.md)
- [Discord Setup Guide](DISCORD_SETUP.md)
- [Releases](https://github.com/denberek/obsidian-agent-fleet/releases)
- [npm package](https://www.npmjs.com/package/obsidian-agent-fleet)
- [Report Issues](https://github.com/denberek/obsidian-agent-fleet/issues)

View file

@ -73,7 +73,7 @@ These are inherited by all agent processes. Never store tokens in vault files.
| Defined in | HEARTBEAT.md in agent folder | _fleet/tasks/<name>.md |
| Prompt source | Heartbeat body | Task body |
| "Run Now" button | Uses heartbeat instruction | Uses task prompt |
| Delivery | Optional Slack channel post | Run log only |
| Delivery | Run log + optional channel post (`channel` in HEARTBEAT.md) | Run log + optional channel post (`channel` in task frontmatter) |
| Scope | One per agent | Many per agent |
| Best for | Autonomous periodic monitoring | Specific scheduled work items |

View file

@ -141,6 +141,9 @@ enabled: true
schedule: "0 */6 * * *" # Cron expression — every 6 hours
notify: true # Show Obsidian notice on completion
channel: my-slack # Post results to this channel (optional)
channel_target: "" # Specific channel/conversation id within `channel` to post to
# (e.g. a Discord channel id). Empty = broadcast as a DM to the
# channel's first allowed user. Quote numeric ids to preserve precision.
---
Check the following endpoints for availability and response time:
@ -156,6 +159,8 @@ a one-line "all clear". Use [REMEMBER] to track trends across heartbeats.
- `schedule` — cron expression (same format as tasks)
- `notify` — show Obsidian notice when heartbeat completes (default true)
- `channel` — name of a configured channel to post results to (optional)
- `channel_target` — specific channel/conversation id within `channel` to post to (optional;
empty broadcasts a DM to the channel's first allowed user). Mirrors a task's `channel_target`.
## Creating a Channel
@ -230,6 +235,12 @@ model: "" # Override agent model for this task only.
# Use aliases like "haiku" for cheap/simple tasks,
# or leave empty to inherit agent's model.
# Resolution order: task.model → agent.model → settings.defaultModel.
channel: "" # Post this task's output to a channel (e.g. "my-discord").
# Empty = run log only. Lets scheduled tasks deliver to chat,
# not just the heartbeat.
channel_target: "" # Optional destination id within `channel`: a Discord/Slack
# channel id or Telegram chat id. Set = post directly to that
# channel; empty = DM the channel's first allowed user.
tags:
- monitoring
---
@ -238,6 +249,23 @@ Task prompt goes here. This is what the agent should do each run.
Be specific and clear about expected output.
```
**Channel delivery (`channel` + `channel_target`).** Any task can post its output to a
configured channel by setting `channel:` to a channel name. Previously only an agent's
heartbeat could post to a channel; now every scheduled/manual task can too. The output
is the task run's **full** output text, prefixed with `*<agent> — <task_id>*`.
Two delivery modes:
- **`channel_target` empty** → broadcast (a DM to the channel's first allowed user) —
same path the heartbeat uses.
- **`channel_target` set** → post directly to that specific channel/conversation. The
target is a transport-native id: a Discord/Slack channel id or a Telegram chat id.
(Discord: enable Developer Mode → right-click the channel → Copy Channel ID. The bot
must have permission to post there.)
Keep the task prompt's output concise/skimmable if it's going to chat. Heartbeats still
use `channel` in HEARTBEAT.md (broadcast only); tasks use `channel`/`channel_target` in
their own frontmatter.
### Task Types
- **recurring** — runs on a cron schedule. Requires `schedule` field.
- **once** — runs at a specific time. Requires `run_at` field (ISO datetime).

194
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
{
"id": "agent-fleet",
"name": "Agent Fleet",
"version": "0.13.6",
"version": "0.14.0",
"minAppVersion": "1.11.4",
"description": "File-backed AI agents with task scheduling, channels, memory, and MCP — running on Claude Code or OpenAI Codex, all as plain markdown.",
"author": "Denis Berekchiyan",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "obsidian-agent-fleet",
"version": "0.13.4",
"version": "0.13.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-agent-fleet",
"version": "0.13.4",
"version": "0.13.6",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {

View file

@ -1,7 +1,7 @@
{
"name": "obsidian-agent-fleet",
"version": "0.13.6",
"description": "Obsidian plugin for file-backed AI agents, task scheduling, channels (Slack), heartbeat, and interactive chat.",
"version": "0.14.0",
"description": "Obsidian plugin for file-backed AI agents, task scheduling, channels (Slack, Telegram, Discord), heartbeat, and interactive chat.",
"license": "MIT",
"main": "plugin/main.js",
"bin": {
@ -14,6 +14,7 @@
"plugin/styles.css",
"README.md",
"SLACK_SETUP.md",
"DISCORD_SETUP.md",
"LICENSE",
"screenshot.png"
],

View file

@ -33,6 +33,7 @@ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
heartbeatBody: "",
heartbeatNotify: false,
heartbeatChannel: "",
heartbeatChannelTarget: "",
...overrides,
};
}

View file

@ -41,6 +41,7 @@ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
heartbeatBody: "",
heartbeatNotify: false,
heartbeatChannel: "",
heartbeatChannelTarget: "",
...overrides,
};
}

View file

@ -39,6 +39,7 @@ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
heartbeatBody: "",
heartbeatNotify: false,
heartbeatChannel: "",
heartbeatChannelTarget: "",
...overrides,
};
}

View file

@ -27,7 +27,7 @@ export const DEFAULT_SETTINGS: FleetSettings = {
defaultFileHashes: {},
};
export const FLEET_SUBFOLDERS = ["agents", "skills", "tasks", "runs", "memory", "channels", "mcp"] as const;
export const FLEET_SUBFOLDERS = ["agents", "skills", "tasks", "runs", "memory", "channels", "mcp", "usage"] as const;
// ─── Memory v2 defaults (see MEMORY_EVOLUTION_DESIGN.md) ───
/** Steady-state token budget for an agent's injected working memory. */

View file

@ -545,7 +545,7 @@ These are inherited by all agent processes. Never store tokens in vault files.
| Defined in | HEARTBEAT.md in agent folder | _fleet/tasks/<name>.md |
| Prompt source | Heartbeat body | Task body |
| "Run Now" button | Uses heartbeat instruction | Uses task prompt |
| Delivery | Optional Slack channel post | Run log only |
| Delivery | Run log + optional channel post (\`channel\` in HEARTBEAT.md) | Run log + optional channel post (\`channel\` in task frontmatter) |
| Scope | One per agent | Many per agent |
| Best for | Autonomous periodic monitoring | Specific scheduled work items |
@ -756,6 +756,9 @@ enabled: true
schedule: "0 */6 * * *" # Cron expression every 6 hours
notify: true # Show Obsidian notice on completion
channel: my-slack # Post results to this channel (optional)
channel_target: "" # Specific channel/conversation id within \`channel\` to post to
# (e.g. a Discord channel id). Empty = broadcast as a DM to the
# channel's first allowed user. Quote numeric ids to preserve precision.
---
Check the following endpoints for availability and response time:
@ -771,6 +774,8 @@ a one-line "all clear". Use [REMEMBER] to track trends across heartbeats.
- \`schedule\` — cron expression (same format as tasks)
- \`notify\` — show Obsidian notice when heartbeat completes (default true)
- \`channel\` — name of a configured channel to post results to (optional)
- \`channel_target\` — specific channel/conversation id within \`channel\` to post to (optional;
empty broadcasts a DM to the channel's first allowed user). Mirrors a task's \`channel_target\`.
## Creating a Channel
@ -845,6 +850,12 @@ model: "" # Override agent model for this task only.
# Use aliases like "haiku" for cheap/simple tasks,
# or leave empty to inherit agent's model.
# Resolution order: task.model agent.model settings.defaultModel.
channel: "" # Post this task's output to a channel (e.g. "my-discord").
# Empty = run log only. Lets scheduled tasks deliver to chat,
# not just the heartbeat.
channel_target: "" # Optional destination id within \`channel\`: a Discord/Slack
# channel id or Telegram chat id. Set = post directly to that
# channel; empty = DM the channel's first allowed user.
tags:
- monitoring
---
@ -853,6 +864,23 @@ Task prompt goes here. This is what the agent should do each run.
Be specific and clear about expected output.
\`\`\`
**Channel delivery (\`channel\` + \`channel_target\`).** Any task can post its output to a
configured channel by setting \`channel:\` to a channel name. Previously only an agent's
heartbeat could post to a channel; now every scheduled/manual task can too. The output
is the task run's **full** output text, prefixed with \`*<agent> — <task_id>*\`.
Two delivery modes:
- **\`channel_target\` empty** → broadcast (a DM to the channel's first allowed user) —
same path the heartbeat uses.
- **\`channel_target\` set** → post directly to that specific channel/conversation. The
target is a transport-native id: a Discord/Slack channel id or a Telegram chat id.
(Discord: enable Developer Mode right-click the channel Copy Channel ID. The bot
must have permission to post there.)
Keep the task prompt's output concise/skimmable if it's going to chat. Heartbeats still
use \`channel\` in HEARTBEAT.md (broadcast only); tasks use \`channel\`/\`channel_target\` in
their own frontmatter.
### Task Types
- **recurring** runs on a cron schedule. Requires \`schedule\` field.
- **once** runs at a specific time. Requires \`run_at\` field (ISO datetime).
@ -1787,7 +1815,7 @@ For smooth animation:
\`\`\`javascript
// Color utilities
function hexToRgb(hex) {
const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);
const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})\$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
@ -2876,7 +2904,7 @@ Top-level fields (\`id\`, \`display_name\`, \`max_input_tokens\`, \`max_tokens\`
\`\`\`bash
curl https://api.anthropic.com/v1/models/claude-opus-4-6 \\
-H "x-api-key: $ANTHROPIC_API_KEY" \\
-H "x-api-key: \$ANTHROPIC_API_KEY" \\
-H "anthropic-version: 2023-06-01"
\`\`\`
@ -3090,7 +3118,7 @@ The response \`usage\` object reports cache activity:
If \`cache_read_input_tokens\` is zero across repeated requests with identical prefixes, a silent invalidator is at work — diff the rendered prompt bytes between two requests to find it.
Language-specific access: \`response.usage.cache_read_input_tokens\` (Python/TS/Ruby), \`$message->usage->cacheReadInputTokens\` (PHP), \`resp.Usage.CacheReadInputTokens\` (Go/C#), \`.usage().cacheReadInputTokens()\` (Java).
Language-specific access: \`response.usage.cache_read_input_tokens\` (Python/TS/Ruby), \`\$message->usage->cacheReadInputTokens\` (PHP), \`resp.Usage.CacheReadInputTokens\` (Go/C#), \`.usage().cacheReadInputTokens()\` (Java).
---
@ -3204,7 +3232,7 @@ The code execution tool lets Claude run code in a secure, sandboxed container. U
- No internet access (fully sandboxed)
- Python 3.11 with data science libraries pre-installed
- Containers persist for 30 days and can be reused across requests
- Free when used with web search/web fetch tools; otherwise $0.05/hour after 1,550 free hours/month per organization
- Free when used with web search/web fetch tools; otherwise \$0.05/hour after 1,550 free hours/month per organization
### Tool Definition
@ -3363,7 +3391,7 @@ Two features are available:
**Supported:**
- Basic types: object, array, string, integer, number, boolean, null
- \`enum\`, \`const\`, \`anyOf\`, \`allOf\`, \`$ref\`/\`$def\`
- \`enum\`, \`const\`, \`anyOf\`, \`allOf\`, \`\$ref\`/\`\$def\`
- String formats: \`date-time\`, \`time\`, \`date\`, \`duration\`, \`email\`, \`hostname\`, \`uri\`, \`ipv4\`, \`ipv6\`, \`uuid\`
- \`additionalProperties: false\` (required for all objects)
@ -3544,11 +3572,11 @@ Everything goes through \`POST /v1/messages\`. Tools and output constraints are
## Current Models (cached: 2026-02-17)
| Model | Model ID | Context | Input $/1M | Output $/1M |
| Model | Model ID | Context | Input \$/1M | Output \$/1M |
| ----------------- | ------------------- | -------------- | ---------- | ----------- |
| Claude Opus 4.6 | \`claude-opus-4-6\` | 200K (1M beta) | $5.00 | $25.00 |
| Claude Sonnet 4.6 | \`claude-sonnet-4-6\` | 200K (1M beta) | $3.00 | $15.00 |
| Claude Haiku 4.5 | \`claude-haiku-4-5\` | 200K | $1.00 | $5.00 |
| Claude Opus 4.6 | \`claude-opus-4-6\` | 200K (1M beta) | \$5.00 | \$25.00 |
| Claude Sonnet 4.6 | \`claude-sonnet-4-6\` | 200K (1M beta) | \$3.00 | \$15.00 |
| Claude Haiku 4.5 | \`claude-haiku-4-5\` | 200K | \$1.00 | \$5.00 |
**ALWAYS use \`claude-opus-4-6\` unless the user explicitly names a different model.** This is non-negotiable. Do not use \`claude-sonnet-4-6\`, \`claude-sonnet-4-5\`, or any other model unless the user literally says "use sonnet" or "use haiku". Never downgrade for cost — that's the user's decision, not yours.
@ -5701,7 +5729,7 @@ server.registerResource(
mimeType: "text/plain"
},
async (uri: string) => {
const match = uri.match(/^file:\\/\\/documents\\/(.+)$/);
const match = uri.match(/^file:\\/\\/documents\\/(.+)\$/);
const documentName = match[1];
const content = await loadDocument(documentName);
return { contents: [{ uri, mimeType: "text/plain", text: content }] };
@ -5853,7 +5881,7 @@ from pydantic import BaseModel, Field, field_validator, ConfigDict
class CreateUserInput(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
name: str = Field(..., description="User's full name", min_length=1, max_length=100)
email: str = Field(..., description="User's email", pattern=r'^[\\w\\.-]+@[\\w\\.-]+\\.\\w+$')
email: str = Field(..., description="User's email", pattern=r'^[\\w\\.-]+@[\\w\\.-]+\\.\\w+\$')
@field_validator('email')
@classmethod
@ -6499,7 +6527,7 @@ Example evaluation file demonstrating the XML format for evaluation questions:
\`\`\`xml
<evaluation>
<qa_pair>
<question>Calculate the compound interest on $10,000 invested at 5% annual interest rate, compounded monthly for 3 years. What is the final amount in dollars (rounded to 2 decimal places)?</question>
<question>Calculate the compound interest on \$10,000 invested at 5% annual interest rate, compounded monthly for 3 years. What is the final amount in dollars (rounded to 2 decimal places)?</question>
<answer>11614.72</answer>
</qa_pair>
<!-- More qa_pairs... -->
@ -9289,7 +9317,7 @@ Put each with_skill version before its baseline counterpart.
--skill-name "my-skill" \\
--benchmark <workspace>/iteration-N/benchmark.json \\
> /dev/null 2>&1 &
VIEWER_PID=$!
VIEWER_PID=\$!
\`\`\`
For iteration 2+, also pass \`--previous-workspace <workspace>/iteration-<N-1>\`.
@ -9333,7 +9361,7 @@ Empty feedback means the user thought it was fine. Focus your improvements on th
Kill the viewer server when you're done with it:
\`\`\`bash
kill $VIEWER_PID 2>/dev/null
kill \$VIEWER_PID 2>/dev/null
\`\`\`
---
@ -12232,8 +12260,8 @@ Unless otherwise stated by the user or existing template
#### Required Format Rules
- **Years**: Format as text strings (e.g., "2024" not "2,024")
- **Currency**: Use $#,##0 format; ALWAYS specify units in headers ("Revenue ($mm)")
- **Zeros**: Use number formatting to make all zeros "-", including percentages (e.g., "$#,##0;($#,##0);-")
- **Currency**: Use \$#,##0 format; ALWAYS specify units in headers ("Revenue (\$mm)")
- **Zeros**: Use number formatting to make all zeros "-", including percentages (e.g., "\$#,##0;(\$#,##0);-")
- **Percentages**: Default to 0.0% format (one decimal)
- **Multiples**: Format as 0.0x for valuation multiples (EV/EBITDA, P/E)
- **Negative numbers**: Use parentheses (123) not minus -123
@ -12243,7 +12271,7 @@ Unless otherwise stated by the user or existing template
#### Assumptions Placement
- Place ALL assumptions (growth rates, margins, multiples, etc.) in separate assumption cells
- Use cell references instead of hardcoded values in formulas
- Example: Use =B5*(1+$B$6) instead of =B5*1.05
- Example: Use =B5*(1+\$B\$6) instead of =B5*1.05
#### Formula Error Prevention
- Verify all cell references are correct

View file

@ -32,6 +32,7 @@ import type {
WorkingMemory,
SkillConfig,
TaskConfig,
UsageRecord,
ValidationIssue,
} from "./types";
@ -553,6 +554,7 @@ export class FleetRepository {
let heartbeatBody = "";
let heartbeatNotify = true;
let heartbeatChannel = "";
let heartbeatChannelTarget = "";
const heartbeatPath = normalizePath(`${folderPath}/HEARTBEAT.md`);
const heartbeatFile = this.vault.getAbstractFileByPath(heartbeatPath);
if (heartbeatFile instanceof TFile) {
@ -562,6 +564,7 @@ export class FleetRepository {
heartbeatSchedule = asString(parsed.frontmatter.schedule) ?? "";
heartbeatNotify = asBoolean(parsed.frontmatter.notify, true);
heartbeatChannel = asString(parsed.frontmatter.channel) ?? "";
heartbeatChannelTarget = asString(parsed.frontmatter.channel_target) ?? "";
heartbeatBody = parsed.body;
}
@ -616,6 +619,7 @@ export class FleetRepository {
heartbeatBody,
heartbeatNotify,
heartbeatChannel,
heartbeatChannelTarget,
wikiKeeper: this.parseWikiKeeperConfig(configFm.wiki_keeper ?? agentFm.wiki_keeper),
wikiReferences: this.parseWikiReferences(configFm.wiki_references ?? agentFm.wiki_references),
};
@ -1311,6 +1315,60 @@ export class FleetRepository {
return parsed.sort((a, b) => b.started.localeCompare(a.started));
}
// ═══════════════════════════════════════════════════════
// Usage ledger (chat/channel token+cost) — _fleet/usage/YYYY-MM-DD.jsonl
// ═══════════════════════════════════════════════════════
private usageLedgerPath(ts: string): string {
return normalizePath(`${this.getSubfolder("usage")}/${ts.slice(0, 10)}.jsonl`);
}
/** Append one usage record to the day's JSONL ledger (one line per turn).
* Uses the raw adapter so it doesn't go through the markdown pipeline. */
async appendUsage(record: UsageRecord): Promise<void> {
await this.ensureFolder(this.getSubfolder("usage"));
const path = this.usageLedgerPath(record.ts);
const line = `${JSON.stringify(record)}\n`;
const adapter = this.vault.adapter;
if (await adapter.exists(path)) {
await adapter.append(path, line);
} else {
await adapter.write(path, line);
}
}
/** Read all usage records on or after `sinceDate` (by ledger file date). */
async readUsageSince(sinceDate: Date): Promise<UsageRecord[]> {
const dir = this.getSubfolder("usage");
const adapter = this.vault.adapter;
if (!(await adapter.exists(dir))) return [];
const sinceStr = `${sinceDate.getFullYear()}-${String(sinceDate.getMonth() + 1).padStart(2, "0")}-${String(sinceDate.getDate()).padStart(2, "0")}`;
const out: UsageRecord[] = [];
const listing = await adapter.list(dir);
for (const filePath of listing.files) {
if (!filePath.endsWith(".jsonl")) continue;
const base = (filePath.split("/").pop() ?? "").replace(/\.jsonl$/, "");
// Files are named YYYY-MM-DD; lexicographic >= matches calendar >=.
if (base < sinceStr) continue;
let content: string;
try {
content = await adapter.read(filePath);
} catch {
continue;
}
for (const raw of content.split("\n")) {
const trimmed = raw.trim();
if (!trimmed) continue;
try {
out.push(JSON.parse(trimmed) as UsageRecord);
} catch {
// skip a corrupt line rather than failing the whole read
}
}
}
return out;
}
async readRunLog(file: TFile): Promise<RunLogData | null> {
const content = await this.vault.cachedRead(file);
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
@ -1776,6 +1834,8 @@ export class FleetRepository {
catch_up?: boolean;
effort?: string;
model?: string;
channel?: string;
channelTarget?: string;
tags?: string[];
body?: string;
}): Promise<void> {
@ -1796,6 +1856,8 @@ export class FleetRepository {
if (updates.catch_up !== undefined) frontmatter.catch_up = updates.catch_up;
if (updates.effort !== undefined) frontmatter.effort = updates.effort || undefined;
if (updates.model !== undefined) frontmatter.model = updates.model || undefined;
if (updates.channel !== undefined) frontmatter.channel = updates.channel || undefined;
if (updates.channelTarget !== undefined) frontmatter.channel_target = updates.channelTarget || undefined;
if (updates.tags !== undefined) frontmatter.tags = updates.tags;
const newBody = updates.body !== undefined ? updates.body : body;
await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody));
@ -2024,6 +2086,7 @@ export class FleetRepository {
schedule?: string;
notify?: boolean;
channel?: string;
channelTarget?: string;
body?: string;
}): Promise<void> {
const agent = this.getAgentByName(agentName);
@ -2041,6 +2104,7 @@ export class FleetRepository {
if (updates.schedule !== undefined) frontmatter.schedule = updates.schedule || undefined;
if (updates.notify !== undefined) frontmatter.notify = updates.notify;
if (updates.channel !== undefined) frontmatter.channel = updates.channel || undefined;
if (updates.channelTarget !== undefined) frontmatter.channel_target = updates.channelTarget || undefined;
const newBody = updates.body !== undefined ? updates.body : body;
await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody));
} else {
@ -2051,6 +2115,7 @@ export class FleetRepository {
if (updates.schedule) frontmatter.schedule = updates.schedule;
if (updates.notify !== undefined) frontmatter.notify = updates.notify;
if (updates.channel) frontmatter.channel = updates.channel;
if (updates.channelTarget) frontmatter.channel_target = updates.channelTarget;
const body = updates.body ?? "";
await this.vault.create(
heartbeatPath,
@ -2257,6 +2322,7 @@ export class FleetRepository {
heartbeatBody: "",
heartbeatNotify: true,
heartbeatChannel: "",
heartbeatChannelTarget: "",
};
}
@ -2319,6 +2385,8 @@ export class FleetRepository {
catchUp: asBoolean(frontmatter.catch_up, this.settings.catchUpMissedTasks),
effort: asString(frontmatter.effort),
model: asString(frontmatter.model),
channel: asString(frontmatter.channel),
channelTarget: asString(frontmatter.channel_target),
tags: asStringArray(frontmatter.tags),
body,
};
@ -2334,7 +2402,7 @@ export class FleetRepository {
}
const rawType = asString(frontmatter.type);
const validTypes: readonly string[] = ["slack", "telegram"];
const validTypes: readonly string[] = ["slack", "telegram", "discord"];
if (!rawType || !validTypes.includes(rawType)) {
this.setIssue(
path,

View file

@ -20,6 +20,7 @@ import { SecretStore } from "./services/secretStore";
import { parseClaudeMcpServers, parseCodexMcpServers, mergeImports } from "./services/mcpImport";
import { SlackAdapter } from "./services/channels/slackAdapter";
import { TelegramAdapter } from "./services/channels/telegramAdapter";
import { DiscordAdapter } from "./services/channels/discordAdapter";
import type { ChannelAdapter } from "./services/channels/adapter";
import type { ChannelConfig, ChannelCredentialEntry, FleetSettings } from "./types";
import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter } from "./utils/markdown";
@ -154,6 +155,7 @@ export default class AgentFleetPlugin extends Plugin {
getSettings: () => this.settings,
getChannelCredentials: () => this.channelCredentials.toRecord(),
getMcpAuth: () => this.mcpAuth,
recordUsage: (record) => this.runtime.recordUsage(record),
adapterFactory: (config: ChannelConfig, credential: ChannelCredentialEntry): ChannelAdapter => {
if (config.type === "slack") {
return new SlackAdapter(config, credential);
@ -161,6 +163,9 @@ export default class AgentFleetPlugin extends Plugin {
if (config.type === "telegram") {
return new TelegramAdapter(config, credential);
}
if (config.type === "discord") {
return new DiscordAdapter(config, credential);
}
throw new Error(`Channel type \`${config.type}\` is not yet supported in this version.`);
},
});
@ -171,16 +176,19 @@ export default class AgentFleetPlugin extends Plugin {
new Notice("Agent Fleet: channel manager failed to start — check console.");
}
// Wire heartbeat results to Slack channels. When an agent's heartbeat
// completes and its heartbeatChannel is set, the runtime calls this handler
// which posts the output via the channel's broadcast method (opens a DM with
// the first allowed user and posts there).
this.runtime.onHeartbeatResult((agentName, channelName, output) => {
void this.channelManager?.broadcastToChannel(
channelName,
`*Heartbeat — ${agentName}*\n\n${output}`,
).catch((err: unknown) => {
console.warn(`Agent Fleet: heartbeat channel post failed for ${agentName}`, err);
// Wire run results to channels. Fires when an agent's heartbeat completes
// (using heartbeatChannel) or when a scheduled/manual task sets a `channel`
// field. `source` is "heartbeat" or the task id, used only to label the post.
// When `target` is set the post goes to that specific channel id; otherwise it
// broadcasts (opens a DM with the first allowed user and posts there).
this.runtime.onChannelResult((agentName, channelName, output, source, target) => {
const label = source === "heartbeat" ? `Heartbeat — ${agentName}` : `${agentName}${source}`;
const text = `*${label}*\n\n${output}`;
const delivery = target
? this.channelManager?.postToChannelTarget(channelName, target, text)
: this.channelManager?.broadcastToChannel(channelName, text);
void delivery?.catch((err: unknown) => {
console.warn(`Agent Fleet: channel post failed for ${agentName}`, err);
});
});
@ -630,16 +638,20 @@ export default class AgentFleetPlugin extends Plugin {
}
private registerVaultHandlers(): void {
// The usage ledger (`_fleet/usage/`) is appended on every chat/channel turn
// and holds no parsed entities — skip it so the hot path never triggers a
// full fleet reparse.
const isLedgerPath = (p: string) => p.startsWith(`${this.settings.fleetFolder}/usage/`);
this.registerEvent(
this.app.vault.on("create", (file) => {
if (file instanceof TFile && file.path.startsWith(`${this.settings.fleetFolder}/`)) {
if (file instanceof TFile && file.path.startsWith(`${this.settings.fleetFolder}/`) && !isLedgerPath(file.path)) {
this.debouncedVaultRefresh();
}
}),
);
this.registerEvent(
this.app.vault.on("modify", (file) => {
if (file instanceof TFile && file.path.startsWith(`${this.settings.fleetFolder}/`)) {
if (file instanceof TFile && file.path.startsWith(`${this.settings.fleetFolder}/`) && !isLedgerPath(file.path)) {
this.debouncedVaultRefresh();
}
}),

View file

@ -46,6 +46,7 @@ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
heartbeatBody: "",
heartbeatNotify: true,
heartbeatChannel: "",
heartbeatChannelTarget: "",
...overrides,
};
}

View file

@ -5,6 +5,7 @@ import type {
ChannelStatus,
FleetSettings,
FleetSnapshot,
UsageRecord,
} from "../types";
import type { FleetRepository } from "../fleetRepository";
import type { McpAuthManager } from "./mcpAuth";
@ -43,6 +44,9 @@ export interface ChannelManagerDeps {
getMcpAuth?: () => McpAuthManager;
/** Factory that produces the transport-specific adapter. */
adapterFactory: ChannelAdapterFactory;
/** Sink for per-turn token/cost usage from channel sessions (the usage ledger).
* Late-bound to the current runtime so it survives runtime rebuilds. */
recordUsage?: (record: UsageRecord) => void;
/** Injectable clock, primarily for tests. */
now?: () => number;
}
@ -629,6 +633,9 @@ export class ChannelManager {
mcpAuth: this.deps.getMcpAuth?.(),
},
);
if (this.deps.recordUsage) {
session.setUsageRecorder(this.deps.recordUsage);
}
try {
await session.loadPersistedState();
@ -786,6 +793,24 @@ export class ChannelManager {
await adapter.broadcast(text);
}
/**
* Post a message to an explicit destination id within a channel (a Discord/Slack
* channel id or Telegram chat id) used for per-task delivery to a specific
* channel rather than the broadcast DM.
*/
async postToChannelTarget(channelName: string, target: string, text: string): Promise<void> {
const adapter = this.adapters.get(channelName);
if (!adapter) {
console.warn(`Agent Fleet: postToChannelTarget — no adapter for channel ${channelName}`);
return;
}
if (!adapter.sendToTarget) {
console.warn(`Agent Fleet: postToChannelTarget — adapter ${channelName} does not support sendToTarget`);
return;
}
await adapter.sendToTarget(target, text);
}
// ═══════════════════════════════════════════════════════
// Conversation lock — per-key FIFO promise chain
// ═══════════════════════════════════════════════════════

View file

@ -87,6 +87,15 @@ export interface ChannelAdapter {
*/
broadcast?(text: string): Promise<void>;
/**
* Post a message to an explicit transport-native destination id a Discord or
* Slack channel id, or a Telegram chat id independent of any inbound
* conversation. Used for per-task result delivery to a specific channel.
* Adapters should chunk + rate-limit the same way `send()` does. Optional;
* transports that don't support it can omit.
*/
sendToTarget?(target: string, text: string): Promise<void>;
onInbound(handler: InboundHandler): () => void;
onStatusChange(handler: StatusHandler): () => void;

View file

@ -0,0 +1,236 @@
import { describe, expect, it, vi } from "vitest";
// The adapter's only runtime obsidian dependency is requestUrl — mock it so we can
// assert the outbound REST request shape (headers/url) without a network call.
const { requestUrlMock } = vi.hoisted(() => ({ requestUrlMock: vi.fn() }));
vi.mock("obsidian", () => ({ requestUrl: requestUrlMock }));
import {
DiscordAdapter,
buildConversationId,
channelIdFromConversationId,
describeDiscordError,
stripLeadingMention,
} from "./discordAdapter";
import type { ChannelConfig, ChannelCredentialEntry } from "../../types";
import type { InboundMessage } from "./adapter";
function makeChannel(overrides: Partial<ChannelConfig> = {}): ChannelConfig {
return {
filePath: "_fleet/channels/test-discord.md",
name: "test-discord",
type: "discord",
defaultAgent: "test-agent",
allowedAgents: [],
enabled: true,
credentialRef: "discord-creds",
allowedUsers: [],
perUserSessions: true,
channelContext: "",
transport: {},
tags: [],
body: "",
...overrides,
};
}
const credential: ChannelCredentialEntry = { type: "discord", botToken: "test-token" };
/** Typed view into the adapter's internals for testing the inbound mapping. */
interface AdapterInternals {
selfUserId: string | null;
routeMessage(message: unknown): void;
}
function internals(adapter: DiscordAdapter): AdapterInternals {
return adapter as unknown as AdapterInternals;
}
const flush = () => new Promise((r) => setTimeout(r, 0));
describe("conversationId helpers", () => {
it("builds and parses guild conversation ids", () => {
const id = buildConversationId("guild1", "chan1");
expect(id).toBe("discord:guild1:chan1");
expect(channelIdFromConversationId(id)).toBe("chan1");
});
it("builds and parses DM conversation ids", () => {
const id = buildConversationId(undefined, "dmchan");
expect(id).toBe("discord:dm:dmchan");
expect(channelIdFromConversationId(id)).toBe("dmchan");
});
it("treats a thread as just another channel id", () => {
// A thread message arrives with channel_id = the thread's id.
const id = buildConversationId("guild1", "thread99");
expect(channelIdFromConversationId(id)).toBe("thread99");
});
it("returns null for non-discord conversation ids", () => {
expect(channelIdFromConversationId("tg:123")).toBeNull();
expect(channelIdFromConversationId("slack:t:c:thread:1")).toBeNull();
});
});
describe("stripLeadingMention", () => {
it("strips a leading <@id> mention", () => {
expect(stripLeadingMention("<@123> hello there", "123")).toBe("hello there");
});
it("strips a leading <@!id> nickname mention", () => {
expect(stripLeadingMention("<@!123> hi", "123")).toBe("hi");
});
it("ignores mentions of other users", () => {
expect(stripLeadingMention("<@999> hi", "123")).toBe("<@999> hi");
});
it("trims surrounding whitespace and handles no mention", () => {
expect(stripLeadingMention(" plain text ", "123")).toBe("plain text");
expect(stripLeadingMention("plain", null)).toBe("plain");
});
});
describe("MESSAGE_CREATE → InboundMessage mapping", () => {
it("emits an InboundMessage for a normal guild message", async () => {
const adapter = new DiscordAdapter(makeChannel(), credential);
internals(adapter).selfUserId = "bot-self";
const received: InboundMessage[] = [];
adapter.onInbound((m) => received.push(m));
internals(adapter).routeMessage({
id: "msg1",
channel_id: "chan1",
guild_id: "guild1",
author: { id: "user1", bot: false },
content: "<@bot-self> do the thing",
timestamp: "2026-06-19T00:00:00.000Z",
});
await flush();
expect(received).toHaveLength(1);
const msg = received[0]!;
expect(msg.conversationId).toBe("discord:guild1:chan1");
expect(msg.externalUserId).toBe("user1");
expect(msg.text).toBe("do the thing");
expect(msg.meta).toMatchObject({
discord_channel_id: "chan1",
discord_guild_id: "guild1",
is_dm: false,
});
});
it("marks DMs with is_dm and a discord:dm: conversation id", async () => {
const adapter = new DiscordAdapter(makeChannel(), credential);
internals(adapter).selfUserId = "bot-self";
const received: InboundMessage[] = [];
adapter.onInbound((m) => received.push(m));
internals(adapter).routeMessage({
id: "msg2",
channel_id: "dmchan",
author: { id: "user2", bot: false },
content: "hi",
timestamp: "2026-06-19T00:00:00.000Z",
});
await flush();
expect(received).toHaveLength(1);
expect(received[0]!.conversationId).toBe("discord:dm:dmchan");
expect(received[0]!.meta).toMatchObject({ is_dm: true });
});
it("ignores bot-authored and self-authored messages", async () => {
const adapter = new DiscordAdapter(makeChannel(), credential);
internals(adapter).selfUserId = "bot-self";
const received: InboundMessage[] = [];
adapter.onInbound((m) => received.push(m));
internals(adapter).routeMessage({
id: "m3", channel_id: "c", guild_id: "g",
author: { id: "other-bot", bot: true }, content: "beep",
});
internals(adapter).routeMessage({
id: "m4", channel_id: "c", guild_id: "g",
author: { id: "bot-self", bot: false }, content: "echo",
});
await flush();
expect(received).toHaveLength(0);
});
it("ignores empty-content messages with no attachments", async () => {
const adapter = new DiscordAdapter(makeChannel(), credential);
internals(adapter).selfUserId = "bot-self";
const received: InboundMessage[] = [];
adapter.onInbound((m) => received.push(m));
internals(adapter).routeMessage({
id: "m5", channel_id: "c", guild_id: "g",
author: { id: "user1", bot: false }, content: "",
});
await flush();
expect(received).toHaveLength(0);
});
it("rejects a non-discord credential", () => {
expect(
() => new DiscordAdapter(makeChannel(), { type: "telegram", botToken: "x" }),
).toThrow(/discord credential/);
});
});
describe("REST request shape", () => {
it("sends the required Discord User-Agent + bot auth on outbound calls", async () => {
// Regression guard: a missing User-Agent gets Cloudflare-blocked (403 code 40333)
// on every REST call while the Gateway still connects.
requestUrlMock.mockReset();
requestUrlMock.mockResolvedValue({ status: 200, json: {}, text: "" });
const adapter = new DiscordAdapter(makeChannel(), credential);
await adapter.send("discord:dm:chan1", "hello");
expect(requestUrlMock).toHaveBeenCalledTimes(1);
const opts = requestUrlMock.mock.calls[0]![0] as {
url: string;
method: string;
headers: Record<string, string>;
body?: string;
};
expect(opts.url).toContain("/channels/chan1/messages");
expect(opts.method).toBe("POST");
expect(opts.headers["User-Agent"]).toMatch(/^DiscordBot \(/);
expect(opts.headers.Authorization).toBe("Bot test-token");
expect(opts.body).toContain("hello");
});
it("sendToTarget posts directly to the given channel id", async () => {
requestUrlMock.mockReset();
requestUrlMock.mockResolvedValue({ status: 200, json: {}, text: "" });
const adapter = new DiscordAdapter(makeChannel(), credential);
await adapter.sendToTarget("987654321", "task result");
expect(requestUrlMock).toHaveBeenCalledTimes(1);
const opts = requestUrlMock.mock.calls[0]![0] as { url: string; body?: string };
expect(opts.url).toContain("/channels/987654321/messages");
expect(opts.body).toContain("task result");
});
});
describe("describeDiscordError", () => {
it("extracts Discord's message + code from a JSON error body", () => {
expect(describeDiscordError('{"message":"internal network error","code":40333}'))
.toBe("internal network error (code 40333)");
expect(describeDiscordError('{"message":"Missing Access","code":50001}'))
.toBe("Missing Access (code 50001)");
});
it("falls back to raw text for non-JSON bodies", () => {
expect(describeDiscordError("Bad Gateway")).toBe("Bad Gateway");
expect(describeDiscordError("")).toBe("no body");
expect(describeDiscordError(undefined)).toBe("no body");
});
});

View file

@ -0,0 +1,852 @@
import WebSocket from "ws";
import { requestUrl } from "obsidian";
import type {
ChannelConfig,
ChannelCredentialEntry,
ChannelStatus,
ChannelType,
} from "../../types";
import type {
ChannelAdapter,
InboundHandler,
InboundImage,
InboundMessage,
StatusHandler,
} from "./adapter";
/**
* Discord adapter using the Gateway (WebSocket) for inbound events and the REST
* API for everything outbound. Hand-rolled over `ws` no discord.js to stay
* consistent with the Slack/Telegram adapters and avoid a heavy dependency.
*
* Transport: outbound WebSocket + outbound HTTPS, so no NAT/firewall concerns.
*
* Security invariant: the allowlist check uses `author.id` from the
* Gateway-delivered MESSAGE_CREATE payload. The Gateway connection is
* authenticated by the bot token during IDENTIFY, so `author.id` is trustworthy.
* Never accept a sender id from anywhere else (e.g. message content).
*
* Note: the Message Content intent is privileged and must be enabled in the
* Discord Developer Portal, or message `content` arrives empty. A disallowed
* intent closes the Gateway with code 4014 we surface that as `needs-auth`
* with a clear console hint.
*/
interface DiscordCredential {
type: "discord";
botToken: string;
}
const DISCORD_API = "https://discord.com/api/v10";
const DEFAULT_GATEWAY = "wss://gateway.discord.gg";
const GATEWAY_QUERY = "?v=10&encoding=json";
// Discord's REST API REQUIRES a User-Agent in the form `DiscordBot ($url, $version)`.
// Without it, requests get Cloudflare-blocked with HTTP 403 `internal network error`
// (code 40333) — every REST call fails while the Gateway (no Cloudflare WAF) still
// connects. See https://docs.discord.food/topics/errors.
const DISCORD_USER_AGENT = "DiscordBot (https://github.com/denberek/obsidian-agent-fleet, 0.13.6)";
// Gateway intents: GUILD_MESSAGES (1<<9) | DIRECT_MESSAGES (1<<12) | MESSAGE_CONTENT (1<<15)
const INTENTS = (1 << 9) | (1 << 12) | (1 << 15); // 37376
// Gateway opcodes
const OP_DISPATCH = 0;
const OP_HEARTBEAT = 1;
const OP_IDENTIFY = 2;
const OP_RESUME = 6;
const OP_RECONNECT = 7;
const OP_INVALID_SESSION = 9;
const OP_HELLO = 10;
const OP_HEARTBEAT_ACK = 11;
// Interaction types
const INTERACTION_APPLICATION_COMMAND = 2;
const INTERACTION_MESSAGE_COMPONENT = 3;
// Interaction callback types
const CALLBACK_CHANNEL_MESSAGE = 4;
const CALLBACK_UPDATE_MESSAGE = 7;
// Message flags
const FLAG_EPHEMERAL = 1 << 6; // 64
export class DiscordAdapter implements ChannelAdapter {
readonly type: ChannelType = "discord";
public config: ChannelConfig;
private readonly credential: DiscordCredential;
private ws: WebSocket | null = null;
private status: ChannelStatus = "stopped";
private stopping = false;
private backoffMs = 1000;
private reconnectTimer: number | null = null;
// Gateway session state (for RESUME).
private sessionId: string | null = null;
private resumeGatewayUrl: string | null = null;
private seq: number | null = null;
private canResume = false;
// Heartbeat state.
private heartbeatTimer: number | null = null;
private heartbeatInitialTimer: number | null = null;
private heartbeatAcked = true;
// Identity learned from the READY dispatch.
private selfUserId: string | null = null;
private applicationId: string | null = null;
private commandRegistered = false;
private warnedEmptyContent = false;
private typingIntervals = new Map<string, number>();
/** Per-channel outbound queue so a chunked reply doesn't burst Discord's rate limit. */
private readonly sendQueues = new Map<string, Promise<void>>();
private readonly inboundHandlers = new Set<InboundHandler>();
private readonly statusHandlers = new Set<StatusHandler>();
private readonly agentSwitchHandlers = new Set<(conversationId: string, agentName: string, userId: string) => void>();
/** Resolver for the validated (existing + enabled) agent list, set by the
* ChannelManager. Falls back to raw allowed_agents when unset. */
private allowedAgentsResolver?: () => string[];
constructor(config: ChannelConfig, credential: ChannelCredentialEntry) {
if (credential.type !== "discord") {
throw new Error(`DiscordAdapter requires a discord credential, got ${credential.type}`);
}
this.config = config;
this.credential = credential;
}
// ═══════════════════════════════════════════════════════
// Lifecycle
// ═══════════════════════════════════════════════════════
async start(): Promise<void> {
this.stopping = false;
this.backoffMs = 1000;
this.canResume = false;
await this.connect();
}
async stop(): Promise<void> {
this.stopping = true;
if (this.reconnectTimer) {
window.clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.clearHeartbeat();
for (const [, interval] of this.typingIntervals) {
window.clearInterval(interval);
}
this.typingIntervals.clear();
try {
this.ws?.close();
} catch {
// best-effort
}
this.ws = null;
this.setStatus("stopped");
}
getStatus(): ChannelStatus {
return this.status;
}
// ═══════════════════════════════════════════════════════
// Outbound
// ═══════════════════════════════════════════════════════
async send(conversationId: string, text: string): Promise<void> {
const channelId = channelIdFromConversationId(conversationId);
if (!channelId) return;
await this.sendToTarget(channelId, text);
}
/** Post directly to a Discord channel id (server channel, thread, or DM channel). */
async sendToTarget(channelId: string, text: string): Promise<void> {
if (!channelId) return;
// Discord's message content cap is 2000 chars.
const chunks = splitText(text, 2000);
await this.enqueueSend(channelId, async () => {
for (const chunk of chunks) {
await this.discordApi("POST", `/channels/${channelId}/messages`, { content: chunk });
}
});
}
async setTyping(conversationId: string, on: boolean): Promise<void> {
const channelId = channelIdFromConversationId(conversationId);
if (!channelId) return;
if (on) {
// Clear any existing interval first to prevent a double-create leak.
const existing = this.typingIntervals.get(conversationId);
if (existing) window.clearInterval(existing);
// Discord typing lasts ~10s; send immediately and refresh every 8s.
try {
await this.discordApi("POST", `/channels/${channelId}/typing`);
} catch (err) {
console.warn("Agent Fleet: Discord typing trigger failed", err);
}
const interval = window.setInterval(() => {
void this.discordApi("POST", `/channels/${channelId}/typing`).catch(() => { /* best-effort */ });
}, 8000);
this.typingIntervals.set(conversationId, interval);
} else {
const interval = this.typingIntervals.get(conversationId);
if (interval) {
window.clearInterval(interval);
this.typingIntervals.delete(conversationId);
}
}
}
async broadcast(text: string): Promise<void> {
// Post to the first allowed user via a DM channel.
const userId = this.config.allowedUsers[0];
if (!userId) return;
try {
const dm = await this.discordApi<{ id?: string }>("POST", "/users/@me/channels", {
recipient_id: userId,
});
const channelId = dm?.id;
if (!channelId) return;
const chunks = splitText(text, 2000);
for (const chunk of chunks) {
await this.discordApi("POST", `/channels/${channelId}/messages`, { content: chunk });
}
} catch (err) {
console.error(`Agent Fleet: Discord broadcast failed on ${this.config.name}`, err);
}
}
// ═══════════════════════════════════════════════════════
// Event subscription
// ═══════════════════════════════════════════════════════
onInbound(handler: InboundHandler): () => void {
this.inboundHandlers.add(handler);
return () => this.inboundHandlers.delete(handler);
}
onStatusChange(handler: StatusHandler): () => void {
this.statusHandlers.add(handler);
return () => this.statusHandlers.delete(handler);
}
onAgentSwitch(handler: (conversationId: string, agentName: string, userId: string) => void): () => void {
this.agentSwitchHandlers.add(handler);
return () => this.agentSwitchHandlers.delete(handler);
}
setAllowedAgentsResolver(resolve: () => string[]): void {
this.allowedAgentsResolver = resolve;
}
/** The agents the picker should offer: validated list if available, else the
* raw configured allow-list. */
private pickerAgents(): string[] {
if (this.allowedAgentsResolver) return this.allowedAgentsResolver();
return this.config.allowedAgents;
}
// ═══════════════════════════════════════════════════════
// Gateway connection
// ═══════════════════════════════════════════════════════
private async connect(): Promise<void> {
if (this.stopping) return;
this.setStatus(this.ws ? "reconnecting" : "connecting");
const base = this.canResume && this.resumeGatewayUrl ? this.resumeGatewayUrl : DEFAULT_GATEWAY;
const url = `${base}${GATEWAY_QUERY}`;
try {
const ws = new WebSocket(url);
// Attach handlers BEFORE assigning to this.ws to avoid a race where events
// fire between construction and handler attachment.
ws.on("message", (data: WebSocket.RawData) => {
this.handleFrame(data);
});
ws.on("error", (err: Error) => {
console.warn(`Agent Fleet: Discord WebSocket error on ${this.config.name}`, err);
});
ws.on("close", (code: number) => {
this.ws = null;
this.clearHeartbeat();
this.handleClose(code);
});
this.ws = ws;
// Connection timeout — if we don't get HELLO within 30s, tear down and retry.
const connectTimeout = window.setTimeout(() => {
if (this.status === "connecting" || this.status === "reconnecting") {
console.warn(`Agent Fleet: Discord WebSocket connect timeout on ${this.config.name}`);
try { ws.close(); } catch { /* best-effort */ }
}
}, 30_000);
ws.on("close", () => window.clearTimeout(connectTimeout));
const statusUnsub = this.onStatusChange((status) => {
if (status === "connected") {
window.clearTimeout(connectTimeout);
statusUnsub();
}
});
} catch (err) {
console.error("Agent Fleet: Discord WebSocket open failed", err);
this.setStatus("error");
this.scheduleReconnect();
}
}
private handleClose(code: number): void {
// Fatal codes won't recover by retrying — they need the user to fix the app
// config (bad token / disallowed intents). Auto-reconnecting every ≤30s with a
// known-bad IDENTIFY just wastes cycles and risks Discord rate-limiting the IP,
// so we surface needs-auth and STOP. Recovery happens when the channel restarts
// (Obsidian reload, or a connection-relevant config edit re-creates the adapter).
// 4004 authentication failed · 4013 invalid intents · 4014 disallowed intents
let fatal = false;
if (code === 4014) {
console.error(
`Agent Fleet: Discord channel ${this.config.name} — disallowed intents (4014). ` +
"Enable the Message Content intent in the Developer Portal → your app → Bot → Privileged Gateway Intents, then reload.",
);
this.canResume = false;
this.setStatus("needs-auth");
fatal = true;
} else if (code === 4004 || code === 4013) {
console.error(
`Agent Fleet: Discord channel ${this.config.name} — gateway auth/intents error (${code}). ` +
"Check the bot token credential, then reload.",
);
this.canResume = false;
this.setStatus("needs-auth");
fatal = true;
} else if (code === 4007 || code === 4009) {
// Invalid seq / session timed out — reconnect fresh (no resume).
this.canResume = false;
}
if (!this.stopping && !fatal) {
this.scheduleReconnect();
}
}
private handleFrame(data: WebSocket.RawData): void {
let frame: GatewayFrame;
try {
frame = JSON.parse(data.toString()) as GatewayFrame;
} catch {
return;
}
if (typeof frame.s === "number") this.seq = frame.s;
switch (frame.op) {
case OP_HELLO: {
const interval = (frame.d as { heartbeat_interval?: number } | undefined)?.heartbeat_interval;
this.startHeartbeat(interval ?? 41_250);
if (this.canResume && this.sessionId && this.seq !== null) {
this.sendGateway(OP_RESUME, {
token: this.credential.botToken,
session_id: this.sessionId,
seq: this.seq,
});
} else {
this.identify();
}
return;
}
case OP_HEARTBEAT: {
// Server requested an immediate heartbeat.
this.sendGateway(OP_HEARTBEAT, this.seq);
return;
}
case OP_HEARTBEAT_ACK: {
this.heartbeatAcked = true;
return;
}
case OP_RECONNECT: {
// Server asked us to reconnect — keep the session so we can RESUME.
try { this.ws?.close(); } catch { /* best-effort */ }
return;
}
case OP_INVALID_SESSION: {
// d is a boolean: whether the session is resumable.
const resumable = frame.d === true;
if (!resumable) {
this.canResume = false;
this.sessionId = null;
}
// Discord asks for a short random delay (15s) before re-identifying.
window.setTimeout(() => {
try { this.ws?.close(); } catch { /* best-effort */ }
}, 1000 + Math.floor(Math.random() * 4000));
return;
}
case OP_DISPATCH: {
this.handleDispatch(frame.t ?? "", frame.d);
return;
}
default:
return;
}
}
private handleDispatch(type: string, d: unknown): void {
if (type === "READY") {
const ready = d as {
session_id?: string;
resume_gateway_url?: string;
user?: { id?: string };
application?: { id?: string };
};
this.sessionId = ready.session_id ?? null;
this.resumeGatewayUrl = ready.resume_gateway_url ?? null;
this.selfUserId = ready.user?.id ?? null;
this.applicationId = ready.application?.id ?? null;
this.canResume = true;
this.backoffMs = 1000;
this.setStatus("connected");
void this.registerAgentsCommand();
return;
}
if (type === "RESUMED") {
this.backoffMs = 1000;
this.setStatus("connected");
return;
}
if (type === "MESSAGE_CREATE") {
this.routeMessage(d as DiscordMessage);
return;
}
if (type === "INTERACTION_CREATE") {
void this.handleInteraction(d as DiscordInteraction);
return;
}
}
private identify(): void {
this.sendGateway(OP_IDENTIFY, {
token: this.credential.botToken,
intents: INTENTS,
properties: { os: "linux", browser: "agent-fleet", device: "agent-fleet" },
});
}
private sendGateway(op: number, d: unknown): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
try {
this.ws.send(JSON.stringify({ op, d }));
} catch (err) {
console.warn("Agent Fleet: Discord gateway send failed", err);
}
}
private startHeartbeat(intervalMs: number): void {
this.clearHeartbeat();
this.heartbeatAcked = true;
const beat = () => {
if (!this.heartbeatAcked) {
// Zombie connection — no ACK since the last beat. Reconnect (resume).
console.warn(`Agent Fleet: Discord heartbeat not ACKed on ${this.config.name} — reconnecting`);
try { this.ws?.close(); } catch { /* best-effort */ }
return;
}
this.heartbeatAcked = false;
this.sendGateway(OP_HEARTBEAT, this.seq);
};
// First beat after interval * jitter (per Discord docs), then every interval.
this.heartbeatInitialTimer = window.setTimeout(() => {
beat();
this.heartbeatTimer = window.setInterval(beat, intervalMs);
}, Math.floor(intervalMs * Math.random()));
}
private clearHeartbeat(): void {
if (this.heartbeatInitialTimer) {
window.clearTimeout(this.heartbeatInitialTimer);
this.heartbeatInitialTimer = null;
}
if (this.heartbeatTimer) {
window.clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private scheduleReconnect(): void {
if (this.stopping) return;
if (this.reconnectTimer) return;
const delay = this.backoffMs;
this.backoffMs = Math.min(30_000, this.backoffMs * 2);
console.warn(`Agent Fleet: Discord channel ${this.config.name} scheduling reconnect in ${delay}ms`);
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null;
if (this.stopping) return;
void this.connect();
}, delay);
}
// ═══════════════════════════════════════════════════════
// Inbound messages
// ═══════════════════════════════════════════════════════
private routeMessage(message: DiscordMessage): void {
if (!message.author || message.author.bot) return;
if (this.selfUserId && message.author.id === this.selfUserId) return;
if (!message.channel_id) return;
const imageAttachments = (message.attachments ?? []).filter(
(a) => typeof a.content_type === "string" && a.content_type.startsWith("image/"),
);
let text = stripLeadingMention(message.content ?? "", this.selfUserId);
// The agent prefix / routing happens in ChannelManager. Empty content with no
// attachments usually means the privileged Message Content intent is off.
if (!text && imageAttachments.length === 0) {
if (!this.warnedEmptyContent) {
this.warnedEmptyContent = true;
console.warn(
`Agent Fleet: Discord channel ${this.config.name} received a message with empty content. ` +
"If this persists, enable the Message Content intent in the Developer Portal.",
);
}
return;
}
const conversationId = buildConversationId(message.guild_id, message.channel_id);
void this.buildAndEmitMessage(message, text, conversationId, imageAttachments);
}
private async buildAndEmitMessage(
message: DiscordMessage,
text: string,
conversationId: string,
imageAttachments: DiscordAttachment[],
): Promise<void> {
const images: InboundImage[] = [];
for (const attachment of imageAttachments) {
try {
const resp = await requestUrl({ url: attachment.url, method: "GET" });
images.push({
data: resp.arrayBuffer,
filename: attachment.filename || `attachment_${message.id}`,
mimeType: attachment.content_type ?? "image/jpeg",
});
} catch (err) {
console.warn("Agent Fleet: Discord attachment download failed", err);
}
}
const msg: InboundMessage = {
conversationId,
externalUserId: message.author!.id,
text,
timestamp: message.timestamp ?? new Date().toISOString(),
meta: {
discord_guild_id: message.guild_id,
discord_channel_id: message.channel_id,
discord_message_id: message.id,
is_dm: !message.guild_id,
},
...(images.length > 0 ? { images } : {}),
};
for (const handler of this.inboundHandlers) {
try {
handler(msg);
} catch (err) {
console.error("Agent Fleet: Discord inbound handler threw", err);
}
}
}
// ═══════════════════════════════════════════════════════
// /agents slash command + buttons
// ═══════════════════════════════════════════════════════
private async registerAgentsCommand(): Promise<void> {
if (this.commandRegistered || !this.applicationId) return;
try {
await this.discordApi("PUT", `/applications/${this.applicationId}/commands`, [
{ name: "agents", description: "Switch the active agent", type: 1 },
]);
this.commandRegistered = true;
} catch (err) {
// Best-effort — global command registration can take up to ~1h to propagate.
console.warn(`Agent Fleet: Discord /agents command registration failed on ${this.config.name}`, err);
}
}
private async handleInteraction(interaction: DiscordInteraction): Promise<void> {
if (interaction.type === INTERACTION_APPLICATION_COMMAND) {
if (interaction.data?.name === "agents") {
await this.respondWithAgentPicker(interaction);
}
return;
}
if (interaction.type === INTERACTION_MESSAGE_COMPONENT) {
const customId = interaction.data?.custom_id ?? "";
if (!customId.startsWith("switch:")) return;
const agentName = customId.slice("switch:".length);
const userId = interaction.member?.user?.id ?? interaction.user?.id;
const channelId = interaction.channel_id;
if (!userId || !channelId) return;
const conversationId = buildConversationId(interaction.guild_id, channelId);
// Notify ChannelManager of the binding change.
for (const handler of this.agentSwitchHandlers) {
try {
handler(conversationId, agentName, userId);
} catch (err) {
console.error("Agent Fleet: Discord agent switch handler threw", err);
}
}
// Acknowledge by updating the original message to show the selection.
await this.respondToInteraction(interaction, CALLBACK_UPDATE_MESSAGE, {
content: `Active agent: **${agentName}**`,
components: this.buildAgentButtons(agentName),
});
return;
}
}
private async respondWithAgentPicker(interaction: DiscordInteraction): Promise<void> {
const agents = this.pickerAgents();
if (agents.length === 0) {
await this.respondToInteraction(interaction, CALLBACK_CHANNEL_MESSAGE, {
content: "No agents available. Add existing, enabled agents to `allowed_agents` in the channel file.",
flags: FLAG_EPHEMERAL,
});
return;
}
await this.respondToInteraction(interaction, CALLBACK_CHANNEL_MESSAGE, {
content: "Select an agent to chat with:",
flags: FLAG_EPHEMERAL,
components: this.buildAgentButtons(this.config.defaultAgent),
});
}
/** Build action-row button components (≤5 buttons/row, ≤5 rows = 25 agents). */
private buildAgentButtons(activeAgent: string): unknown[] {
const agents = this.pickerAgents().slice(0, 25);
const rows: unknown[] = [];
for (let i = 0; i < agents.length; i += 5) {
const slice = agents.slice(i, i + 5);
rows.push({
type: 1,
components: slice.map((name) => ({
type: 2,
// style 1 = primary (active), 2 = secondary
style: name === activeAgent ? 1 : 2,
label: name === activeAgent ? `${name}` : name,
custom_id: `switch:${name}`,
})),
});
}
return rows;
}
private async respondToInteraction(
interaction: DiscordInteraction,
callbackType: number,
data: Record<string, unknown>,
): Promise<void> {
try {
await this.discordApi(
"POST",
`/interactions/${interaction.id}/${interaction.token}/callback`,
{ type: callbackType, data },
);
} catch (err) {
console.warn("Agent Fleet: Discord interaction response failed", err);
}
}
// ═══════════════════════════════════════════════════════
// Discord REST API + per-channel send queue
// ═══════════════════════════════════════════════════════
private async discordApi<T = Record<string, unknown>>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const res = await requestUrl({
url: `${DISCORD_API}${path}`,
method,
contentType: "application/json",
headers: {
Authorization: `Bot ${this.credential.botToken}`,
"User-Agent": DISCORD_USER_AGENT,
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
// Handle statuses ourselves so we can honor retry_after on 429.
throw: false,
});
if (res.status === 429) {
const retryAfter = (res.json as { retry_after?: number } | undefined)?.retry_after
?? Number(res.headers["retry-after"] ?? "1");
await new Promise((r) => window.setTimeout(r, Math.max(1000, retryAfter * 1000)));
return this.discordApi<T>(method, path, body);
}
if (res.status === 401 || res.status === 403) {
// Surface Discord's real reason (body carries `message` + numeric `code`,
// e.g. 50001 Missing Access, 50007 Cannot send messages to this user,
// 0 401:Unauthorized). Without it a 403 is unactionable.
throw new Error(`Discord ${method} ${path} ${res.status}: ${describeDiscordError(res.text)}`);
}
if (res.status < 200 || res.status >= 300) {
throw new Error(`Discord ${method} ${path} HTTP ${res.status}: ${res.text}`);
}
// 204 No Content (e.g. typing, interaction callbacks) has no JSON body.
if (res.status === 204) return undefined as T;
return res.json as T;
}
/** Serialize sends per Discord channel id so a chunked reply stays under the
* per-channel rate limit. */
private async enqueueSend(channel: string, fn: () => Promise<void>): Promise<void> {
const prev = this.sendQueues.get(channel) ?? Promise.resolve();
const next = prev.then(fn);
// `wrapped` keeps the per-channel chain alive (never rejects) so a failed send
// doesn't poison the next one. But the CALLER must still see this send's failure
// — otherwise delivery errors are invisible (the dashboard counts them as sent
// and the channel status never reflects them). So await `next`, not `wrapped`.
const wrapped = next.catch(() => { /* keep chain unrejected for the next send */ });
this.sendQueues.set(channel, wrapped);
try {
await next;
} finally {
if (this.sendQueues.get(channel) === wrapped) {
this.sendQueues.delete(channel);
}
}
}
private setStatus(next: ChannelStatus): void {
if (this.status === next) return;
this.status = next;
for (const h of this.statusHandlers) {
try { h(next); } catch { /* best-effort */ }
}
}
}
// ═══════════════════════════════════════════════════════
// Discord types (minimal subset)
// ═══════════════════════════════════════════════════════
interface GatewayFrame {
op: number;
d?: unknown;
s?: number | null;
t?: string | null;
}
interface DiscordAttachment {
id: string;
filename: string;
url: string;
content_type?: string;
size?: number;
}
interface DiscordMessage {
id: string;
channel_id?: string;
guild_id?: string;
author?: { id: string; bot?: boolean; username?: string };
content?: string;
timestamp?: string;
attachments?: DiscordAttachment[];
}
interface DiscordInteraction {
id: string;
token: string;
type: number;
channel_id?: string;
guild_id?: string;
member?: { user?: { id: string } };
user?: { id: string };
data?: { name?: string; custom_id?: string; component_type?: number };
message?: { id: string };
}
// ═══════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════
/**
* Conversation id shape:
* guild message / thread: `discord:<guild_id>:<channel_id>`
* DM: `discord:dm:<channel_id>`
* A thread is just another channel_id, so per-thread isolation is automatic.
* The channel id is always the third segment in both forms.
*/
export function buildConversationId(guildId: string | undefined, channelId: string): string {
return guildId ? `discord:${guildId}:${channelId}` : `discord:dm:${channelId}`;
}
export function channelIdFromConversationId(conversationId: string): string | null {
const parts = conversationId.split(":");
if (parts[0] !== "discord") return null;
return parts[2] ?? null;
}
/** Strip a leading bot mention (`<@id>` or `<@!id>`) so the agent sees clean text. */
export function stripLeadingMention(content: string, selfUserId: string | null): string {
let text = content.trimStart();
if (selfUserId) {
const mention = new RegExp(`^<@!?${selfUserId}>\\s*`);
text = text.replace(mention, "");
}
return text.trim();
}
/** Extract Discord's human-readable error (`message` + `code`) from a response
* body, falling back to the raw text. Discord error bodies are JSON like
* `{"message":"Missing Access","code":50001}`. */
export function describeDiscordError(rawBody: string | undefined): string {
const raw = rawBody ?? "";
try {
const j = JSON.parse(raw) as { message?: string; code?: number };
if (j && (j.message !== undefined || j.code !== undefined)) {
return `${j.message ?? "error"} (code ${j.code ?? "?"})`;
}
} catch {
// not JSON — fall through to raw
}
return raw || "no body";
}
function splitText(text: string, limit: number): string[] {
if (text.length <= limit) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > limit) {
let cutAt = remaining.lastIndexOf("\n\n", limit);
if (cutAt < limit / 2) cutAt = remaining.lastIndexOf("\n", limit);
if (cutAt < limit / 2) cutAt = limit;
chunks.push(remaining.slice(0, cutAt));
remaining = remaining.slice(cutAt).replace(/^\n+/, "");
}
if (remaining) chunks.push(remaining);
return chunks;
}

View file

@ -194,6 +194,15 @@ export class SlackAdapter implements ChannelAdapter {
});
}
/** Post directly to a Slack channel id (the bot must be a member). */
async sendToTarget(channelId: string, text: string): Promise<void> {
if (!channelId) return;
const mrkdwn = markdownToMrkdwn(text);
await this.enqueueSend(channelId, async () => {
await this.slackApi("chat.postMessage", { channel: channelId, text: mrkdwn });
});
}
async broadcast(text: string): Promise<void> {
// Post to the first allowed user's DM. Open the DM channel first via
// conversations.open, then post with chat.postMessage.

View file

@ -135,6 +135,19 @@ export class TelegramAdapter implements ChannelAdapter {
}
}
/** Post directly to a Telegram chat id (user, group, or @channelusername). */
async sendToTarget(chatId: string, text: string): Promise<void> {
if (!chatId) return;
const chunks = splitText(text, 4096);
for (const chunk of chunks) {
await this.tgApi("sendMessage", {
chat_id: chatId,
text: chunk,
parse_mode: "Markdown",
});
}
}
async setTyping(conversationId: string, on: boolean): Promise<void> {
const chatId = chatIdFromConversationId(conversationId);
if (!chatId) return;

View file

@ -39,6 +39,7 @@ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
heartbeatBody: "",
heartbeatNotify: true,
heartbeatChannel: "",
heartbeatChannelTarget: "",
...overrides,
};
}

View file

@ -2,7 +2,7 @@ import { type ChildProcess } from "child_process";
import { createHash, randomUUID } from "crypto";
import { normalizePath, TFile } from "obsidian";
import type { Vault } from "obsidian";
import type { AgentConfig, ChatMessage, FleetSettings } from "../types";
import type { AgentConfig, ChatMessage, FleetSettings, UsageRecord } from "../types";
import type { FleetRepository } from "../fleetRepository";
import { slugify } from "../utils/markdown";
import { resolveModel, shouldPassModelFlag } from "./../utils/modelResolution";
@ -320,6 +320,14 @@ export class ChatSession {
// Stats (derived from stream-json events)
private stats: ChatSessionStats = { costTotalUsd: 0, turnCount: 0 };
/** Sink for per-turn token/cost records (the usage ledger). Set by the plugin. */
private usageRecorder?: (record: UsageRecord) => void;
/** Wire the per-turn usage ledger sink. Called once after construction by the
* plugin for both chat-panel and channel sessions. */
setUsageRecorder(fn: (record: UsageRecord) => void): void {
this.usageRecorder = fn;
}
private statsListeners = new Set<(s: ChatSessionStats) => void>();
/** Subscribe to stats changes. Fires once immediately with current state. */
@ -1008,6 +1016,9 @@ export class ChatSession {
}
this.stats.turnCount += 1;
dirty = true;
// Ledger this turn's tokens + cost so chat/channel usage counts toward the
// dashboard totals (runs are counted separately via their run logs).
this.recordTurnUsage(event as unknown as Record<string, unknown>, costDelta);
}
// After digesting a result event, evaluate the auto-compact threshold.
@ -1021,6 +1032,33 @@ export class ChatSession {
if (dirty) this.emitStats();
}
/** Emit one usage-ledger record for the just-completed turn. Token breakdown
* comes from the result event's aggregate `usage`; cost is the CLI's per-turn
* `total_cost_usd` when present (else estimated from tokens at read time). */
private recordTurnUsage(event: Record<string, unknown>, costUsd: number): void {
if (!this.usageRecorder) return;
const usage = event.usage as Record<string, unknown> | undefined;
const num = (v: unknown) => (typeof v === "number" ? v : 0);
const inputTokens = num(usage?.input_tokens);
const outputTokens = num(usage?.output_tokens);
const cacheReadTokens = num(usage?.cache_read_input_tokens);
const cacheCreateTokens = num(usage?.cache_creation_input_tokens);
const totalTokens = inputTokens + outputTokens + cacheReadTokens + cacheCreateTokens;
if (totalTokens === 0 && costUsd === 0) return;
this.usageRecorder({
ts: new Date().toISOString(),
agent: this.agent.name,
source: this.channelName ? "channel" : "chat",
model: this.stats.concreteModel ?? this.agent.model,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreateTokens,
totalTokens,
...(costUsd > 0 ? { costUsd } : {}),
});
}
/** If the agent has autoCompactThreshold set and the latest turn's
* context fraction is above it, queue a /compact for the next user turn.
* Rate-limited to once per 30s to avoid loops if the CLI reports odd usage. */

View file

@ -24,6 +24,7 @@ import type {
SkillCandidate,
SkillProposal,
TaskConfig,
UsageRecord,
} from "../types";
export class FleetRuntime {
@ -47,6 +48,9 @@ export class FleetRuntime {
* for days that actually had activity. */
private chartRuns: RunLogData[] = [];
private static readonly CHART_WINDOW_DAYS = 14;
/** Chat/channel usage records over the chart window. Feeds the comprehensive
* token/cost totals (runs are counted from run logs). */
private recentUsage: UsageRecord[] = [];
private statusChangeListeners = new Set<() => void>();
private runOutputListeners = new Map<string, Set<(chunk: string) => void>>();
private runOutputBuffers = new Map<string, string>();
@ -64,8 +68,11 @@ export class FleetRuntime {
private heartbeatRegisteredAt = 0;
/** Tracks agents with a heartbeat currently in-flight (prevents duplicate runs). */
private heartbeatsInFlight = new Set<string>();
/** Callback for heartbeat result delivery (e.g. Slack posting). Set by main.ts. */
private heartbeatResultHandler?: (agentName: string, channel: string, output: string) => void;
/** Callback for run-result channel delivery (e.g. Slack/Discord posting). Set by
* main.ts. `source` is "heartbeat" for heartbeat runs or the task id otherwise,
* so the handler can label the post. `target` is an optional transport-native
* destination id (post to a specific channel) empty means broadcast/DM. */
private channelResultHandler?: (agentName: string, channel: string, output: string, source: string, target: string) => void;
/** Single choke point for memory writes (per-agent locked). */
private readonly memoryWriter: MemoryWriter;
@ -105,11 +112,13 @@ export class FleetRuntime {
}
/**
* Register a callback that receives heartbeat results for delivery to external
* channels (e.g. Slack). Called from main.ts after the ChannelManager is ready.
* Register a callback that receives run results for delivery to external
* channels (e.g. Slack, Discord). Fires for heartbeat runs (using the agent's
* heartbeatChannel) and for any scheduled/manual task that sets a `channel`
* field. Called from main.ts after the ChannelManager is ready.
*/
onHeartbeatResult(handler: (agentName: string, channel: string, output: string) => void): void {
this.heartbeatResultHandler = handler;
onChannelResult(handler: (agentName: string, channel: string, output: string, source: string, target: string) => void): void {
this.channelResultHandler = handler;
}
async refreshFromVault(): Promise<void> {
@ -139,6 +148,22 @@ export class FleetRuntime {
const since = new Date();
since.setDate(since.getDate() - (FleetRuntime.CHART_WINDOW_DAYS - 1));
this.chartRuns = await this.repository.listRunsSince(since);
this.recentUsage = await this.repository.readUsageSince(since);
}
/** Chat/channel usage records over the chart window (for token/cost totals). */
getUsageRecords(): UsageRecord[] {
return this.recentUsage;
}
/** Append a chat/channel turn's usage to the ledger and reflect it live in the
* cached totals. Fire-and-forget from ChatSession via the plugin. */
recordUsage(record: UsageRecord): void {
this.recentUsage.push(record);
void this.repository.appendUsage(record).catch((err) => {
console.warn("Agent Fleet: failed to append usage record", err);
});
this.emitStatusChange();
}
getAgentState(agentName: string): AgentRuntimeState {
@ -523,7 +548,18 @@ export class FleetRuntime {
return { ok: false, message: "A reflection is already in progress." };
}
this.reflectionsInFlight.add(agentName);
const nowIso = new Date().toISOString();
const started = new Date().toISOString();
const nowIso = started;
const reflectionTaskId = `reflection-${Date.now()}`;
// Surface the reflection as a running agent (overview "Active Agents" card +
// sidebar status) with live output, exactly like a task/heartbeat run.
this.runtimeState.set(agentName, {
status: "running",
currentTaskId: reflectionTaskId,
runStarted: started,
});
this.runOutputBuffers.set(agentName, "");
this.emitStatusChange();
try {
// Guarantee the legacy→v2 migration (and its raw-archive seeding) has run
// before we read/consolidate, so a first reflection can't lose pre-v2
@ -541,7 +577,7 @@ export class FleetRuntime {
const task: TaskConfig = {
filePath: "",
taskId: `reflection-${Date.now()}`,
taskId: reflectionTaskId,
agent: agent.name,
type: "immediate",
priority: "low",
@ -556,9 +592,17 @@ export class FleetRuntime {
// Gate the spawn through the reflection semaphore, and suppress memory
// capture for the reflection run itself (it consolidates memory; it must
// not emit new captures or carry the capture instruction).
// not emit new captures or carry the capture instruction). Stream output so
// the overview shows live progress.
const result = await this.withReflectionSlot(() =>
this.executor.execute(agent, task, prompt, undefined, { suppressMemoryCapture: true }),
this.executor.execute(agent, task, prompt, (chunk) => {
const current = this.runOutputBuffers.get(agentName) ?? "";
this.runOutputBuffers.set(agentName, current + chunk);
const listeners = this.runOutputListeners.get(agentName);
if (listeners) {
for (const listener of listeners) listener(chunk);
}
}, { suppressMemoryCapture: true }),
);
const parsed = parseReflectionOutput(result.outputText);
@ -572,20 +616,79 @@ export class FleetRuntime {
await this.generateProposals(agent, merged, nowIso);
}
const message = applied
? "Reflection complete — working memory consolidated."
: "Reflection produced no memory block; working memory left unchanged.";
// Record the reflection as a run so it shows in Recent Activity / run
// history with its full output — the user can confirm it ran and see what
// it consolidated. A clean CLI exit is "success" even if nothing changed;
// `finalResult` carries the consolidation outcome.
const runStatus: RunStatus =
result.exitCode === 0 || result.exitCode === null ? "success" : "failure";
const run: RunLogData = {
runId: result.runId,
agent: agent.name,
task: reflectionTaskId,
status: runStatus,
started,
completed: new Date().toISOString(),
durationSeconds: result.durationSeconds,
tokensUsed: result.tokensUsed,
costUsd: result.costUsd,
model: result.resolvedModel || agent.reflection.model || agent.model,
modelSource: result.modelSource,
concreteModel: result.concreteModel,
exitCode: result.exitCode,
tags: Array.from(new Set([...agent.tags, "reflection"])),
prompt: result.prompt,
output: result.outputText,
toolsUsed: result.toolsUsed.map((tool) => `${tool.tool}${tool.command ? `: ${tool.command}` : ""}`),
finalResult: message,
stderr: result.stderr,
};
const runPath = await this.repository.writeRunLog(run);
await this.refreshRunCaches();
this.emitStatusChange();
return applied
? { ok: true, message: "Reflection complete — working memory consolidated." }
: {
ok: false,
message: "Reflection produced no memory block; working memory left unchanged.",
};
this.runtimeState.set(agentName, {
status: runStatus === "success" ? "idle" : "error",
currentRunId: result.runId,
lastRun: { ...run, filePath: runPath },
});
return { ok: applied, message };
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.warn(`Agent Fleet: reflection failed for "${agentName}":`, error);
// Record the failure as a run too, so it's visible in activity.
const run: RunLogData = {
runId: randomUUID(),
agent: agentName,
task: reflectionTaskId,
status: "failure",
started,
completed: new Date().toISOString(),
durationSeconds: Math.round((Date.now() - new Date(started).getTime()) / 1000),
model: agent.reflection.model || agent.model,
exitCode: 1,
tags: Array.from(new Set([...agent.tags, "reflection"])),
prompt: "",
output: `Reflection failed: ${msg}`,
toolsUsed: [],
};
let lastRun: RunLogData = run;
try {
const runPath = await this.repository.writeRunLog(run);
await this.refreshRunCaches();
lastRun = { ...run, filePath: runPath };
} catch (writeErr) {
console.warn(`Agent Fleet: failed to write reflection run log for "${agentName}"`, writeErr);
}
this.runtimeState.set(agentName, { status: "error", lastRun });
return { ok: false, message: `Reflection failed: ${msg}` };
} finally {
this.reflectionsInFlight.delete(agentName);
this.runOutputBuffers.delete(agentName);
this.runOutputListeners.delete(agentName);
this.emitStatusChange();
}
}
@ -693,13 +796,29 @@ export class FleetRuntime {
}
}
// Post heartbeat results to Slack channel if configured
// Post run results to a channel if configured. Heartbeat runs use the
// agent's heartbeatChannel; regular scheduled/manual tasks use their own
// `channel` field. This lets any task — not just the heartbeat — deliver
// its output to Slack/Discord/Telegram.
const isHeartbeatRun = task.tags.includes("heartbeat");
if (isHeartbeatRun && !wasAborted && agent.heartbeatChannel && run.output.trim()) {
const deliveryChannel = isHeartbeatRun ? agent.heartbeatChannel : (task.channel ?? "");
// Both heartbeats and tasks may post to a specific channel id via their
// target field (HEARTBEAT.md `channel_target` / task `channel_target`);
// an empty target falls back to broadcast (DM to the first allowed user).
const deliveryTarget = isHeartbeatRun
? (agent.heartbeatChannelTarget ?? "")
: (task.channelTarget ?? "");
if (deliveryChannel && !wasAborted && run.output.trim()) {
try {
this.heartbeatResultHandler?.(agent.name, agent.heartbeatChannel, run.output);
this.channelResultHandler?.(
agent.name,
deliveryChannel,
run.output,
isHeartbeatRun ? "heartbeat" : task.taskId,
deliveryTarget,
);
} catch (err) {
console.warn(`Agent Fleet: heartbeat channel delivery failed for ${agent.name}`, err);
console.warn(`Agent Fleet: channel delivery failed for ${agent.name}`, err);
}
}

View file

@ -288,11 +288,13 @@ export class AgentFleetSettingTab extends PluginSettingTab {
dropdown
.addOption("slack", "Slack")
.addOption("telegram", "Telegram")
.addOption("discord", "Discord")
.setValue("slack")
.onChange((value) => {
state.type = value;
slackFields.setCssStyles({ display: value === "slack" ? "" : "none" });
telegramFields.setCssStyles({ display: value === "telegram" ? "" : "none" });
discordFields.setCssStyles({ display: value === "discord" ? "" : "none" });
}),
);
@ -330,6 +332,19 @@ export class AgentFleetSettingTab extends PluginSettingTab {
});
});
// Discord-specific fields
const discordFields = addForm.createDiv();
discordFields.setCssStyles({ display: "none" });
new Setting(discordFields)
.setName("Bot token")
.setDesc("From the Discord Developer Portal → your application → Bot → Reset Token. Enable the Message Content intent on the same page.")
.addText((text) => {
text.inputEl.type = "password";
text.setPlaceholder("MTA...").onChange((value) => {
state.botToken = value.trim();
});
});
new Setting(addForm).addButton((button) =>
button
.setButtonText("Add credential")
@ -342,6 +357,8 @@ export class AgentFleetSettingTab extends PluginSettingTab {
let entry: ChannelCredentialEntry;
if (state.type === "telegram") {
entry = { type: "telegram", botToken: state.botToken };
} else if (state.type === "discord") {
entry = { type: "discord", botToken: state.botToken };
} else {
if (!state.appToken) {
new Notice("Slack requires both bot token and app-level token.");

View file

@ -44,7 +44,7 @@ export interface FleetSettings {
mcpImported?: boolean;
}
export type ChannelType = "slack" | "telegram";
export type ChannelType = "slack" | "telegram" | "discord";
export type ChannelStatus =
| "connected"
@ -57,7 +57,8 @@ export type ChannelStatus =
export type ChannelCredentialEntry =
| { type: "slack"; botToken: string; appToken: string }
| { type: "telegram"; botToken: string };
| { type: "telegram"; botToken: string }
| { type: "discord"; botToken: string };
export interface ChannelConfig {
filePath: string;
@ -82,6 +83,29 @@ export interface ValidationIssue {
message: string;
}
/**
* One token-consuming event recorded to the usage ledger
* (`_fleet/usage/YYYY-MM-DD.jsonl`). Chat and channel turns append these so their
* token/cost are counted in the dashboard totals (runs/heartbeats/reflections
* already carry tokens+cost in their run logs). `costUsd` is the CLI-reported
* dollar cost when available; when absent it's estimated from tokens at read time.
*/
export interface UsageRecord {
/** ISO-8601 timestamp of the turn. */
ts: string;
agent: string;
/** Origin of the usage — only chat/channel are ledgered; runs live in run logs. */
source: "chat" | "channel";
model: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheCreateTokens: number;
totalTokens: number;
/** CLI-reported cost for this turn; undefined → estimate from tokens. */
costUsd?: number;
}
export interface SkillConfig {
filePath: string;
name: string;
@ -178,6 +202,10 @@ export interface AgentConfig {
heartbeatNotify: boolean;
/** Channel name to post heartbeat results to (e.g. "my-slack"). Empty = no channel post. */
heartbeatChannel: string;
/** Specific channel/conversation id to post heartbeat results to within
* heartbeatChannel (e.g. a Discord channel id). Empty = broadcast as a DM to
* the channel's first allowed user. Mirrors a task's channelTarget. */
heartbeatChannelTarget: string;
/** Wiki Keeper scope config. Present only on agents that manage a wiki
* scope. See WIKI_KEEPER_DESIGN.md. */
wikiKeeper?: WikiKeeperConfig;
@ -206,6 +234,16 @@ export interface TaskConfig {
effort?: string;
/** Optional per-task model override. Empty/absent = inherit from agent. */
model?: string;
/** Channel name to post this task's output to (e.g. "my-discord"). Empty/absent
* = no channel post (run log only). Mirrors an agent's heartbeatChannel but is
* per-task, so scheduled tasks not just the heartbeat can deliver to a
* channel. */
channel?: string;
/** Optional transport-native destination id within `channel` (Discord/Slack
* channel id, Telegram chat id). When set, the task posts directly to that
* channel/conversation; when empty, delivery falls back to the channel's
* broadcast (DM to the first allowed user). Ignored unless `channel` is set. */
channelTarget?: string;
tags: string[];
body: string;
}

View file

@ -35,6 +35,7 @@ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
heartbeatBody: "",
heartbeatNotify: false,
heartbeatChannel: "",
heartbeatChannelTarget: "",
...overrides,
};
}

View file

@ -212,6 +212,18 @@ describe("memoryFormat", () => {
expect(prefs?.entries.filter((e) => /post to #wiki/i.test(e.text))).toHaveLength(1);
});
it("carryForwardPinnedPreferences trusts the model and does not re-add a reworded pin", () => {
// The model UPDATED a pin (14 → 24) and emitted pins, so we trust its
// consolidation rather than re-adding the byte-different stale version.
let prev = emptyWorkingMemory("p", "A");
prev = appendEntries(prev, [{ text: "warm-path priority: 14 shortlists", pinned: true }], "Preferences");
const consolidated = parseSectionsFromBody("## Preferences\n- [pin] warm-path priority: 24 shortlists");
const merged = carryForwardPinnedPreferences(prev, consolidated);
const prefs = merged.find((s) => s.name === "Preferences");
expect(prefs?.entries).toHaveLength(1);
expect(prefs?.entries[0]?.text).toContain("24 shortlists");
});
it("redactRememberForDisplay strips complete blocks and holds partial/unclosed tags", () => {
// Complete block removed.
expect(redactRememberForDisplay("a [REMEMBER] x [/REMEMBER] b")).toBe("a b");

View file

@ -393,19 +393,23 @@ export function carryForwardPinnedPreferences(
prev: WorkingMemory | null,
sections: MemorySection[],
): MemorySection[] {
// Rescue pinned entries from ANY prior section (not just Preferences) — a
// pin could have landed in Recent/Observations — so a reflection that omits
// it can never silently drop a pinned fact.
// Rescue pinned entries from ANY prior section (not just Preferences) — a pin
// could have landed in Recent/Observations.
const pins = (prev?.sections ?? [])
.flatMap((s) => s.entries)
.filter((e) => e.pinned);
if (pins.length === 0) return sections;
const present = new Set(
sections.flatMap((s) => s.entries).map((e) => e.text.trim().toLowerCase()),
);
const missing = pins.filter((e) => !present.has(e.text.trim().toLowerCase()));
if (missing.length === 0) return sections;
// The reflection model is explicitly instructed to preserve AND update pinned
// facts, so a reworded/updated pin ("14 shortlists" → "24 shortlists") is the
// model doing its job — not a drop. An earlier version re-added every prev pin
// that wasn't byte-identical to the new output, which undid the model's
// consolidation and piled up near-duplicate pins on every reflection. Instead,
// trust the consolidated output whenever it contains ANY pinned entry, and only
// rescue the previous pins when the reflection produced NONE at all — the real
// signal that it dropped them rather than updating them.
const newHasPins = sections.some((s) => s.entries.some((e) => e.pinned));
if (newHasPins) return sections;
const out = sections.map((s) => ({ name: s.name, entries: [...s.entries] }));
let prefs = out.find((s) => s.name === "Preferences");
@ -413,7 +417,7 @@ export function carryForwardPinnedPreferences(
prefs = { name: "Preferences", entries: [] };
out.unshift(prefs);
}
prefs.entries.unshift(...missing);
prefs.entries.unshift(...pins);
return out;
}

37
src/utils/pricing.test.ts Normal file
View file

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { estimateCostFromBreakdown, estimateCostFromTotal } from "./pricing";
describe("pricing", () => {
it("prices a Claude breakdown using the model's per-type rates", () => {
// opus: input $15, output $75, cacheRead $1.50 per 1M.
const cost = estimateCostFromBreakdown("claude-opus-4-7", {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 1_000_000,
cacheCreateTokens: 0,
});
expect(cost).toBeCloseTo(15 + 75 + 1.5, 5);
});
it("matches family by substring (sonnet/haiku)", () => {
const sonnet = estimateCostFromBreakdown("sonnet", { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 });
const haiku = estimateCostFromBreakdown("claude-haiku-4-5", { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 });
expect(sonnet).toBeCloseTo(3, 5);
expect(haiku).toBeCloseTo(1, 5);
});
it("falls back to a default rate for unknown models", () => {
const cost = estimateCostFromBreakdown("some-unknown-model", { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 });
expect(cost).toBeCloseTo(3, 5); // default = sonnet-ish input rate
});
it("estimates from a total-token count with a blended rate", () => {
// haiku blended = 0.7*1 + 0.3*5 = 2.2 per 1M.
expect(estimateCostFromTotal("haiku", 1_000_000)).toBeCloseTo(2.2, 5);
});
it("returns zero cost for zero tokens", () => {
expect(estimateCostFromBreakdown("opus", { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 })).toBe(0);
expect(estimateCostFromTotal("opus", 0)).toBe(0);
});
});

58
src/utils/pricing.ts Normal file
View file

@ -0,0 +1,58 @@
// Token → USD cost estimation, used as a fallback when the CLI doesn't report a
// dollar cost (Codex agents, or any turn missing `total_cost_usd`). Claude runs
// carry an exact CLI cost; this table only fills the gaps. Rates are USD per
// MILLION tokens and will drift as vendors change pricing — update as needed.
interface ModelRates {
input: number;
output: number;
cacheWrite: number;
cacheRead: number;
}
/** Per-family rates (USD / 1M tokens). Matched by substring against the model id. */
const RATE_TABLE: Array<{ match: RegExp; rates: ModelRates }> = [
{ match: /opus/i, rates: { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 } },
{ match: /sonnet/i, rates: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } },
{ match: /haiku/i, rates: { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 } },
// OpenAI Codex / GPT slugs — approximate; no cache-tier distinction.
{ match: /gpt-5|codex|o[0-9]/i, rates: { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 } },
];
/** Fallback when the model id matches nothing (use mid-tier Sonnet-ish rates). */
const DEFAULT_RATES: ModelRates = { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 };
function ratesFor(model: string): ModelRates {
for (const { match, rates } of RATE_TABLE) {
if (match.test(model)) return rates;
}
return DEFAULT_RATES;
}
export interface TokenBreakdown {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheCreateTokens: number;
}
/** Estimate USD cost from a full per-type token breakdown (most accurate). */
export function estimateCostFromBreakdown(model: string, b: TokenBreakdown): number {
const r = ratesFor(model);
return (
(b.inputTokens * r.input +
b.outputTokens * r.output +
b.cacheCreateTokens * r.cacheWrite +
b.cacheReadTokens * r.cacheRead) /
1_000_000
);
}
/** Estimate USD cost when only a single total-token count is known (e.g. a run
* log, which doesn't store the input/output split). Assumes a ~70/30 input/output
* blend a rough best-effort, used only when the CLI reported no cost. */
export function estimateCostFromTotal(model: string, totalTokens: number): number {
const r = ratesFor(model);
const blended = 0.7 * r.input + 0.3 * r.output;
return (totalTokens * blended) / 1_000_000;
}

View file

@ -362,7 +362,7 @@ export class AgentChatView extends ItemView {
// resolved (in switchToAgent), so we don't pre-check here.
this.selectedConversationId = "";
this.textarea.disabled = false;
this.textarea.placeholder = "Message the agent\u2026 (Ctrl+Enter to send)";
this.textarea.placeholder = "Message the agent\u2026 (Shift+Enter for newline)";
void this.switchToAgent(val);
};
@ -393,7 +393,7 @@ export class AgentChatView extends ItemView {
this.textarea = inputRow.createEl("textarea", {
cls: "af-chat-input",
attr: { placeholder: "Message the agent\u2026 (Ctrl+Enter to send)", rows: "1" },
attr: { placeholder: "Message the agent\u2026 (Shift+Enter for newline)", rows: "1" },
});
this.sendBtn = inputRow.createEl("button", { cls: "af-chat-send-btn" });
createIcon(this.sendBtn, "arrow-up", "af-btn-icon");
@ -415,7 +415,10 @@ export class AgentChatView extends ItemView {
this.sendBtn.onclick = () => void this.handleSend();
this.textarea.onkeydown = (e: KeyboardEvent) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
// Enter sends; Shift+Enter inserts a newline (common chat convention).
// Skip while an IME composition is active so Enter confirms the candidate
// rather than sending a half-composed message.
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
void this.handleSend();
}
@ -582,7 +585,7 @@ export class AgentChatView extends ItemView {
}
this.leaf.updateHeader();
this.textarea.placeholder = "Message the agent\u2026 (Ctrl+Enter to send)";
this.textarea.placeholder = "Message the agent\u2026 (Shift+Enter for newline)";
// Load the session if not already displayed. Check for actual chat bubbles
// (not just the empty state divs) to decide whether switchToAgent is needed.
@ -690,6 +693,7 @@ export class AgentChatView extends ItemView {
this.app.vault,
{ inAppConversationId: resolvedId, mcpAuth: this.plugin.mcpAuth },
);
session.setUsageRecorder((r) => this.plugin.runtime.recordUsage(r));
managed = { session };
this.sessions.set(key, managed);
await session.loadPersistedState();
@ -1417,7 +1421,7 @@ export class AgentChatView extends ItemView {
const input = composer.createEl("textarea", {
cls: "af-chat-input af-thread-input",
attr: { placeholder: "Message in thread\u2026 (Ctrl+Enter to send)", rows: "1" },
attr: { placeholder: "Message in thread\u2026 (Shift+Enter for newline)", rows: "1" },
});
// Paste image support
@ -1532,7 +1536,8 @@ export class AgentChatView extends ItemView {
sendBtn.onclick = () => void submit();
input.onkeydown = (e: KeyboardEvent) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
// Enter sends; Shift+Enter inserts a newline.
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
void submit();
}

View file

@ -2,7 +2,8 @@ import { ItemView, MarkdownRenderer, Notice, TFile, WorkspaceLeaf, setIcon } fro
import { IconPickerModal } from "../modals/iconPickerModal";
import { VIEW_TYPE_DASHBOARD } from "../constants";
import type AgentFleetPlugin from "../main";
import type { AgentConfig, AgentHealth, ChannelConfig, McpServer, McpTool, RunLogData, SkillConfig, TaskConfig } from "../types";
import type { AgentConfig, AgentHealth, ChannelConfig, McpServer, McpTool, RunLogData, SkillConfig, TaskConfig, UsageRecord } from "../types";
import { estimateCostFromBreakdown, estimateCostFromTotal } from "../utils/pricing";
import { truncate, slugify, parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter } from "../utils/markdown";
import { splitLines } from "../utils/platform";
import { createIcon } from "../utils/icons";
@ -499,8 +500,9 @@ export class FleetDashboardView extends ItemView {
this.renderStatCard(grid, "Runs Today", String(todayRuns.length), "", "activity",
`${passed} passed \u00B7 ${failed} failed \u00B7 ${status.running} running`);
const totalTokens = todayRuns.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
const totalCost = todayRuns.reduce((sum, r) => sum + (r.costUsd ?? 0), 0);
// Comprehensive: run logs (tasks/heartbeats/reflections) + chat/channel usage.
const todayUsage = this.plugin.runtime.getUsageRecords().filter((u) => this.runToLocalDate(u.ts) === todayStr);
const { tokens: totalTokens, cost: totalCost } = this.combinedTotals(todayRuns, todayUsage);
const costSuffix = totalCost > 0 ? ` \u00B7 $${totalCost.toFixed(2)}` : "";
this.renderStatCard(grid, "Tokens Used", formatTokenCount(totalTokens), "", "zap", `today${costSuffix}`);
@ -567,6 +569,35 @@ export class FleetDashboardView extends ItemView {
return this.toLocalDateStr(new Date(started));
}
/** Cost of a run log: the CLI-reported dollars, or an estimate from total
* tokens when the CLI reported none (e.g. Codex). */
private runCost(r: RunLogData): number {
return r.costUsd ?? estimateCostFromTotal(r.model, r.tokensUsed ?? 0);
}
/** Cost of a chat/channel usage record: CLI-reported, or estimated from the
* per-type token breakdown when absent. */
private usageCost(u: UsageRecord): number {
return u.costUsd ?? estimateCostFromBreakdown(u.model, {
inputTokens: u.inputTokens,
outputTokens: u.outputTokens,
cacheReadTokens: u.cacheReadTokens,
cacheCreateTokens: u.cacheCreateTokens,
});
}
/** Comprehensive token + cost totals across run logs AND chat/channel usage
* records, with the pricing fallback applied per source. */
private combinedTotals(runs: RunLogData[], usage: UsageRecord[]): { tokens: number; cost: number } {
const tokens =
runs.reduce((s, r) => s + (r.tokensUsed ?? 0), 0) +
usage.reduce((s, u) => s + u.totalTokens, 0);
const cost =
runs.reduce((s, r) => s + this.runCost(r), 0) +
usage.reduce((s, u) => s + this.usageCost(u), 0);
return { tokens, cost };
}
private buildChartData(runs: RunLogData[], days: number): BarChartDay[] {
const result: BarChartDay[] = [];
const now = new Date();
@ -616,7 +647,11 @@ export class FleetDashboardView extends ItemView {
: undefined;
const taskLabel = runningTask
? `${runningTask.taskId}`
: state.status === "running" ? " → Heartbeat" : "";
: state.currentTaskId?.startsWith("reflection-")
? " → Reflection"
: state.currentTaskId?.startsWith("heartbeat-")
? " → Heartbeat"
: state.status === "running" ? " → Heartbeat" : "";
const header = card.createDiv({ cls: "af-streaming-card-header" });
header.createSpan({ cls: "af-dot pulse", attr: { style: "background: var(--af-yellow)" } });
@ -626,14 +661,20 @@ export class FleetDashboardView extends ItemView {
}
const output = card.createDiv({ cls: "af-streaming-output" });
const buffer = this.plugin.runtime.getRunOutputBuffer(agentName);
const lines = splitLines(buffer).slice(-4);
output.setText(lines.join("\n"));
// Reflection reads a lot of context before emitting its memory block, so the
// buffer is empty for most of the run — show a placeholder instead of a blank
// card so it doesn't look stalled.
const placeholder = state.currentTaskId?.startsWith("reflection-")
? "Consolidating memory…"
: "Working…";
const renderBuffer = (buf: string) => {
const lines = splitLines(buf).filter((l) => l.trim().length > 0).slice(-4);
output.setText(lines.length > 0 ? lines.join("\n") : placeholder);
};
renderBuffer(this.plugin.runtime.getRunOutputBuffer(agentName));
const unsub = this.plugin.runtime.onRunOutput(agentName, () => {
const updated = this.plugin.runtime.getRunOutputBuffer(agentName);
const updatedLines = splitLines(updated).slice(-4);
output.setText(updatedLines.join("\n"));
renderBuffer(this.plugin.runtime.getRunOutputBuffer(agentName));
output.scrollTop = output.scrollHeight;
});
this.streamingUnsubscribes.push(unsub);
@ -1058,8 +1099,9 @@ export class FleetDashboardView extends ItemView {
const successRuns = runs.filter((r) => r.status === "success").length;
const successRate = totalRuns > 0 ? Math.round((successRuns / totalRuns) * 100) : 0;
const avgTime = totalRuns > 0 ? Math.round(runs.reduce((s, r) => s + r.durationSeconds, 0) / totalRuns) : 0;
const totalTokens = runs.reduce((s, r) => s + (r.tokensUsed ?? 0), 0);
const totalCostAgent = runs.reduce((s, r) => s + (r.costUsd ?? 0), 0);
// Comprehensive: this agent's run logs + its chat/channel usage over the window.
const agentUsage = this.plugin.runtime.getUsageRecords().filter((u) => u.agent === agent.name);
const { tokens: totalTokens, cost: totalCostAgent } = this.combinedTotals(runs, agentUsage);
const costSuffixAgent = totalCostAgent > 0 ? ` \u00B7 $${totalCostAgent.toFixed(2)}` : "";
this.renderStatCard(statsRow, "Total Runs", String(totalRuns), "", "activity", "all time");
@ -2121,6 +2163,7 @@ export class FleetDashboardView extends ItemView {
const typeSelect = typeRow.createEl("select", { cls: "af-form-select" });
typeSelect.createEl("option", { text: "slack", attr: { value: "slack" } });
typeSelect.createEl("option", { text: "telegram", attr: { value: "telegram" } });
typeSelect.createEl("option", { text: "discord", attr: { value: "discord" } });
typeSelect.addEventListener("change", () => { state.type = typeSelect.value; });
// Credential dropdown
@ -2205,7 +2248,7 @@ export class FleetDashboardView extends ItemView {
const usersLabel = accessSection.createDiv({ cls: "af-form-label" });
usersLabel.setText("Allowed users");
this.addTooltip(usersLabel, "Slack user IDs (U...), one per line. Only listed users can reach the bot.");
this.addTooltip(usersLabel, "User IDs, one per line — Slack (U...), Telegram (numeric), or Discord (numeric snowflakes). Only listed users can reach the bot.");
const usersTextarea = accessSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "U0AQW6P37N1\nU0BXYZ12345", rows: "4" },
@ -2389,7 +2432,7 @@ export class FleetDashboardView extends ItemView {
const typeRow = detailsSection.createDiv({ cls: "af-form-row" });
typeRow.createDiv({ cls: "af-form-label", text: "Type" });
const typeSelect = typeRow.createEl("select", { cls: "af-form-select" });
for (const t of ["slack", "telegram"] as const) {
for (const t of ["slack", "telegram", "discord"] as const) {
const opt = typeSelect.createEl("option", { text: t, attr: { value: t } });
if (t === channel.type) opt.selected = true;
}
@ -3573,6 +3616,7 @@ export class FleetDashboardView extends ItemView {
heartbeatBody: "",
heartbeatNotify: true,
heartbeatChannel: "",
heartbeatChannelTarget: "",
autoCompactThreshold: 85,
wikiReferences: [] as string[],
};
@ -3838,7 +3882,17 @@ export class FleetDashboardView extends ItemView {
for (const ch of createSnapshot.channels) {
hbChannelSelect.createEl("option", { text: ch.name, attr: { value: ch.name } });
}
hbChannelSelect.addEventListener("change", () => { state.heartbeatChannel = hbChannelSelect.value; });
hbChannelSelect.addEventListener("change", () => { state.heartbeatChannel = hbChannelSelect.value; syncHbTarget(); });
const hbTargetRow = hbBody.createDiv({ cls: "af-form-row" });
const hbTargetLabel = hbTargetRow.createDiv({ cls: "af-form-label" });
hbTargetLabel.setText("Target ID");
this.addTooltip(hbTargetLabel, "Specific channel id to post to (Discord/Slack channel id, Telegram chat id). Empty = DM the channels first allowed user.");
const hbTargetInput = hbTargetRow.createEl("input", { cls: "af-form-input", attr: { type: "text", placeholder: "Channel/chat id — empty = DM" } });
hbTargetInput.value = state.heartbeatChannelTarget;
hbTargetInput.addEventListener("input", () => { state.heartbeatChannelTarget = hbTargetInput.value.trim(); });
const syncHbTarget = () => { hbTargetRow.setCssStyles({ display: state.heartbeatChannel ? "" : "none" }); };
syncHbTarget();
const hbInstructionLabel = hbBody.createDiv({ cls: "af-form-label" });
hbInstructionLabel.setCssStyles({ width: "auto" });
@ -4010,6 +4064,7 @@ export class FleetDashboardView extends ItemView {
schedule: state.heartbeatSchedule.trim(),
notify: state.heartbeatNotify,
channel: state.heartbeatChannel,
channelTarget: state.heartbeatChannel ? state.heartbeatChannelTarget : "",
body: state.heartbeatBody.trim(),
});
}
@ -4238,6 +4293,7 @@ export class FleetDashboardView extends ItemView {
heartbeatBody: agent.heartbeatBody,
heartbeatNotify: agent.heartbeatNotify,
heartbeatChannel: agent.heartbeatChannel,
heartbeatChannelTarget: agent.heartbeatChannelTarget,
autoCompactThreshold: agent.autoCompactThreshold ?? 85,
wikiReferences: (agent.wikiReferences ?? []).map((r) => r.agent),
};
@ -4495,7 +4551,17 @@ export class FleetDashboardView extends ItemView {
const opt = hbChannelSelect.createEl("option", { text: ch.name, attr: { value: ch.name } });
if (ch.name === state.heartbeatChannel) opt.selected = true;
}
hbChannelSelect.addEventListener("change", () => { state.heartbeatChannel = hbChannelSelect.value; });
hbChannelSelect.addEventListener("change", () => { state.heartbeatChannel = hbChannelSelect.value; syncHbTarget(); });
const hbTargetRow = hbBody.createDiv({ cls: "af-form-row" });
const hbTargetLabel = hbTargetRow.createDiv({ cls: "af-form-label" });
hbTargetLabel.setText("Target ID");
this.addTooltip(hbTargetLabel, "Specific channel id to post to (Discord/Slack channel id, Telegram chat id). Empty = DM the channels first allowed user.");
const hbTargetInput = hbTargetRow.createEl("input", { cls: "af-form-input", attr: { type: "text", placeholder: "Channel/chat id — empty = DM" } });
hbTargetInput.value = state.heartbeatChannelTarget;
hbTargetInput.addEventListener("input", () => { state.heartbeatChannelTarget = hbTargetInput.value.trim(); });
const syncHbTarget = () => { hbTargetRow.setCssStyles({ display: state.heartbeatChannel ? "" : "none" }); };
syncHbTarget();
const hbInstructionLabel = hbBody.createDiv({ cls: "af-form-label" });
hbInstructionLabel.setCssStyles({ width: "auto" });
@ -4703,6 +4769,7 @@ export class FleetDashboardView extends ItemView {
schedule: state.heartbeatSchedule.trim(),
notify: state.heartbeatNotify,
channel: state.heartbeatChannel,
channelTarget: state.heartbeatChannel ? state.heartbeatChannelTarget : "",
body: state.heartbeatBody.trim(),
});
}
@ -4717,6 +4784,50 @@ export class FleetDashboardView extends ItemView {
};
}
/**
* Render the optional "post results to a channel" controls shared by the
* create and edit task forms: a channel picker plus a transport-native target
* id (Discord/Slack channel id, Telegram chat id). Empty target = broadcast/DM.
*/
private renderTaskChannelDelivery(
section: HTMLElement,
channels: ChannelConfig[],
state: { channel: string; channelTarget: string },
): void {
const channelRow = section.createDiv({ cls: "af-form-row" });
const channelLabel = channelRow.createDiv({ cls: "af-form-label", text: "Channel" });
const channelSelect = channelRow.createEl("select", { cls: "af-form-select" });
channelSelect.createEl("option", { text: "— none (run log only) —", attr: { value: "" } });
for (const ch of channels) {
const opt = channelSelect.createEl("option", { text: ch.name, attr: { value: ch.name } });
if (ch.name === state.channel) opt.selected = true;
}
this.addTooltip(
channelLabel,
"Post this tasks full output to a channel when it finishes. Leave as none to only write the run log (the default batched behavior).",
);
const targetRow = section.createDiv({ cls: "af-form-row" });
const targetLabel = targetRow.createDiv({ cls: "af-form-label", text: "Target ID" });
const targetInput = targetRow.createEl("input", {
cls: "af-form-input",
attr: { type: "text", placeholder: "Discord/Slack channel ID or Telegram chat ID — empty = DM you" },
});
targetInput.value = state.channelTarget;
targetInput.addEventListener("input", () => { state.channelTarget = targetInput.value.trim(); });
this.addTooltip(
targetLabel,
"Where in the channel to post. For Discord, enable Developer Mode and right-click the channel → Copy Channel ID. Leave empty to DM the first allowed user instead.",
);
const syncVisibility = () => { targetRow.setCssStyles({ display: state.channel ? "" : "none" }); };
syncVisibility();
channelSelect.addEventListener("change", () => {
state.channel = channelSelect.value;
syncVisibility();
});
}
// ═══════════════════════════════════════════════════════
// Create Task Page
// ═══════════════════════════════════════════════════════
@ -4754,6 +4865,8 @@ export class FleetDashboardView extends ItemView {
catchUp: true,
effort: "",
model: "",
channel: "",
channelTarget: "",
};
const form = page.createDiv({ cls: "af-create-form" });
@ -4926,6 +5039,8 @@ export class FleetDashboardView extends ItemView {
if (val === state.effort) opt.selected = true;
}
taskEffortSelect.addEventListener("change", () => { state.effort = taskEffortSelect.value; });
// Channel delivery (optional) — post this task's output to a channel on completion.
this.renderTaskChannelDelivery(execSection, snapshot.channels, state);
this.addTooltip(
taskEffortLabel,
"Overrides the agent\u2019s effort level for this task. Higher effort = more thinking tokens spent.",
@ -4965,6 +5080,8 @@ export class FleetDashboardView extends ItemView {
catch_up: state.catchUp,
effort: state.effort || undefined,
model: state.model || undefined,
channel: state.channel || undefined,
channel_target: state.channel && state.channelTarget ? state.channelTarget : undefined,
tags: parseTags(state.tags),
};
@ -5055,6 +5172,8 @@ export class FleetDashboardView extends ItemView {
catchUp: task.catchUp,
effort: task.effort ?? "",
model: task.model ?? "",
channel: task.channel ?? "",
channelTarget: task.channelTarget ?? "",
tags: task.tags.join(", "),
body: task.body,
};
@ -5243,6 +5362,8 @@ export class FleetDashboardView extends ItemView {
};
renderTaskModelPicker(state.agent);
agentSelect.addEventListener("change", () => renderTaskModelPicker(agentSelect.value));
// Channel delivery (optional) — post this task's output to a channel on completion.
this.renderTaskChannelDelivery(execSection, snapshot.channels, state);
this.addTooltip(
modelLabelEdit,
"Override the agent\u2019s model for this task only. Useful for routing simple runs to haiku while the agent stays on opus for heavier work.",
@ -5292,6 +5413,8 @@ export class FleetDashboardView extends ItemView {
catch_up: state.catchUp,
effort: state.effort || undefined,
model: state.model || "",
channel: state.channel || "",
channelTarget: state.channel && state.channelTarget ? state.channelTarget : "",
tags: parseTags(state.tags),
body: state.body.trim(),
});

View file

@ -28,5 +28,6 @@
"0.13.3": "1.11.4",
"0.13.4": "1.11.4",
"0.13.5": "1.11.4",
"0.13.6": "1.11.4"
"0.13.6": "1.11.4",
"0.14.0": "1.11.4"
}