release: 0.16.0 — Windows stdin fix, robustness hardening, UX polish, internal restructuring

See CHANGELOG.md for details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Denis Berekchiyan 2026-07-01 22:58:22 -07:00
parent 0ede55ffb6
commit 0b5ea13bd0
72 changed files with 11634 additions and 7564 deletions

View file

@ -1,5 +1,46 @@
# Changelog
## 0.16.0 — 2026-07-01
A hardening and quality release: a Windows fix, ~20 robustness fixes across process lifecycle and persistence, dashboard performance work, a batch of UX improvements, and a large internal restructuring — with 79 new tests (331 total).
**Windows: ENAMETOOLONG fixed** (thanks @zhaoming-mike!)
- The Claude adapter now passes the prompt via stdin instead of argv, so one-shot runs (heartbeats, scheduled tasks, Wiki Keeper) no longer fail on Windows' 32,767-character command-line limit. Also makes long prompts more robust on macOS/Linux.
**Robustness**
- Codex follow-up messages queued during a turn are no longer silently lost if the next turn fails to start.
- Stdout buffers are capped (10MB) in both chat and one-shot runs — a runaway process can no longer exhaust memory.
- The chat "working" spinner can no longer get stuck when a CLI process dies between turns.
- Heartbeat overlap guard now holds until the run actually finishes (it previously released on enqueue, so overlapping heartbeat runs were possible).
- One hung vault write can no longer wedge all subsequent memory captures (10s per-capture timeout).
- Corrupted `permissions.json` or agent sidecar files now log an error and flag a validation issue in the dashboard instead of silently running the agent with empty permissions.
- CLI output that fails to parse is now logged with context instead of showing "(no output)" with no clue — makes CLI version drift debuggable.
- Reference validation re-runs after create/update/delete, so deleting a skill immediately flags agents that still reference it.
- Working-memory files with a newer schema version are left untouched instead of being clobbered.
- Legacy memory migration and Slack's send queue no longer race under concurrency; channel shutdown drains in-flight turns.
- MCP OAuth callback server reliably frees its port (previously a lingering keep-alive connection could cause "address already in use" on the next login).
- Stale MCP temp files and Codex overlays are swept on plugin load; temp names are now collision-proof UUIDs.
- Reflection run failures now show a Notice (all other run types already did).
**UX**
- Deleting a task, skill, or channel now asks for confirmation (parity with agent deletion).
- Search: shows "No results" instead of nothing, a "Showing 10 of N" footer when truncated, and is debounced.
- All create/edit forms disable their submit button while saving (no more accidental double-submits).
- Empty states have Create buttons; a dismissible welcome card appears on a pristine fleet.
- Agent cards show heartbeat state, schedule, next run time, and a quick pause/resume toggle.
- Task scheduling is a single Immediate / Recurring / One-time selector (saved file format unchanged).
- Chat error bubbles include recovery hints for common failures (context overflow, rate limit, auth, timeout, missing CLI).
- Switching an agent between Claude and Codex explains how permission modes map.
- Run success Notices report how many memory facts were captured.
**Performance**
- Agents page no longer refetches runs per card (was O(n²) with fleet size).
- Live run output batches dashboard updates (~100ms) instead of re-rendering per stdout chunk.
- Run logs are parse-cached by mtime; fleet status is cached; name lookups are indexed; running-card timers share one ticker.
**Internal**
- `FleetRepository` decomposed into focused store modules (`src/repository/`); the dashboard view split into form and page modules (`src/views/forms/`, `src/views/pages/`). Shared prompt assembly guarantees chat and one-shot runs build identical context. Channel adapters share text-splitting and backoff helpers. 221 lines of dead CSS removed.
## 0.15.0 — 2026-06-21
Accurate cost tracking, live follow-ups in channels, and Discord docs.

View file

@ -240,6 +240,7 @@ Discord uses the Gateway (outbound WebSocket) + REST. Features: `@agent-name` ro
- Agents with `approval_required` set cannot be bound to a channel
- Multi-agent routing: type `@agent-name: message` to switch agents, or use `/agents` for interactive picker
- Obsidian must be running for channels to work — when closed, bots go offline
- Live follow-ups (since 0.15.0): a message sent while the agent is still working is folded into the running turn (Claude: live stdin; Codex: queued follow-up turn) instead of waiting for a fresh turn — matching the in-app chat. Each injected follow-up gets its own reply; `[REMEMBER]` blocks are stripped from channel replies.
## Creating a Task

319
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,9 +1,9 @@
{
"id": "agent-fleet",
"name": "Agent Fleet",
"version": "0.15.0",
"version": "0.16.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.",
"description": "File-backed AI agents with task scheduling, channels, memory, and MCP \u2014 running on Claude Code or OpenAI Codex, all as plain markdown.",
"author": "Denis Berekchiyan",
"authorUrl": "https://github.com/denberek",
"isDesktopOnly": true

4
package-lock.json generated
View file

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

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-agent-fleet",
"version": "0.15.0",
"version": "0.16.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",

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentConfig, FleetSettings } from "../types";
import type { ExecBuildOptions } from "./types";
import { claudeCodeAdapter, isCodexShapedModel } from "./claudeCodeAdapter";
@ -151,6 +151,49 @@ describe("claudeCodeAdapter.parseExecOutput", () => {
const parsed = claudeCodeAdapter.parseExecOutput("", "spawn failed", true);
expect(parsed.outputText).toBe("spawn failed");
});
describe("parse-failure logging", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("does not warn about non-JSON noise between valid stream events", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const stdout = [
"Some CLI banner line",
JSON.stringify({ type: "result", result: "Done.", usage: { input_tokens: 1, output_tokens: 1 } }),
].join("\n");
const parsed = claudeCodeAdapter.parseExecOutput(stdout, "", true);
expect(parsed.outputText).toBe("Done.");
expect(warn).not.toHaveBeenCalled();
});
it("warns when a streaming run produced no parseable JSON event at all", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const parsed = claudeCodeAdapter.parseExecOutput("total garbage\nno json here", "", true);
expect(parsed.outputText).toBe("(no output)");
expect(warn).toHaveBeenCalledTimes(1);
const message = String(warn.mock.calls[0]?.[0]);
expect(message).toContain("no parseable JSON event");
expect(message).toContain("total garbage");
});
it("warns when non-streaming whole-stdout JSON fails to parse", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const parsed = claudeCodeAdapter.parseExecOutput('{"result": "truncat', "", false);
expect(parsed.outputText).toBe("(no output)");
expect(warn).toHaveBeenCalledTimes(1);
const message = String(warn.mock.calls[0]?.[0]);
expect(message).toContain("failed to parse");
expect(message).toContain('{"result": "truncat');
});
it("does not warn on empty stdout", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
claudeCodeAdapter.parseExecOutput("", "spawn failed", true);
expect(warn).not.toHaveBeenCalled();
});
});
});
describe("isCodexShapedModel", () => {

View file

@ -1,6 +1,7 @@
import type { AgentConfig, ExecutionToolUse, FleetSettings } from "../types";
import { splitLines } from "../utils/platform";
import { restoreClaudeSettingsFile, writeClaudeSettingsFile } from "../utils/claudeSettings";
import { parseJsonLoud, tryParseJson, warnJsonParseFailure } from "./parseHelpers";
import type {
CliAdapter,
ExecBuildOptions,
@ -263,25 +264,25 @@ export const claudeCodeAdapter: CliAdapter = {
let rawJson: unknown;
if (streaming) {
// stream-json: find the last parseable event (normally the "result" line)
// stream-json: find the last parseable event (normally the "result"
// line). Individual non-JSON lines are expected (banners, verbose
// noise) — but a stream with NO parseable event at all is a real
// failure worth surfacing.
const lines = splitLines(trimmed);
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]?.trim();
if (!line) continue;
try {
const parsed: unknown = JSON.parse(line);
if (parsed && typeof parsed === "object") {
rawJson = parsed;
break;
}
} catch { /* skip non-json lines */ }
const parsed = tryParseJson(line);
if (parsed && typeof parsed === "object") {
rawJson = parsed;
break;
}
}
if (rawJson === undefined && trimmed) {
warnJsonParseFailure("Claude Code stream-json output contained no parseable JSON event", trimmed);
}
} else if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
rawJson = JSON.parse(trimmed);
} catch {
rawJson = undefined;
}
rawJson = parseJsonLoud("Claude Code JSON output failed to parse", trimmed);
}
let outputText = extractText(rawJson) ?? "";
@ -291,17 +292,16 @@ export const claudeCodeAdapter: CliAdapter = {
for (const line of splitLines(trimmed)) {
const l = line.trim();
if (!l) continue;
try {
const ev = JSON.parse(l) as ClaudeStreamEvent;
// Only extract assistant text content, skip system/result/user events
if (ev.type === "assistant" && ev.message?.content) {
for (const block of ev.message.content) {
if (block.type === "text" && block.text) textParts.push(block.text);
}
} else if (ev.type === "result" && typeof ev.result === "string") {
textParts.push(ev.result);
const ev = tryParseJson(l) as ClaudeStreamEvent | undefined;
if (!ev || typeof ev !== "object") continue; // non-JSON noise is expected
// Only extract assistant text content, skip system/result/user events
if (ev.type === "assistant" && ev.message?.content) {
for (const block of ev.message.content) {
if (block.type === "text" && block.text) textParts.push(block.text);
}
} catch { /* not JSON, skip */ }
} else if (ev.type === "result" && typeof ev.result === "string") {
textParts.push(ev.result);
}
}
outputText = textParts.join("\n").trim();
}
@ -316,18 +316,17 @@ export const claudeCodeAdapter: CliAdapter = {
for (const line of splitLines(trimmed)) {
const l = line.trim();
if (!l) continue;
try {
const ev: unknown = JSON.parse(l);
if (!concreteModel) {
const m = extractConcreteModel(ev);
if (m) concreteModel = m;
}
if (!finalResult) {
const r = extractFinalResult(ev);
if (r) finalResult = r;
}
if (concreteModel && finalResult) break;
} catch { /* not JSON, skip */ }
const ev = tryParseJson(l); // non-JSON noise is expected, skip silently
if (ev === undefined) continue;
if (!concreteModel) {
const m = extractConcreteModel(ev);
if (m) concreteModel = m;
}
if (!finalResult) {
const r = extractFinalResult(ev);
if (r) finalResult = r;
}
if (concreteModel && finalResult) break;
}
}
@ -345,12 +344,10 @@ export const claudeCodeAdapter: CliAdapter = {
extractStreamChunk(line: string): string | null {
const trimmed = line.trim();
if (!trimmed) return null;
let event: Record<string, unknown>;
try {
event = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
return null;
}
// Live per-line parsing — non-JSON lines are expected noise, skip silently.
const parsed = tryParseJson(trimmed);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const event = parsed as Record<string, unknown>;
const type = event.type as string | undefined;
// Assistant message: {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentConfig, FleetSettings } from "../types";
import type { ExecBuildOptions } from "./types";
import {
@ -230,6 +230,39 @@ describe("codexAdapter.parseExecOutput", () => {
const parsed = codexAdapter.parseExecOutput("", "codex: command failed", true);
expect(parsed.outputText).toBe("codex: command failed");
});
describe("parse-failure logging", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("does not warn about non-JSON noise mixed with valid JSONL events", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const stdout = [
"codex banner text",
JSON.stringify({ type: "item.completed", item: { id: "i1", type: "agent_message", text: "hi" } }),
].join("\n");
const parsed = codexAdapter.parseExecOutput(stdout, "", true);
expect(parsed.outputText).toBe("hi");
expect(warn).not.toHaveBeenCalled();
});
it("warns when non-empty stdout contained no parseable JSONL event", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const parsed = codexAdapter.parseExecOutput("plain text output\nstill not json", "", true);
expect(parsed.outputText).toBe("(no output)");
expect(warn).toHaveBeenCalledTimes(1);
const message = String(warn.mock.calls[0]?.[0]);
expect(message).toContain("no parseable JSONL event");
expect(message).toContain("plain text output");
});
it("does not warn on empty stdout", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
codexAdapter.parseExecOutput("", "codex: command failed", true);
expect(warn).not.toHaveBeenCalled();
});
});
});
describe("codexAdapter.extractStreamChunk", () => {

View file

@ -1,6 +1,7 @@
import type { AgentConfig, ExecutionToolUse, FleetSettings } from "../types";
import { splitLines } from "../utils/platform";
import { setupCodexPermissions } from "./codexPermissions";
import { tryParseJson, warnJsonParseFailure } from "./parseHelpers";
import type {
CliAdapter,
ExecBuildOptions,
@ -125,17 +126,16 @@ export function buildCodexExecArgs(opts: ExecBuildOptions): { args: string[]; st
type JsonRecord = Record<string, unknown>;
/** Parse one JSONL line into an object event, or null. Non-JSON lines are
* expected noise (CLI banners, progress text) and are skipped silently
* parseExecOutput warns separately when NO line parsed at all. */
function parseJsonLine(line: string): JsonRecord | null {
const trimmed = line.trim();
if (!trimmed) return null;
try {
const parsed: unknown = JSON.parse(trimmed);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as JsonRecord)
: null;
} catch {
return null;
}
const parsed = tryParseJson(trimmed);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as JsonRecord)
: null;
}
function itemOf(event: JsonRecord): JsonRecord | null {
@ -311,10 +311,12 @@ export const codexAdapter: CliAdapter = {
let totalTokens = 0;
let sessionId: string | undefined;
let lastTurnCompleted: JsonRecord | undefined;
let parsedAnyEvent = false;
for (const line of splitLines(stdout)) {
const event = parseJsonLine(line);
if (!event) continue;
parsedAnyEvent = true;
const type = typeof event.type === "string" ? event.type : "";
if (type === "thread.started" && typeof event.thread_id === "string") {
@ -342,6 +344,13 @@ export const codexAdapter: CliAdapter = {
}
}
// Non-JSON lines between events are expected; a non-empty stdout with NO
// parseable JSONL event at all means the CLI's output format drifted (or
// --json was ignored) — surface that instead of a silent "(no output)".
if (!parsedAnyEvent && stdout.trim()) {
warnJsonParseFailure("Codex exec output contained no parseable JSONL event", stdout.trim());
}
let outputText = agentMessages.join("\n\n").trim();
if (!outputText) outputText = errors.join("\n").trim();
if (!outputText) outputText = stderr.trim() || "(no output)";

View file

@ -239,8 +239,14 @@ export function buildCodexHomeOverlay(
if (!f.endsWith(".rules")) continue;
try {
copyFileSync(join(userRulesDir, f), join(rulesDir, `user-${f}`));
} catch {
/* skip an unreadable global rule rather than fail the run */
} catch (err) {
// Skip an unreadable global rule rather than fail the run — but say
// so: the user's safety rules are being dropped for this agent's run.
console.warn(
`Agent Fleet: couldn't copy the Codex rules file ${join(userRulesDir, f)} into agent ` +
`"${agent.name}"'s permission overlay (${err instanceof Error ? err.message : String(err)}); ` +
`its rules will NOT apply to this agent's runs.`,
);
}
}
}

View file

@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseJsonLoud, tryParseJson, warnJsonParseFailure } from "./parseHelpers";
afterEach(() => {
vi.restoreAllMocks();
});
describe("tryParseJson", () => {
it("parses valid JSON of any shape", () => {
expect(tryParseJson('{"a":1}')).toEqual({ a: 1 });
expect(tryParseJson("[1,2]")).toEqual([1, 2]);
expect(tryParseJson('"str"')).toBe("str");
expect(tryParseJson("3")).toBe(3);
});
it("returns undefined for non-JSON without logging", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(tryParseJson("Loading model… please wait")).toBeUndefined();
expect(tryParseJson("")).toBeUndefined();
expect(warn).not.toHaveBeenCalled();
});
});
describe("parseJsonLoud", () => {
it("parses valid JSON silently", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(parseJsonLoud("ctx", '{"ok":true}')).toEqual({ ok: true });
expect(warn).not.toHaveBeenCalled();
});
it("warns with context, parse error, and a text preview on failure", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(parseJsonLoud("final result JSON", "{broken")).toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
const message = String(warn.mock.calls[0]?.[0]);
expect(message).toContain("final result JSON");
expect(message).toContain("{broken");
});
});
describe("warnJsonParseFailure", () => {
it("truncates the preview to ~200 chars", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const long = "x".repeat(500);
warnJsonParseFailure("ctx", long);
const message = String(warn.mock.calls[0]?.[0]);
expect(message).toContain("x".repeat(200));
expect(message).not.toContain("x".repeat(201));
expect(message).toContain("…");
});
it("includes the error message when an error is given", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
warnJsonParseFailure("ctx", "text", new Error("Unexpected token"));
expect(String(warn.mock.calls[0]?.[0])).toContain("Unexpected token");
});
});

View file

@ -0,0 +1,49 @@
/**
* Shared JSON-parsing helpers for the CLI adapters.
*
* Two distinct situations, two helpers:
*
* - `tryParseJson` per-line parsing of stream-json/JSONL output, where
* non-JSON lines are EXPECTED (CLI banners, verbose noise, progress text).
* Fails silently by design; warning per line would spam the console.
*
* - `parseJsonLoud` / `warnJsonParseFailure` for output that SHOULD be
* valid JSON (a whole-stdout result payload, or a stream that yielded no
* parseable event at all). Silence here hides real failures: the user sees
* "(no output)" with no clue that the CLI's output format drifted. These
* log a console.warn with the parse error and a preview of the offending
* text so version drift is debuggable.
*/
/** Parse a line that may or may not be JSON. Returns undefined on failure
* WITHOUT logging non-JSON lines are expected between stream events. */
export function tryParseJson(text: string): unknown | undefined {
try {
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
}
const PREVIEW_LENGTH = 200;
/** Warn that output which should have contained JSON didn't parse, with a
* ~200-char preview of the offending text and the parse error (if any). */
export function warnJsonParseFailure(context: string, text: string, error?: unknown): void {
const preview = text.length > PREVIEW_LENGTH ? `${text.slice(0, PREVIEW_LENGTH)}` : text;
const reason =
error instanceof Error ? error.message : error !== undefined ? String(error) : "no parseable JSON found";
console.warn(`Agent Fleet: ${context}${reason}. Output begins: ${preview}`);
}
/** Parse JSON that is expected to be valid (e.g. a whole-stdout result
* payload). Logs a console.warn via `warnJsonParseFailure` on failure and
* returns undefined so callers keep their existing fallback behavior. */
export function parseJsonLoud(context: string, text: string): unknown | undefined {
try {
return JSON.parse(text) as unknown;
} catch (err) {
warnJsonParseFailure(context, text, err);
return undefined;
}
}

View file

@ -857,6 +857,7 @@ Discord uses the Gateway (outbound WebSocket) + REST. Features: \`@agent-name\`
- Agents with \`approval_required\` set cannot be bound to a channel
- Multi-agent routing: type \`@agent-name: message\` to switch agents, or use \`/agents\` for interactive picker
- Obsidian must be running for channels to work when closed, bots go offline
- Live follow-ups (since 0.15.0): a message sent while the agent is still working is folded into the running turn (Claude: live stdin; Codex: queued follow-up turn) instead of waiting for a fresh turn matching the in-app chat. Each injected follow-up gets its own reply; \`[REMEMBER]\` blocks are stripped from channel replies.
## Creating a Task

338
src/fleetRepository.test.ts Normal file
View file

@ -0,0 +1,338 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// The shared obsidian stub lacks App/FileSystemAdapter (fleetRepository
// references FileSystemAdapter at runtime via instanceof); extend it here.
// Its parseYaml also lacks inline-list support (`skills: [s1]`), which the
// reference-validation tests need — bolt on a minimal `[a, b]` parser.
vi.mock("obsidian", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const baseParseYaml = actual.parseYaml as (input: string) => Record<string, unknown>;
return {
...actual,
App: class App {},
FileSystemAdapter: class FileSystemAdapter {},
parseYaml: (input: string): Record<string, unknown> => {
const out = baseParseYaml(input);
for (const [key, value] of Object.entries(out)) {
if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) {
const inner = value.slice(1, -1).trim();
out[key] = inner
? inner.split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
: [];
}
}
return out;
},
};
});
import type { App } from "obsidian";
import { TFile, TFolder } from "obsidian";
import { DEFAULT_SETTINGS } from "./constants";
import { FleetRepository } from "./fleetRepository";
/** Minimal in-memory vault: enough surface for the repository paths under test. */
class FakeVault {
files = new Map<string, TFile>();
contents = new Map<string, string>();
folders = new Map<string, TFolder>();
cachedReadCalls = 0;
private clock = 1;
addFile(path: string, content: string): TFile {
const existing = this.files.get(path);
if (existing) {
this.contents.set(path, content);
existing.stat.mtime = this.clock++;
existing.stat.size = content.length;
return existing;
}
const file = new TFile();
file.path = path;
const base = path.split("/").pop() ?? "";
file.basename = base.replace(/\.[^.]+$/, "");
file.extension = base.split(".").pop() ?? "";
file.stat = { ctime: this.clock, mtime: this.clock++, size: content.length };
this.files.set(path, file);
this.contents.set(path, content);
this.ensureFolderChain(path.slice(0, path.lastIndexOf("/")));
return file;
}
private ensureFolderChain(path: string): void {
if (!path || this.folders.has(path)) return;
const folder = new TFolder();
folder.path = path;
this.folders.set(path, folder);
const parent = path.slice(0, path.lastIndexOf("/"));
if (parent) this.ensureFolderChain(parent);
}
getAbstractFileByPath(path: string): TFile | TFolder | null {
const file = this.files.get(path);
if (file) return file;
const folder = this.folders.get(path);
if (!folder) return null;
const children: Array<TFile | TFolder> = [];
for (const candidate of [...this.folders.values(), ...this.files.values()]) {
if (!candidate.path.startsWith(`${path}/`)) continue;
if (candidate.path.slice(path.length + 1).includes("/")) continue;
children.push(candidate);
}
folder.children = children;
return folder;
}
async cachedRead(file: TFile): Promise<string> {
this.cachedReadCalls++;
const content = this.contents.get(file.path);
if (content === undefined) throw new Error(`no such file: ${file.path}`);
return content;
}
async create(path: string, content: string): Promise<TFile> {
if (this.files.has(path)) throw new Error("File already exists");
return this.addFile(path, content);
}
async createFolder(path: string): Promise<void> {
if (this.folders.has(path)) throw new Error("Folder already exists");
this.ensureFolderChain(path);
}
async modify(file: TFile, content: string): Promise<void> {
this.addFile(file.path, content);
}
getMarkdownFiles(): TFile[] {
return [...this.files.values()].filter((f) => f.extension === "md");
}
removeTree(path: string): void {
this.files.delete(path);
this.contents.delete(path);
this.folders.delete(path);
for (const p of [...this.files.keys()]) {
if (p.startsWith(`${path}/`)) {
this.files.delete(p);
this.contents.delete(p);
}
}
for (const p of [...this.folders.keys()]) {
if (p.startsWith(`${path}/`)) this.folders.delete(p);
}
}
}
function makeApp(vault: FakeVault): App {
return {
vault,
fileManager: {
trashFile: async (file: TFile | TFolder) => {
vault.removeTree(file.path);
},
},
} as unknown as App;
}
describe("FleetRepository", () => {
let vault: FakeVault;
let repo: FleetRepository;
beforeEach(() => {
vault = new FakeVault();
repo = new FleetRepository(makeApp(vault), { ...DEFAULT_SETTINGS });
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("name-keyed lookups", () => {
it("reflects loads, removals, and reloads (index never goes stale)", async () => {
vault.addFile("_fleet/agents/foo.md", "---\nname: foo\nmodel: sonnet\n---\n\nBody\n");
vault.addFile("_fleet/tasks/t1.md", "---\ntask_id: t1\nagent: foo\ntype: immediate\n---\n\nDo it\n");
vault.addFile("_fleet/skills/s1.md", "---\nname: s1\n---\n\nSkill\n");
await repo.loadFile("_fleet/agents/foo.md");
await repo.loadFile("_fleet/tasks/t1.md");
await repo.loadFile("_fleet/skills/s1.md");
expect(repo.getAgentByName("foo")?.filePath).toBe("_fleet/agents/foo.md");
expect(repo.getTaskById("t1")?.filePath).toBe("_fleet/tasks/t1.md");
expect(repo.getSkillByName("s1")?.filePath).toBe("_fleet/skills/s1.md");
repo.removeFile("_fleet/agents/foo.md");
expect(repo.getAgentByName("foo")).toBeUndefined();
await repo.loadFile("_fleet/agents/foo.md");
expect(repo.getAgentByName("foo")).toBeDefined();
});
it("keeps the first entity when names collide (linear-scan parity)", async () => {
vault.addFile("_fleet/agents/a1.md", "---\nname: dup\nmodel: sonnet\n---\n\nA\n");
vault.addFile("_fleet/agents/a2.md", "---\nname: dup\nmodel: sonnet\n---\n\nB\n");
await repo.loadFile("_fleet/agents/a1.md");
await repo.loadFile("_fleet/agents/a2.md");
expect(repo.getAgentByName("dup")?.filePath).toBe("_fleet/agents/a1.md");
});
});
describe("corrupt permissions.json", () => {
it("loads the folder agent, logs an error, and records a validation issue", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vault.addFile("_fleet/agents/bot/agent.md", "---\nname: bot\n---\n\nPrompt\n");
vault.addFile("_fleet/agents/bot/permissions.json", "{not json");
await repo.loadAll();
const agent = repo.getAgentByName("bot");
expect(agent).toBeDefined();
expect(agent?.permissionRules).toEqual({ allow: [], deny: [] });
expect(errorSpy).toHaveBeenCalled();
const issues = repo
.getSnapshot()
.validationIssues.filter((i) => i.path === "_fleet/agents/bot/permissions.json");
// Exactly one, even though loadAll also reloads the folder agent via
// loadFile(agent.md) — the stale issue is cleared before re-parsing.
expect(issues).toHaveLength(1);
});
});
describe("migrateLegacyMemory", () => {
const legacyPath = "_fleet/memory/bot.md";
const workingPath = "_fleet/memory/bot/working.md";
it("lets concurrent callers await the same in-flight migration", async () => {
vault.addFile(legacyPath, "---\nagent: bot\n---\n\n## Learned Context\n\n- fact one\n- fact two\n");
let secondSawWorking = false;
const first = repo.migrateLegacyMemory("bot");
const second = repo.migrateLegacyMemory("bot").then(() => {
secondSawWorking = vault.getAbstractFileByPath(workingPath) !== null;
});
await Promise.all([first, second]);
// The second caller must resolve only after the migration completed.
expect(secondSawWorking).toBe(true);
expect(vault.getAbstractFileByPath(workingPath)).toBeInstanceOf(TFile);
expect(vault.getAbstractFileByPath(legacyPath)).toBeNull();
const day = new Date().toISOString().slice(0, 10);
const raw = vault.contents.get(`_fleet/memory/bot/raw/${day}.md`) ?? "";
expect(raw.match(/\[migrated\] Imported/g)).toHaveLength(1);
// A later call is a no-op — no double seeding.
await repo.migrateLegacyMemory("bot");
const rawAfter = vault.contents.get(`_fleet/memory/bot/raw/${day}.md`) ?? "";
expect(rawAfter).toBe(raw);
});
});
describe("readRunLog cache", () => {
it("returns the cached parse until mtime changes", async () => {
const file = vault.addFile(
"_fleet/runs/2026-07-01/000000-a-t.md",
"---\nrun_id: r1\nagent: a\ntask: t\nstatus: success\nstarted: 2026-07-01T00:00:00Z\n---\n\n## Prompt\n\nhi\n\n## Output\n\nok\n",
);
const first = await repo.readRunLog(file);
expect(first?.status).toBe("success");
const readsAfterFirst = vault.cachedReadCalls;
const second = await repo.readRunLog(file);
expect(second).toBe(first); // cached object, no re-read
expect(vault.cachedReadCalls).toBe(readsAfterFirst);
// Modifying the file bumps mtime → cache invalidates.
vault.addFile(
file.path,
"---\nrun_id: r1\nagent: a\ntask: t\nstatus: failure\nstarted: 2026-07-01T00:00:00Z\n---\n\n## Prompt\n\nhi\n\n## Output\n\nboom\n",
);
const third = await repo.readRunLog(file);
expect(third?.status).toBe("failure");
expect(vault.cachedReadCalls).toBe(readsAfterFirst + 1);
});
});
describe("reference validation re-runs after mutations", () => {
it("flags an agent's dangling skill reference immediately after deleteSkill", async () => {
vault.addFile("_fleet/agents/foo.md", "---\nname: foo\nmodel: sonnet\nskills: [s1]\n---\n\nBody\n");
vault.addFile("_fleet/skills/s1.md", "---\nname: s1\n---\n\nSkill\n");
await repo.loadAll();
expect(repo.getSnapshot().validationIssues).toHaveLength(0);
await repo.deleteSkill("s1");
// No loadAll() in between — the issue must appear from the delete alone.
const issues = repo.getSnapshot().validationIssues;
expect(
issues.some(
(i) => i.path === "_fleet/agents/foo.md" && i.message.includes("missing skill `s1`"),
),
).toBe(true);
expect(repo.getSkillByName("s1")).toBeUndefined();
});
it("flags a task's dangling agent reference immediately after deleteAgent", async () => {
vault.addFile("_fleet/agents/foo.md", "---\nname: foo\nmodel: sonnet\n---\n\nBody\n");
vault.addFile("_fleet/tasks/t1.md", "---\ntask_id: t1\nagent: foo\ntype: immediate\n---\n\nDo\n");
await repo.loadAll();
expect(repo.getSnapshot().validationIssues).toHaveLength(0);
await repo.deleteAgent("foo", false);
expect(
repo.getSnapshot().validationIssues.some(
(i) => i.path === "_fleet/tasks/t1.md" && i.message.includes("missing agent `foo`"),
),
).toBe(true);
});
it("clears a dangling-skill issue as soon as the skill is created", async () => {
vault.addFile("_fleet/agents/foo.md", "---\nname: foo\nmodel: sonnet\nskills: [s1]\n---\n\nBody\n");
await repo.loadAll();
expect(
repo.getSnapshot().validationIssues.some((i) => i.message.includes("missing skill `s1`")),
).toBe(true);
await repo.createSkillTemplate("s1");
expect(
repo.getSnapshot().validationIssues.some((i) => i.message.includes("missing skill `s1`")),
).toBe(false);
});
it("re-running validation neither duplicates reference issues nor clears load-time corrupt-file issues", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
// Load-time issue: corrupt permissions.json on a folder agent.
vault.addFile("_fleet/agents/bot/agent.md", "---\nname: bot\n---\n\nPrompt\n");
vault.addFile("_fleet/agents/bot/permissions.json", "{not json");
// Reference issue: task pointing at a missing agent.
vault.addFile("_fleet/tasks/t1.md", "---\ntask_id: t1\nagent: ghost\ntype: immediate\n---\n\nDo\n");
await repo.loadAll();
const count = (issues: Array<{ path: string; message: string }>) => ({
corrupt: issues.filter((i) => i.path === "_fleet/agents/bot/permissions.json").length,
dangling: issues.filter((i) => i.message.includes("missing agent `ghost`")).length,
});
expect(count(repo.getSnapshot().validationIssues)).toEqual({ corrupt: 1, dangling: 1 });
// Two mutations, each re-running validation.
await repo.updateTask("t1", { priority: "high" });
await repo.updateHeartbeat("bot", { enabled: false });
expect(count(repo.getSnapshot().validationIssues)).toEqual({ corrupt: 1, dangling: 1 });
});
});
describe("fail-soft reads warn instead of staying silent", () => {
it("readCandidates returns [] and warns on corrupt JSON", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vault.addFile("_fleet/memory/bot/candidates.json", "{oops");
expect(await repo.readCandidates("bot")).toEqual([]);
expect(warnSpy).toHaveBeenCalled();
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import { existsSync, readFileSync } from "fs";
import { homedir } from "os";
import { readdir, rm, stat } from "fs/promises";
import { homedir, tmpdir } from "os";
import { join } from "path";
import {
Notice,
@ -51,6 +52,11 @@ export default class AgentFleetPlugin extends Plugin {
channelManager!: ChannelManager;
secretStore!: SecretStore;
/** Successful CLI verifications, keyed by `${label}:${cliPath}` timestamp.
* Skips re-spawning `--version` for the same binary within the TTL. */
private cliVerifiedAt = new Map<string, number>();
private static readonly CLI_VERIFY_TTL_MS = 5 * 60_000;
private statusBarEl?: HTMLElement;
private subscribedViews = new Set<{ render: () => Promise<void> }>();
private vaultChangeTimer?: number;
@ -209,6 +215,12 @@ export default class AgentFleetPlugin extends Plugin {
// Claude/Codex servers into the registry on first load.
void this.importNativeMcpServers();
// Sweep temp files a force-quit orphaned (their normal cleanup lives in
// finally blocks that never ran). Fire-and-forget — must not block load.
void this.cleanupStaleTempFiles().catch((err) => {
console.warn("Agent Fleet: stale temp-file sweep failed", err);
});
// Periodically refresh expiring OAuth tokens (every 30 min) so the next run
// projects a fresh bearer. Refreshed tokens persist to SecretStore.
this.registerInterval(
@ -441,6 +453,25 @@ export default class AgentFleetPlugin extends Plugin {
}
private async verifyCliBinary(cliPath: string, label: string, showNotice: boolean): Promise<boolean> {
// Skip re-spawning `--version` if this exact binary verified successfully
// recently — settings edits would otherwise probe on every save.
const cacheKey = `${label}:${cliPath}`;
const verifiedAt = this.cliVerifiedAt.get(cacheKey);
if (verifiedAt !== undefined && Date.now() - verifiedAt < AgentFleetPlugin.CLI_VERIFY_TTL_MS) {
if (showNotice) {
new Notice(`${label} CLI available.`);
}
return true;
}
const installHint =
label === "Claude"
? "install with: npm install -g @anthropic-ai/claude-code"
: "install with: npm install -g @openai/codex";
const failureMessage =
`${label} CLI verification failed (path: ${cliPath || "not set"}). ` +
`Fix the ${label} CLI Path in settings, or ${installHint}`;
return await new Promise((resolve) => {
// On macOS/Linux: spawns through login shell so env vars are available.
// On Windows: spawns directly (env is inherited from the system).
@ -451,24 +482,71 @@ export default class AgentFleetPlugin extends Plugin {
});
proc.on("close", (code) => {
const ok = code === 0;
if (!ok) {
if (ok) {
this.cliVerifiedAt.set(cacheKey, Date.now());
} else {
console.error(`Agent Fleet: ${label} CLI verification failed`, stderr);
}
if (showNotice) {
new Notice(ok ? `${label} CLI available.` : `${label} CLI verification failed — check ${label} CLI Path in settings.`);
new Notice(ok ? `${label} CLI available.` : failureMessage, ok ? 5000 : 10000);
}
resolve(ok);
});
proc.on("error", (error) => {
console.error(`Agent Fleet: ${label} CLI verification error`, error);
if (showNotice) {
new Notice(`${label} CLI verification failed — check ${label} CLI Path in settings.`);
new Notice(failureMessage, 10000);
}
resolve(false);
});
});
}
/**
* Best-effort removal of per-run temp files that a force-quit left behind:
* MCP projection files under `<vaultBase>/.claude/` `af-mcp.<token>.json`
* and `af-mcp-<slug>.<token>.cjs` (mcpProjection.ts) plus
* `af-remember-mcp.<token>.{json,cjs}` (rememberMcpServer.ts) older than
* 24h, and per-agent CODEX_HOME overlay dirs under the OS temp dir
* (codexPermissions.ts OVERLAY_ROOT) older than 7 days. Conservative: only
* names matching the plugin's own prefixes are touched.
*/
private async cleanupStaleTempFiles(): Promise<void> {
const now = Date.now();
const sweep = async (dir: string, matches: (name: string) => boolean, maxAgeMs: number) => {
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return; // dir missing/unreadable — nothing to sweep
}
for (const name of entries) {
if (!matches(name)) continue;
const fullPath = join(dir, name);
try {
const info = await stat(fullPath);
if (now - info.mtimeMs > maxAgeMs) {
await rm(fullPath, { recursive: true, force: true });
}
} catch {
// best-effort — skip entries we can't stat/remove
}
}
};
const vaultBase = this.repository.getVaultBasePath();
if (vaultBase) {
await sweep(
join(vaultBase, ".claude"),
(name) => /^(af-mcp|af-remember-mcp)[.-].+\.(json|cjs)$/.test(name),
24 * 60 * 60 * 1000,
);
}
// Overlays are keyed by agent and rebuilt on demand, so removing old ones
// is safe even if the agent still exists.
await sweep(join(tmpdir(), "agent-fleet-codex"), () => true, 7 * 24 * 60 * 60 * 1000);
}
async openPath(path: string): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(normalizePath(path));
if (file instanceof TFile) {

View file

@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentConfig } from "../types";
import { ConversationStore } from "./conversationStore";
import { FakeVault, makeApp } from "./testSupport";
function agent(overrides: Partial<AgentConfig>): AgentConfig {
return {
filePath: "_fleet/agents/bot.md",
name: "bot",
model: "sonnet",
adapter: "claude-code",
permissionMode: "bypassPermissions",
maxRetries: 1,
skills: [],
mcpServers: [],
enabled: true,
timeout: 300,
approvalRequired: [],
memory: false,
memoryMaxEntries: 100,
memoryTokenBudget: 2000,
reflection: {
enabled: false,
schedule: "0 3 * * *",
recurrenceThreshold: 3,
proposeSkills: false,
model: undefined,
},
autoCompactThreshold: 85,
tags: [],
avatar: "",
body: "",
contextBody: "",
skillsBody: "",
env: {},
permissionRules: { allow: [], deny: [] },
isFolder: false,
heartbeatEnabled: false,
heartbeatSchedule: "",
heartbeatBody: "",
heartbeatNotify: true,
heartbeatChannel: "",
heartbeatChannelTarget: "",
...overrides,
} as AgentConfig;
}
describe("ConversationStore", () => {
let vault: FakeVault;
let store: ConversationStore;
beforeEach(() => {
vault = new FakeVault();
store = new ConversationStore(
makeApp(vault),
(name) => `_fleet/memory/${name}.md`,
);
});
it("flat agents park conversations beside the memory dir; folder agents nest in their folder", async () => {
await store.createConversation(agent({}), "chat-1", "First");
expect(vault.files.has("_fleet/memory/bot-conversations/chat-1.json")).toBe(true);
const folderAgent = agent({ isFolder: true, filePath: "_fleet/agents/bot/agent.md" });
await store.createConversation(folderAgent, "chat-1", "First");
expect(vault.files.has("_fleet/agents/bot/conversations/chat-1.json")).toBe(true);
});
it("createConversation is idempotent — an existing file is left alone", async () => {
const a = agent({});
await store.createConversation(a, "chat-1", "Original");
const before = vault.contents.get("_fleet/memory/bot-conversations/chat-1.json");
await store.createConversation(a, "chat-1", "Renamed attempt");
expect(vault.contents.get("_fleet/memory/bot-conversations/chat-1.json")).toBe(before);
});
it("lists conversations sorted by lastActive desc, skipping unreadable files", async () => {
const a = agent({});
const dir = "_fleet/memory/bot-conversations";
vault.addFile(`${dir}/old.json`, JSON.stringify({ name: "Old", lastActive: "2026-01-01T00:00:00Z", messages: [1] }));
vault.addFile(`${dir}/new.json`, JSON.stringify({ name: "New", lastActive: "2026-07-01T00:00:00Z", messages: [] }));
vault.addFile(`${dir}/broken.json`, "{not json");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const list = await store.listConversations(a);
expect(list.map((c) => c.id)).toEqual(["new", "old"]);
expect(list[0]?.messageCount).toBe(0);
expect(list[1]?.messageCount).toBe(1);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it("renameConversation rewrites the name field", async () => {
const a = agent({});
await store.createConversation(a, "chat-1", "Before");
await store.renameConversation(a, "chat-1", "After");
const state = JSON.parse(vault.contents.get("_fleet/memory/bot-conversations/chat-1.json") ?? "{}") as {
name?: string;
};
expect(state.name).toBe("After");
});
it("deleteConversation trashes the file and the parent folder once empty", async () => {
const a = agent({});
await store.createConversation(a, "chat-1", "Only");
await store.deleteConversation(a, "chat-1");
expect(vault.files.has("_fleet/memory/bot-conversations/chat-1.json")).toBe(false);
expect(vault.folders.has("_fleet/memory/bot-conversations")).toBe(false);
});
});

View file

@ -0,0 +1,146 @@
import { TFile, TFolder, normalizePath } from "obsidian";
import type { App, Vault } from "obsidian";
import { slugify } from "../utils/markdown";
import type { AgentConfig, ConversationMeta } from "../types";
import { ensureFolder } from "./shared";
/**
* Conversation registry (in-app multi-conversation): per-conversation JSON
* state files under the agent's `conversations/` folder. Extracted verbatim
* from FleetRepository.
*/
export class ConversationStore {
private readonly vault: Vault;
constructor(
private readonly app: App,
/** Facade's legacy flat memory path flat agents park their
* conversations beside it (`<memory dir>/<agent>-conversations`). */
private readonly getLegacyMemoryPath: (agentName: string) => string,
) {
this.vault = app.vault;
}
/** Folder that holds per-conversation JSON files for an agent. Folder
* agents nest under their own folder; flat agents share the memory dir. */
private getConversationsDir(agent: AgentConfig): string {
if (agent.isFolder) {
const folderPath = agent.filePath.replace(/\/agent\.md$/, "");
return normalizePath(`${folderPath}/conversations`);
}
const memoryDir = this.getLegacyMemoryPath(agent.name).replace(/\/[^/]+$/, "");
return normalizePath(`${memoryDir}/${agent.name}-conversations`);
}
/** Path to a conversation's state file. The id is slugified for safety
* on disk; callers should treat the original id as opaque. */
private getConversationPath(agent: AgentConfig, conversationId: string): string {
const dir = this.getConversationsDir(agent);
const safeId = slugify(conversationId) || "conversation";
return normalizePath(`${dir}/${safeId}.json`);
}
/** List all conversations for an agent, sorted by lastActive desc.
* Scans the conversations/ folder; pre-feature chat.json files (if any)
* are intentionally NOT surfaced here see the "no migration" decision
* in the conversation-feature design notes. */
async listConversations(agent: AgentConfig): Promise<ConversationMeta[]> {
const out: ConversationMeta[] = [];
const dir = this.getConversationsDir(agent);
const folder = this.vault.getAbstractFileByPath(dir);
if (folder instanceof TFolder) {
for (const child of folder.children) {
if (!(child instanceof TFile) || child.extension !== "json") continue;
const id = child.basename;
const meta = await this.readConversationMeta(child, id);
if (meta) out.push(meta);
}
}
out.sort((a, b) => b.lastActive.localeCompare(a.lastActive));
return out;
}
private async readConversationMeta(
file: TFile,
id: string,
): Promise<ConversationMeta | null> {
try {
const content = await this.vault.cachedRead(file);
const state = JSON.parse(content) as {
name?: string;
lastActive?: string;
messages?: unknown[];
};
return {
id,
name: state.name?.trim() || "Untitled",
lastActive: state.lastActive ?? new Date(file.stat.mtime).toISOString(),
messageCount: Array.isArray(state.messages) ? state.messages.length : 0,
};
} catch (err) {
console.warn(`Agent Fleet: skipping unreadable conversation file ${file.path}`, err);
return null;
}
}
/** Create an empty conversation file with a given id + name. Idempotent
* if the file already exists, leaves it alone (callers can just switch
* into it). */
async createConversation(agent: AgentConfig, id: string, name: string): Promise<void> {
const path = this.getConversationPath(agent, id);
const existing = this.vault.getAbstractFileByPath(path);
if (existing instanceof TFile) return;
const dir = path.replace(/\/[^/]+$/, "");
await ensureFolder(this.vault, dir);
const now = new Date().toISOString();
const state = {
sessionId: null,
messages: [],
lastActive: now,
createdAt: now,
name: name.trim(),
};
await this.vault.create(path, JSON.stringify(state, null, 2));
}
/** Rename a conversation by writing a new `name` field into its state file. */
async renameConversation(agent: AgentConfig, conversationId: string, name: string): Promise<void> {
const path = this.getConversationPath(agent, conversationId);
const file = this.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return;
const content = await this.vault.cachedRead(file);
let state: Record<string, unknown>;
try {
state = JSON.parse(content) as Record<string, unknown>;
} catch (err) {
console.warn(`Agent Fleet: cannot rename conversation — invalid JSON in ${path}`, err);
return;
}
state.name = name.trim();
await this.vault.modify(file, JSON.stringify(state, null, 2));
}
/** Delete a conversation: the JSON file, its threads sidecar, and the
* parent `conversations/` folder if it just emptied out. The chat view
* auto-creates a fresh conversation if the user deletes their last one,
* so this never strands the panel in a no-conversations state. */
async deleteConversation(agent: AgentConfig, conversationId: string): Promise<void> {
const path = this.getConversationPath(agent, conversationId);
const file = this.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
await this.app.fileManager.trashFile(file);
}
const threadsDir = path.replace(/\.json$/, ".threads");
const threadsFolder = this.vault.getAbstractFileByPath(threadsDir);
if (threadsFolder instanceof TFolder) {
await this.app.fileManager.trashFile(threadsFolder);
}
const dir = this.getConversationsDir(agent);
const folder = this.vault.getAbstractFileByPath(dir);
if (folder instanceof TFolder && folder.children.length === 0) {
await this.app.fileManager.trashFile(folder);
}
}
}

View file

@ -0,0 +1,716 @@
import { TFile, TFolder, normalizePath } from "obsidian";
import type { App, Vault } from "obsidian";
import type { FLEET_SUBFOLDERS } from "../constants";
import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter, slugify } from "../utils/markdown";
import { collectMarkdownChildren, createFileIfMissing, ensureFolder, getAvailablePath, trashPath } from "./shared";
import { sidecarPermissionsPath } from "./entityParsers";
import type { EntityStore } from "./entityStore";
import type { TaskConfig } from "../types";
// ═══════════════════════════════════════════════════════
// Update/create option shapes — structurally identical to the inline object
// types the facade methods have always taken, so callers are unaffected.
// ═══════════════════════════════════════════════════════
export interface CreateAgentFolderOptions {
name: string;
description: string;
avatar: string;
tags: string[];
systemPrompt: string;
model: string;
adapter: string;
cwd: string;
timeout: number;
permissionMode: string;
effort?: string;
approvalRequired: string[];
memory: boolean;
memoryMaxEntries: number;
skills: string[];
mcpServers?: string[];
skillsBody: string;
contextBody: string;
enabled?: boolean;
permissionRules?: { allow: string[]; deny: string[] };
autoCompactThreshold?: number;
wikiReferences?: string[];
}
export interface CreateSkillFolderOptions {
name: string;
description: string;
tags: string[];
body: string;
toolsBody: string;
referencesBody: string;
examplesBody: string;
}
export interface AgentUpdates {
description?: string;
avatar?: string;
tags?: string[];
systemPrompt?: string;
model?: string;
adapter?: string;
cwd?: string;
timeout?: number;
permissionMode?: string;
effort?: string;
permissionRules?: { allow: string[]; deny: string[] };
approvalRequired?: string[];
memory?: boolean;
memoryTokenBudget?: number;
reflectionEnabled?: boolean;
reflectionSchedule?: string;
reflectionProposeSkills?: boolean;
skills?: string[];
mcpServers?: string[];
skillsBody?: string;
contextBody?: string;
enabled?: boolean;
/** 0-100; percent of context window at which auto-compact fires. */
autoCompactThreshold?: number;
/** Names of Wiki Keeper agents this agent can read from. */
wikiReferences?: string[];
}
export interface TaskUpdates {
agent?: string;
type?: string;
schedule?: string;
runAt?: string;
enabled?: boolean;
priority?: string;
catch_up?: boolean;
effort?: string;
model?: string;
channel?: string;
channelTarget?: string;
tags?: string[];
body?: string;
}
export interface SkillUpdates {
description?: string;
tags?: string[];
body?: string;
toolsBody?: string;
referencesBody?: string;
examplesBody?: string;
}
export interface ChannelUpdates {
default_agent?: string;
allowed_agents?: string[];
enabled?: boolean;
credential_ref?: string;
allowed_users?: string[];
per_user_sessions?: boolean;
channel_context?: string;
tags?: string[];
body?: string;
type?: string;
transport?: Record<string, unknown>;
}
export interface HeartbeatUpdates {
enabled?: boolean;
schedule?: string;
notify?: boolean;
channel?: string;
channelTarget?: string;
body?: string;
}
/**
* Entity create/update/delete: persists changes to the vault files, then
* drives the EntityStore (loadFile / clearStoredFile / validateReferences) so
* the in-memory maps and reference issues are fresh without waiting for the
* debounced vault-event reload. Extracted verbatim from the FleetRepository
* facade; memory paths are injected because deleting an agent also trashes
* its MemoryStore-owned files.
*/
export class EntityMutations {
private readonly vault: Vault;
constructor(
private readonly app: App,
private readonly deps: {
store: EntityStore;
getSubfolder: (name: (typeof FLEET_SUBFOLDERS)[number]) => string;
getMemoryPath: (agentName: string) => string;
getMemoryDir: (agentName: string) => string;
},
) {
this.vault = app.vault;
}
private get store(): EntityStore {
return this.deps.store;
}
async updateTaskRunMetadata(task: TaskConfig, updates: Partial<Pick<TaskConfig, "lastRun" | "nextRun" | "runCount">>): Promise<void> {
const file = this.vault.getAbstractFileByPath(task.filePath);
if (!(file instanceof TFile)) {
return;
}
const content = await this.vault.cachedRead(file);
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
const nextFrontmatter = {
...frontmatter,
last_run: updates.lastRun ?? task.lastRun,
next_run: updates.nextRun ?? task.nextRun,
run_count: updates.runCount ?? task.runCount,
};
await this.vault.modify(file, stringifyMarkdownWithFrontmatter(nextFrontmatter, body));
await this.store.loadFile(file);
}
async createAgentTemplate(name: string): Promise<TFile> {
const path = await getAvailablePath(this.vault, this.deps.getSubfolder("agents"), slugify(name));
const content = `---\nname: ${slugify(name)}\ndescription: \nenabled: true\nskills: []\ntags: []\n---\n\nAgent instructions go here.\n`;
const file = await this.vault.create(path, content);
// Register the new entity in-memory and revalidate references immediately
// (a new agent can resolve a task's missing-agent issue) instead of waiting
// for the debounced vault-event reload.
await this.store.loadFile(file);
return file;
}
async createAgentFolder(opts: CreateAgentFolderOptions): Promise<string> {
const slug = slugify(opts.name);
const folderPath = normalizePath(`${this.deps.getSubfolder("agents")}/${slug}`);
await ensureFolder(this.vault, folderPath);
// agent.md
const agentFm: Record<string, unknown> = {
name: opts.name,
description: opts.description || undefined,
avatar: opts.avatar || undefined,
enabled: opts.enabled ?? true,
tags: opts.tags,
skills: opts.skills,
mcp_servers: opts.mcpServers?.length ? opts.mcpServers : undefined,
};
if (opts.model && opts.model !== "default") {
agentFm.model = opts.model;
}
const agentPath = normalizePath(`${folderPath}/agent.md`);
await this.vault.create(agentPath, stringifyMarkdownWithFrontmatter(agentFm, opts.systemPrompt || ""));
// config.md
const configFm: Record<string, unknown> = {
model: opts.model || "default",
adapter: opts.adapter || "claude-code",
timeout: opts.timeout,
max_retries: 1,
cwd: opts.cwd || "",
permission_mode: opts.permissionMode || "bypassPermissions",
effort: opts.effort || undefined,
approval_required: opts.approvalRequired,
memory: opts.memory,
memory_max_entries: opts.memoryMaxEntries,
};
if (typeof opts.autoCompactThreshold === "number") {
configFm.auto_compact_threshold = opts.autoCompactThreshold;
}
if (opts.wikiReferences && opts.wikiReferences.length > 0) {
configFm.wiki_references = opts.wikiReferences.map((agent) => ({ agent }));
}
const configPath = normalizePath(`${folderPath}/config.md`);
await this.vault.create(configPath, stringifyMarkdownWithFrontmatter(configFm, ""));
// SKILLS.md
const skillsPath = normalizePath(`${folderPath}/SKILLS.md`);
await this.vault.create(skillsPath, stringifyMarkdownWithFrontmatter({}, opts.skillsBody || ""));
// CONTEXT.md
const contextPath = normalizePath(`${folderPath}/CONTEXT.md`);
await this.vault.create(contextPath, stringifyMarkdownWithFrontmatter({}, opts.contextBody || ""));
// permissions.json (only if rules are non-empty)
const rules = opts.permissionRules;
if (rules && (rules.allow.length > 0 || rules.deny.length > 0)) {
const permPath = normalizePath(`${folderPath}/permissions.json`);
await this.vault.create(permPath, JSON.stringify(rules, null, 2) + "\n");
}
// Register the new folder agent in-memory + revalidate references now.
await this.store.loadFile(agentPath);
return agentPath;
}
async createSkillTemplate(name: string): Promise<TFile> {
const path = await getAvailablePath(this.vault, this.deps.getSubfolder("skills"), slugify(name));
const content = `---\nname: ${slugify(name)}\ndescription: \ntags: []\n---\n\nSkill instructions go here.\n`;
const file = await this.vault.create(path, content);
// Register in-memory + revalidate (a new skill can resolve an agent's
// missing-skill issue) without waiting for the debounced vault reload.
await this.store.loadFile(file);
return file;
}
async createSkillFolder(opts: CreateSkillFolderOptions): Promise<void> {
const folderPath = normalizePath(`${this.deps.getSubfolder("skills")}/${slugify(opts.name)}`);
await ensureFolder(this.vault, folderPath);
const skillFm = {
name: opts.name,
description: opts.description || undefined,
tags: opts.tags.length > 0 ? opts.tags : undefined,
};
const skillPath = normalizePath(`${folderPath}/skill.md`);
await createFileIfMissing(this.vault, skillPath, stringifyMarkdownWithFrontmatter(skillFm, opts.body || "Skill instructions go here."));
if (opts.toolsBody) {
const toolsPath = normalizePath(`${folderPath}/tools.md`);
await createFileIfMissing(this.vault, toolsPath, `# Tools\n\n${opts.toolsBody}`);
}
if (opts.referencesBody) {
const refsPath = normalizePath(`${folderPath}/references.md`);
await createFileIfMissing(this.vault, refsPath, `# References\n\n${opts.referencesBody}`);
}
if (opts.examplesBody) {
const examplesPath = normalizePath(`${folderPath}/examples.md`);
await createFileIfMissing(this.vault, examplesPath, `# Examples\n\n${opts.examplesBody}`);
}
// Register the new folder skill in-memory + revalidate references now.
await this.store.loadFile(skillPath);
}
async updateAgent(agentName: string, updates: AgentUpdates): Promise<void> {
const agent = this.store.getAgentByName(agentName);
if (!agent) return;
if (agent.isFolder) {
const folderPath = normalizePath(agent.filePath.replace(/\/agent\.md$/, ""));
// Update agent.md
const agentFile = this.vault.getAbstractFileByPath(agent.filePath);
if (agentFile instanceof TFile) {
const content = await this.vault.cachedRead(agentFile);
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
if (updates.description !== undefined) frontmatter.description = updates.description || undefined;
if (updates.avatar !== undefined) frontmatter.avatar = updates.avatar || undefined;
if (updates.tags !== undefined) frontmatter.tags = updates.tags;
if (updates.skills !== undefined) frontmatter.skills = updates.skills;
if (updates.mcpServers !== undefined) frontmatter.mcp_servers = updates.mcpServers.length > 0 ? updates.mcpServers : undefined;
if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled;
if (updates.model !== undefined && updates.model !== "default") frontmatter.model = updates.model;
const newBody = updates.systemPrompt !== undefined ? updates.systemPrompt : body;
await this.vault.modify(agentFile, stringifyMarkdownWithFrontmatter(frontmatter, newBody));
}
// Update config.md
const configPath = normalizePath(`${folderPath}/config.md`);
const configFile = this.vault.getAbstractFileByPath(configPath);
if (configFile instanceof TFile) {
const content = await this.vault.cachedRead(configFile);
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
if (updates.model !== undefined) frontmatter.model = updates.model;
if (updates.adapter !== undefined) frontmatter.adapter = updates.adapter;
if (updates.timeout !== undefined) frontmatter.timeout = updates.timeout;
if (updates.cwd !== undefined) frontmatter.cwd = updates.cwd;
if (updates.permissionMode !== undefined) frontmatter.permission_mode = updates.permissionMode;
if (updates.effort !== undefined) frontmatter.effort = updates.effort || undefined;
if (updates.approvalRequired !== undefined) frontmatter.approval_required = updates.approvalRequired;
if (updates.memory !== undefined) frontmatter.memory = updates.memory;
if (updates.memoryTokenBudget !== undefined) frontmatter.memory_token_budget = updates.memoryTokenBudget;
if (updates.reflectionEnabled !== undefined) frontmatter.reflection_enabled = updates.reflectionEnabled;
if (updates.reflectionSchedule !== undefined) frontmatter.reflection_schedule = updates.reflectionSchedule;
if (updates.reflectionProposeSkills !== undefined) frontmatter.reflection_propose_skills = updates.reflectionProposeSkills;
if (updates.autoCompactThreshold !== undefined) {
frontmatter.auto_compact_threshold = updates.autoCompactThreshold;
}
if (updates.wikiReferences !== undefined) {
frontmatter.wiki_references = updates.wikiReferences.length > 0
? updates.wikiReferences.map((agent) => ({ agent }))
: undefined;
}
// Strip dead legacy permission fields whenever we touch config.md —
// permissions.json is now the canonical surface for allow/deny.
delete frontmatter.allowed_tools;
delete frontmatter.blocked_tools;
await this.vault.modify(configFile, stringifyMarkdownWithFrontmatter(frontmatter, body));
}
// Update SKILLS.md
if (updates.skillsBody !== undefined) {
const skillsPath = normalizePath(`${folderPath}/SKILLS.md`);
const skillsFile = this.vault.getAbstractFileByPath(skillsPath);
if (skillsFile instanceof TFile) {
await this.vault.modify(skillsFile, stringifyMarkdownWithFrontmatter({}, updates.skillsBody));
} else {
await this.vault.create(skillsPath, stringifyMarkdownWithFrontmatter({}, updates.skillsBody));
}
}
// Update CONTEXT.md
if (updates.contextBody !== undefined) {
const contextPath = normalizePath(`${folderPath}/CONTEXT.md`);
const contextFile = this.vault.getAbstractFileByPath(contextPath);
if (contextFile instanceof TFile) {
await this.vault.modify(contextFile, stringifyMarkdownWithFrontmatter({}, updates.contextBody));
} else {
await this.vault.create(contextPath, stringifyMarkdownWithFrontmatter({}, updates.contextBody));
}
}
// Update permissions.json
if (updates.permissionRules !== undefined) {
const permPath = normalizePath(`${folderPath}/permissions.json`);
const permFile = this.vault.getAbstractFileByPath(permPath);
const rules = updates.permissionRules;
if (rules.allow.length > 0 || rules.deny.length > 0) {
const content = JSON.stringify(rules, null, 2) + "\n";
if (permFile instanceof TFile) {
await this.vault.modify(permFile, content);
} else {
await this.vault.create(permPath, content);
}
} else if (permFile instanceof TFile) {
// Remove permissions.json if both lists are empty
await this.app.fileManager.trashFile(permFile);
}
}
} else {
// Legacy single-file agent
const file = this.vault.getAbstractFileByPath(agent.filePath);
if (!(file instanceof TFile)) return;
const content = await this.vault.cachedRead(file);
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
if (updates.description !== undefined) frontmatter.description = updates.description || undefined;
if (updates.avatar !== undefined) frontmatter.avatar = updates.avatar || undefined;
if (updates.tags !== undefined) frontmatter.tags = updates.tags;
if (updates.skills !== undefined) frontmatter.skills = updates.skills;
if (updates.mcpServers !== undefined) frontmatter.mcp_servers = updates.mcpServers.length > 0 ? updates.mcpServers : undefined;
if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled;
if (updates.model !== undefined) frontmatter.model = updates.model;
if (updates.adapter !== undefined) frontmatter.adapter = updates.adapter;
if (updates.timeout !== undefined) frontmatter.timeout = updates.timeout;
if (updates.cwd !== undefined) frontmatter.cwd = updates.cwd;
if (updates.permissionMode !== undefined) frontmatter.permission_mode = updates.permissionMode;
if (updates.effort !== undefined) frontmatter.effort = updates.effort || undefined;
if (updates.approvalRequired !== undefined) frontmatter.approval_required = updates.approvalRequired;
if (updates.memory !== undefined) frontmatter.memory = updates.memory;
if (updates.autoCompactThreshold !== undefined) {
frontmatter.auto_compact_threshold = updates.autoCompactThreshold;
}
if (updates.wikiReferences !== undefined) {
frontmatter.wiki_references = updates.wikiReferences.length > 0
? updates.wikiReferences.map((agent) => ({ agent }))
: undefined;
}
// Strip dead legacy permission fields whenever we touch the file —
// permissions.json sidecar is now the canonical surface.
delete frontmatter.allowed_tools;
delete frontmatter.blocked_tools;
const newBody = updates.systemPrompt !== undefined ? updates.systemPrompt : body;
await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody));
// Sidecar permissions.json for flat agents — parity with folder agents.
if (updates.permissionRules !== undefined) {
const sidecarPath = sidecarPermissionsPath(agent.filePath);
const sidecarFile = this.vault.getAbstractFileByPath(sidecarPath);
const rules = updates.permissionRules;
if (rules.allow.length > 0 || rules.deny.length > 0) {
const content = JSON.stringify(rules, null, 2) + "\n";
if (sidecarFile instanceof TFile) {
await this.vault.modify(sidecarFile, content);
} else {
await this.vault.create(sidecarPath, content);
}
} else if (sidecarFile instanceof TFile) {
await this.app.fileManager.trashFile(sidecarFile);
}
}
}
// Reload the edited entity into the in-memory maps and revalidate — the
// skills list may now reference a missing skill (or resolve one).
await this.store.loadFile(agent.filePath);
}
async updateTask(taskId: string, updates: TaskUpdates): Promise<void> {
const task = this.store.getTaskById(taskId);
if (!task) return;
const file = this.vault.getAbstractFileByPath(task.filePath);
if (!(file instanceof TFile)) return;
const content = await this.vault.cachedRead(file);
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
if (updates.agent !== undefined) frontmatter.agent = updates.agent;
if (updates.type !== undefined) frontmatter.type = updates.type;
if (updates.schedule !== undefined) frontmatter.schedule = updates.schedule || undefined;
if (updates.runAt !== undefined) frontmatter.run_at = updates.runAt || undefined;
if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled;
if (updates.priority !== undefined) frontmatter.priority = updates.priority;
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));
// Reload + revalidate — the task may have been retargeted to a different
// (possibly missing) agent.
await this.store.loadFile(file);
}
async updateSkill(skillName: string, updates: SkillUpdates): Promise<void> {
const skill = this.store.getSkillByName(skillName);
if (!skill) return;
if (skill.isFolder) {
const folderPath = normalizePath(skill.filePath.replace(/\/skill\.md$/, ""));
// Update skill.md
const skillFile = this.vault.getAbstractFileByPath(skill.filePath);
if (skillFile instanceof TFile) {
const content = await this.vault.cachedRead(skillFile);
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
if (updates.description !== undefined) frontmatter.description = updates.description || undefined;
if (updates.tags !== undefined) frontmatter.tags = updates.tags.length > 0 ? updates.tags : undefined;
const newBody = updates.body !== undefined ? updates.body : body;
await this.vault.modify(skillFile, stringifyMarkdownWithFrontmatter(frontmatter, newBody));
}
// Update tools.md
if (updates.toolsBody !== undefined) {
const toolsPath = normalizePath(`${folderPath}/tools.md`);
const toolsFile = this.vault.getAbstractFileByPath(toolsPath);
if (updates.toolsBody) {
if (toolsFile instanceof TFile) {
await this.vault.modify(toolsFile, `# Tools\n\n${updates.toolsBody}`);
} else {
await this.vault.create(toolsPath, `# Tools\n\n${updates.toolsBody}`);
}
}
}
// Update references.md
if (updates.referencesBody !== undefined) {
const refsPath = normalizePath(`${folderPath}/references.md`);
const refsFile = this.vault.getAbstractFileByPath(refsPath);
if (updates.referencesBody) {
if (refsFile instanceof TFile) {
await this.vault.modify(refsFile, `# References\n\n${updates.referencesBody}`);
} else {
await this.vault.create(refsPath, `# References\n\n${updates.referencesBody}`);
}
}
}
// Update examples.md
if (updates.examplesBody !== undefined) {
const examplesPath = normalizePath(`${folderPath}/examples.md`);
const examplesFile = this.vault.getAbstractFileByPath(examplesPath);
if (updates.examplesBody) {
if (examplesFile instanceof TFile) {
await this.vault.modify(examplesFile, `# Examples\n\n${updates.examplesBody}`);
} else {
await this.vault.create(examplesPath, `# Examples\n\n${updates.examplesBody}`);
}
}
}
} else {
// Legacy single-file skill
const file = this.vault.getAbstractFileByPath(skill.filePath);
if (!(file instanceof TFile)) return;
const content = await this.vault.cachedRead(file);
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
if (updates.description !== undefined) frontmatter.description = updates.description || undefined;
if (updates.tags !== undefined) frontmatter.tags = updates.tags.length > 0 ? updates.tags : undefined;
const newBody = updates.body !== undefined ? updates.body : body;
await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody));
}
// Reload + revalidate (skill names can't change here, but keep the
// in-memory entity fresh and the validation pass current).
await this.store.loadFile(skill.filePath);
}
async deleteSkill(skillName: string): Promise<void> {
const skill = this.store.getSkillByName(skillName);
if (!skill) return;
if (skill.isFolder) {
const folderPath = normalizePath(skill.filePath.replace(/\/skill\.md$/, ""));
const folder = this.vault.getAbstractFileByPath(folderPath);
if (folder instanceof TFolder) {
await this.app.fileManager.trashFile(folder);
}
this.store.clearIssuesUnder(folderPath);
} else {
await trashPath(this.app, skill.filePath);
}
// Drop the entity from the in-memory maps and revalidate immediately so
// agents referencing the deleted skill are flagged without a full reload.
this.store.clearStoredFile(skill.filePath);
this.store.validateReferences();
}
async deleteTask(taskId: string): Promise<void> {
const task = this.store.getTaskById(taskId);
if (!task) return;
await trashPath(this.app, task.filePath);
this.store.clearStoredFile(task.filePath);
this.store.validateReferences();
}
async updateChannel(channelName: string, updates: ChannelUpdates): Promise<void> {
const channel = this.store.getChannelByName(channelName);
if (!channel) return;
const file = this.vault.getAbstractFileByPath(channel.filePath);
if (!(file instanceof TFile)) return;
const content = await this.vault.cachedRead(file);
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
if (updates.default_agent !== undefined) {
frontmatter.default_agent = updates.default_agent;
// Remove legacy `agent` field to avoid confusion
delete frontmatter.agent;
}
if (updates.allowed_agents !== undefined) frontmatter.allowed_agents = updates.allowed_agents;
if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled;
if (updates.credential_ref !== undefined) frontmatter.credential_ref = updates.credential_ref;
if (updates.allowed_users !== undefined) frontmatter.allowed_users = updates.allowed_users;
if (updates.per_user_sessions !== undefined) frontmatter.per_user_sessions = updates.per_user_sessions;
if (updates.channel_context !== undefined) frontmatter.channel_context = updates.channel_context || undefined;
if (updates.tags !== undefined) frontmatter.tags = updates.tags;
if (updates.type !== undefined) frontmatter.type = updates.type;
if (updates.transport !== undefined) frontmatter.transport = updates.transport;
const newBody = updates.body !== undefined ? updates.body : body;
await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody));
// Reload + revalidate — default_agent / credential_ref may now dangle.
await this.store.loadFile(file);
}
async deleteChannel(channelName: string): Promise<void> {
const channel = this.store.getChannelByName(channelName);
if (!channel) return;
await trashPath(this.app, channel.filePath);
this.store.clearStoredFile(channel.filePath);
this.store.validateReferences();
// Also trash the channel's sessions folder if it exists
const sessionsFolder = normalizePath(
`${this.deps.getSubfolder("channels")}/${slugify(channelName)}/sessions`,
);
const folder = this.vault.getAbstractFileByPath(sessionsFolder);
if (folder instanceof TFolder) {
await this.app.fileManager.trashFile(folder);
}
}
async updateHeartbeat(agentName: string, updates: HeartbeatUpdates): Promise<void> {
const agent = this.store.getAgentByName(agentName);
if (!agent || !agent.isFolder) return;
const folderPath = normalizePath(agent.filePath.replace(/\/agent\.md$/, ""));
const heartbeatPath = normalizePath(`${folderPath}/HEARTBEAT.md`);
const file = this.vault.getAbstractFileByPath(heartbeatPath);
if (file instanceof TFile) {
// Update existing file
const content = await this.vault.cachedRead(file);
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
if (updates.enabled !== undefined) frontmatter.enabled = updates.enabled;
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 {
// Create new HEARTBEAT.md
const frontmatter: Record<string, unknown> = {
enabled: updates.enabled ?? false,
};
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,
stringifyMarkdownWithFrontmatter(frontmatter, body),
);
}
// Reload the owning folder agent so its heartbeat fields are fresh
// in-memory (reference-neutral, but keeps the snapshot consistent).
await this.store.loadFile(heartbeatPath);
}
async deleteAgent(agentName: string, deleteTasks: boolean): Promise<{ trashedFiles: string[] }> {
const trashedFiles: string[] = [];
const agent = this.store.getAgentByName(agentName);
if (!agent) return { trashedFiles };
if (agent.isFolder) {
// Trash the entire agent folder
const folderPath = normalizePath(agent.filePath.replace(/\/agent\.md$/, ""));
const folder = this.vault.getAbstractFileByPath(folderPath);
if (folder instanceof TFolder) {
const files: TFile[] = [];
collectMarkdownChildren(folder, files);
// Trash all files inside the folder first, then the folder
for (const f of files) {
trashedFiles.push(f.path);
}
await this.app.fileManager.trashFile(folder);
}
// Issues keyed on member files (permissions.json, …) go with the folder.
this.store.clearIssuesUnder(folderPath);
} else {
// Trash the single agent definition file
await trashPath(this.app, agent.filePath);
trashedFiles.push(agent.filePath);
// Drop any issue keyed on the flat agent's permissions sidecar.
this.store.clearIssuesFor(sidecarPermissionsPath(agent.filePath));
}
this.store.clearStoredFile(agent.filePath);
// Trash the agent's memory: legacy flat file + v2 memory folder.
const memoryPath = this.deps.getMemoryPath(agentName);
if (this.vault.getAbstractFileByPath(memoryPath)) {
await trashPath(this.app, memoryPath);
trashedFiles.push(memoryPath);
}
const memoryDir = this.deps.getMemoryDir(agentName);
const memoryFolder = this.vault.getAbstractFileByPath(memoryDir);
if (memoryFolder instanceof TFolder) {
await this.app.fileManager.trashFile(memoryFolder);
trashedFiles.push(memoryDir);
}
// Optionally trash associated tasks
if (deleteTasks) {
const tasks = this.store.getTasksForAgent(agentName);
for (const task of tasks) {
await trashPath(this.app, task.filePath);
trashedFiles.push(task.filePath);
this.store.clearStoredFile(task.filePath);
}
}
// Revalidate once after all map mutations: tasks/channels referencing the
// deleted agent get flagged immediately, without a full reload.
this.store.validateReferences();
return { trashedFiles };
}
}

View file

@ -0,0 +1,556 @@
import { TFile, normalizePath } from "obsidian";
import type { Vault } from "obsidian";
import {
DEFAULT_MEMORY_TOKEN_BUDGET,
DEFAULT_REFLECTION_SCHEDULE,
DEFAULT_RECURRENCE_THRESHOLD,
} from "../constants";
import { parseMarkdownWithFrontmatter } from "../utils/markdown";
import { asBoolean, asNumber, asString, asStringArray, isRecord } from "./shared";
import type {
AgentConfig,
ChannelConfig,
FleetSettings,
ReflectionConfig,
SkillConfig,
TaskConfig,
} from "../types";
export type ParsedEntity = AgentConfig | SkillConfig | TaskConfig;
/**
* Everything the entity parsers need from their host. Extracted verbatim from
* the FleetRepository facade the parsers used to be private methods closing
* over `this`; the context object is the same surface, made explicit:
*
* - `reportIssue` lands parse failures in the load-time validation-issue map
* (NOT the reference-issue map see EntityStore for the two-tier split).
* - `clearIssue` drops a stale load-time issue keyed on a member file (e.g.
* permissions.json) before that file is re-evaluated on a folder reload.
* - The `warned*` sets are owned by the store so one-time console warnings
* stay one-time across reloads.
*/
export interface ParserContext {
vault: Vault;
settings: FleetSettings;
reportIssue(path: string, message: string): void;
clearIssue(path: string): void;
warnedLegacyPerms: Set<string>;
warnedFolderAgentModelConflict: Set<string>;
}
let warnedLegacyBudget = false;
/**
* Resolve an agent's working-memory token budget. Prefers the new
* `memory_token_budget`; if absent but the deprecated `memory_max_entries` is
* present, fall back to the default budget and emit a one-time console notice
* (design §13.4 the old entry-count knob no longer maps cleanly to a budget).
*/
function resolveMemoryTokenBudget(
fm: Record<string, unknown>,
fallback: Record<string, unknown> = {},
): number {
const explicit = fm.memory_token_budget ?? fallback.memory_token_budget;
if (explicit !== undefined) return asNumber(explicit, DEFAULT_MEMORY_TOKEN_BUDGET);
const legacy = fm.memory_max_entries ?? fallback.memory_max_entries;
if (legacy !== undefined && !warnedLegacyBudget) {
warnedLegacyBudget = true;
console.info(
"Agent Fleet: `memory_max_entries` is deprecated and no longer enforced; " +
"memory is now bounded by `memory_token_budget` (default " +
`${DEFAULT_MEMORY_TOKEN_BUDGET}). Set that field to tune memory size.`,
);
}
return DEFAULT_MEMORY_TOKEN_BUDGET;
}
/** Build a {@link ReflectionConfig} from frontmatter, with an optional fallback
* frontmatter (folder agents split fields across config.md and agent.md). */
function parseReflectionConfig(
fm: Record<string, unknown>,
fallback: Record<string, unknown> = {},
): ReflectionConfig {
const pick = (key: string): unknown => fm[key] ?? fallback[key];
return {
enabled: asBoolean(pick("reflection_enabled"), false),
schedule: asString(pick("reflection_schedule")) ?? DEFAULT_REFLECTION_SCHEDULE,
recurrenceThreshold: asNumber(pick("reflection_recurrence_threshold"), DEFAULT_RECURRENCE_THRESHOLD),
proposeSkills: asBoolean(pick("reflection_propose_skills"), false),
model: asString(pick("reflection_model")),
};
}
/** For a flat agent at `<dir>/<name>.md`, the permissions sidecar lives at
* `<dir>/<name>.permissions.json`. Folder agents use `<folder>/permissions.json`
* (handled in loadFolderAgent). */
export function sidecarPermissionsPath(agentMdPath: string): string {
return normalizePath(agentMdPath.replace(/\.md$/, ".permissions.json"));
}
/** Coerce a frontmatter `env:` block into a stringstring map (numbers and
* booleans stringified, other value shapes dropped). */
function parseEnvMap(value: unknown): Record<string, string> {
if (!isRecord(value)) return {};
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(value)) {
if (typeof v === "string") {
result[k] = v;
} else if (typeof v === "number" || typeof v === "boolean") {
result[k] = String(v);
}
}
return result;
}
/** Parse a `wiki_references:` frontmatter list into an array of references.
* Accepts either `[{ agent: "name" }, ...]` or shorthand `["name", ...]`.
* Returns undefined when the field is absent. */
function parseWikiReferences(raw: unknown): AgentConfig["wikiReferences"] {
if (!Array.isArray(raw)) return undefined;
const refs: Array<{ agent: string }> = [];
for (const item of raw) {
if (typeof item === "string" && item.trim()) {
refs.push({ agent: item.trim() });
} else if (item && typeof item === "object") {
const agent = (item as Record<string, unknown>).agent;
if (typeof agent === "string" && agent.trim()) {
refs.push({ agent: agent.trim() });
}
}
}
return refs.length > 0 ? refs : undefined;
}
/** Parse a `wiki_keeper:` frontmatter block into a WikiKeeperConfig.
* Returns undefined when no block is present agents without this
* are regular agents, unaffected. */
function parseWikiKeeperConfig(raw: unknown): AgentConfig["wikiKeeper"] {
if (!raw || typeof raw !== "object") return undefined;
const r = raw as Record<string, unknown>;
return {
scopeRoot: asString(r.scope_root) ?? "",
inboxPath: asString(r.inbox_path) ?? "_sources/inbox",
archivePath: asString(r.archive_path) ?? "_sources/archive",
failedPath: asString(r.failed_path) ?? "_sources/failed",
topicsRoot: asString(r.topics_root) ?? "_topics",
indexPath: asString(r.index_path) ?? "index.md",
logPath: asString(r.log_path) ?? "log.md",
watchedFolders: asStringArray(r.watched_folders),
excludePatterns: asStringArray(r.exclude_patterns),
watchedSince: asString(r.watched_since) ?? "",
// Default flipped to TRUE in v1.3 — Karpathy compounding requires
// substantive answers to file themselves back into the wiki.
fileSubstantiveAnswers: asBoolean(r.file_substantive_answers, true),
obsidianUrlScheme: asBoolean(r.obsidian_url_scheme, true),
maxTokensPerIngest: asNumber(r.max_tokens_per_ingest, 60000),
maxTokensPerRefresh: asNumber(r.max_tokens_per_refresh, 30000),
indexSplitThreshold: asNumber(r.index_split_threshold, 100),
dedupSimilarityThreshold: asNumber(r.dedup_similarity_threshold, 0.82),
summaryStaleDays: asNumber(r.summary_stale_days, 30),
stateFile: asString(r.state_file) ?? ".wiki-keeper-state.json",
};
}
// ═══════════════════════════════════════════════════════
// Flat entity parsers (single .md files)
// ═══════════════════════════════════════════════════════
export function parseAgent(ctx: ParserContext, path: string, content: string): AgentConfig | null {
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
if (!isRecord(frontmatter)) {
ctx.reportIssue(path, "Invalid frontmatter.");
return null;
}
const name = asString(frontmatter.name);
const model = asString(frontmatter.model) ?? ctx.settings.defaultModel;
if (!name || !model) {
ctx.reportIssue(path, "Agent requires string field `name` and a valid model or default model setting.");
return null;
}
// Legacy migration for flat agents: pull rules from frontmatter
// allowed_tools/blocked_tools when present. The sidecar
// <name>.permissions.json (read in loadFile) will override these if it
// exists and parses cleanly. Saving the agent rewrites rules to the
// sidecar and stops emitting the legacy frontmatter fields.
const legacyAllow = asStringArray(frontmatter.allowed_tools);
const legacyDeny = asStringArray(frontmatter.blocked_tools);
if ((legacyAllow.length > 0 || legacyDeny.length > 0) && !ctx.warnedLegacyPerms.has(name)) {
ctx.warnedLegacyPerms.add(name);
console.warn(
`Agent Fleet: "${name}" still uses legacy allowed_tools/blocked_tools in its frontmatter. ` +
`Permission rules now live in <name>.permissions.json beside the .md. Open Edit and Save to migrate.`,
);
}
return {
filePath: path,
name,
description: asString(frontmatter.description),
model,
adapter: asString(frontmatter.adapter) ?? "claude-code",
permissionMode: asString(frontmatter.permission_mode) ?? "bypassPermissions",
effort: asString(frontmatter.effort),
maxRetries: asNumber(frontmatter.max_retries, 1),
skills: asStringArray(frontmatter.skills),
mcpServers: asStringArray(frontmatter.mcp_servers),
cwd: asString(frontmatter.cwd),
enabled: asBoolean(frontmatter.enabled, true),
timeout: asNumber(frontmatter.timeout, 300),
approvalRequired: asStringArray(frontmatter.approval_required),
memory: asBoolean(frontmatter.memory, false),
memoryMaxEntries: asNumber(frontmatter.memory_max_entries, 100),
memoryTokenBudget: resolveMemoryTokenBudget(frontmatter),
reflection: parseReflectionConfig(frontmatter),
autoCompactThreshold: asNumber(frontmatter.auto_compact_threshold, 85),
tags: asStringArray(frontmatter.tags),
avatar: asString(frontmatter.avatar) ?? "",
body,
contextBody: "",
skillsBody: "",
env: parseEnvMap(frontmatter.env),
permissionRules: { allow: legacyAllow, deny: legacyDeny },
isFolder: false,
heartbeatEnabled: false,
heartbeatSchedule: "",
heartbeatBody: "",
heartbeatNotify: true,
heartbeatChannel: "",
heartbeatChannelTarget: "",
};
}
export function parseSkill(ctx: ParserContext, path: string, content: string): SkillConfig | null {
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
const name = asString(frontmatter.name);
if (!name) {
ctx.reportIssue(path, "Skill requires string field `name`.");
return null;
}
return {
filePath: path,
name,
description: asString(frontmatter.description),
tags: asStringArray(frontmatter.tags),
body,
toolsBody: "",
referencesBody: "",
examplesBody: "",
isFolder: false,
};
}
export function parseTask(ctx: ParserContext, path: string, content: string): TaskConfig | null {
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
const taskId = asString(frontmatter.task_id);
const agent = asString(frontmatter.agent);
const type = asString(frontmatter.type) as TaskConfig["type"] | undefined;
if (!taskId || !agent || !type) {
ctx.reportIssue(path, "Task requires `task_id`, `agent`, and `type`.");
return null;
}
if (type === "recurring" && !asString(frontmatter.schedule)) {
ctx.reportIssue(path, "Recurring task requires `schedule`.");
return null;
}
if (type === "once" && !asString(frontmatter.run_at)) {
ctx.reportIssue(path, "One-time task requires `run_at`.");
return null;
}
const rawPriority = asString(frontmatter.priority);
const validPriorities = ["low", "medium", "high", "critical"];
const priority = (rawPriority && validPriorities.includes(rawPriority) ? rawPriority : "medium") as TaskConfig["priority"];
return {
filePath: path,
taskId,
agent,
schedule: asString(frontmatter.schedule),
runAt: asString(frontmatter.run_at),
type,
priority,
enabled: asBoolean(frontmatter.enabled, true),
created: asString(frontmatter.created) ?? new Date().toISOString(),
lastRun: asString(frontmatter.last_run),
nextRun: asString(frontmatter.next_run),
runCount: asNumber(frontmatter.run_count, 0),
catchUp: asBoolean(frontmatter.catch_up, ctx.settings.catchUpMissedTasks),
effort: asString(frontmatter.effort),
model: asString(frontmatter.model),
channel: asString(frontmatter.channel),
channelTarget: asString(frontmatter.channel_target),
tags: asStringArray(frontmatter.tags),
body,
};
}
export function parseChannelFile(ctx: ParserContext, path: string, content: string): ChannelConfig | null {
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
const name = asString(frontmatter.name);
if (!name) {
ctx.reportIssue(path, "Channel requires string field `name`.");
return null;
}
const rawType = asString(frontmatter.type);
const validTypes: readonly string[] = ["slack", "telegram", "discord"];
if (!rawType || !validTypes.includes(rawType)) {
ctx.reportIssue(
path,
`Channel \`${name}\` requires \`type\` to be one of: ${validTypes.join(", ")}.`,
);
return null;
}
const type = rawType as ChannelConfig["type"];
// `default_agent` is the canonical field; `agent` is accepted as a backward-compat alias.
const defaultAgent = asString(frontmatter.default_agent) ?? asString(frontmatter.agent);
if (!defaultAgent) {
ctx.reportIssue(path, `Channel \`${name}\` requires \`default_agent\` (or \`agent\`).`);
return null;
}
const allowedAgents = asStringArray(frontmatter.allowed_agents);
const credentialRef = asString(frontmatter.credential_ref);
if (!credentialRef) {
ctx.reportIssue(
path,
`Channel \`${name}\` requires \`credential_ref\` pointing at a configured credential.`,
);
return null;
}
const transport = isRecord(frontmatter.transport) ? frontmatter.transport : {};
return {
filePath: path,
name,
type,
defaultAgent,
allowedAgents,
enabled: asBoolean(frontmatter.enabled, true),
credentialRef,
allowedUsers: asStringArray(frontmatter.allowed_users),
perUserSessions: asBoolean(frontmatter.per_user_sessions, true),
channelContext: asString(frontmatter.channel_context) ?? "",
transport,
tags: asStringArray(frontmatter.tags),
body,
};
}
// ═══════════════════════════════════════════════════════
// Folder entity loaders (agents/<name>/agent.md + member files,
// skills/<name>/skill.md + member files)
// ═══════════════════════════════════════════════════════
export async function loadFolderSkill(
ctx: ParserContext,
folderPath: string,
skillMdFile: TFile,
): Promise<SkillConfig | null> {
const skillContent = await ctx.vault.cachedRead(skillMdFile);
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(skillContent);
const name = asString(frontmatter.name);
if (!name) {
ctx.reportIssue(skillMdFile.path, "Folder skill skill.md requires string field `name`.");
return null;
}
const readBody = async (fileName: string): Promise<string> => {
const path = normalizePath(`${folderPath}/${fileName}`);
const file = ctx.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return "";
const content = await ctx.vault.cachedRead(file);
const parsed = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
return parsed.body;
};
return {
filePath: skillMdFile.path,
name,
description: asString(frontmatter.description),
tags: asStringArray(frontmatter.tags),
body,
toolsBody: await readBody("tools.md"),
referencesBody: await readBody("references.md"),
examplesBody: await readBody("examples.md"),
isFolder: true,
};
}
export async function loadFolderAgent(
ctx: ParserContext,
folderPath: string,
agentMdFile: TFile,
): Promise<AgentConfig | null> {
const agentContent = await ctx.vault.cachedRead(agentMdFile);
const { frontmatter: agentFm, body: agentBody } = parseMarkdownWithFrontmatter<Record<string, unknown>>(agentContent);
const name = asString(agentFm.name);
if (!name) {
ctx.reportIssue(agentMdFile.path, "Folder agent agent.md requires string field `name`.");
return null;
}
// Read config.md
let configFm: Record<string, unknown> = {};
const configPath = normalizePath(`${folderPath}/config.md`);
const configFile = ctx.vault.getAbstractFileByPath(configPath);
if (configFile instanceof TFile) {
const configContent = await ctx.vault.cachedRead(configFile);
const parsed = parseMarkdownWithFrontmatter<Record<string, unknown>>(configContent);
configFm = parsed.frontmatter;
}
// Read permissions.json — canonical source of truth for allow/deny rules.
// Legacy migration: if permissions.json is missing or empty BUT config.md
// carries `allowed_tools`/`blocked_tools` (the dead surface from older
// versions of this plugin), surface those values as permissionRules so
// the agent keeps working. The next call to updateAgent / updateWiki-
// KeeperAgent rewrites them into permissions.json proper.
let permissionRules: { allow: string[]; deny: string[] } = { allow: [], deny: [] };
const permissionsPath = normalizePath(`${folderPath}/permissions.json`);
// Folder reloads bypass clearStoredFile, so drop any stale issue keyed on
// the permissions file before re-evaluating it (avoids duplicates).
ctx.clearIssue(permissionsPath);
const permissionsFile = ctx.vault.getAbstractFileByPath(permissionsPath);
if (permissionsFile instanceof TFile) {
try {
const permContent = await ctx.vault.cachedRead(permissionsFile);
const parsed = JSON.parse(permContent) as Record<string, unknown>;
permissionRules = {
allow: asStringArray(parsed.allow),
deny: asStringArray(parsed.deny),
};
} catch (err) {
// Fail-soft (the agent still loads) but loud: with the file unreadable
// the agent would otherwise silently run with EMPTY permission rules.
console.error(
`Agent Fleet: invalid JSON in ${permissionsPath} — agent "${name}" is running with EMPTY permission rules`,
err,
);
ctx.reportIssue(
permissionsPath,
`Invalid JSON in permissions.json for agent \`${name}\` — permission rules are NOT applied. Fix or delete the file.`,
);
}
}
if (permissionRules.allow.length === 0 && permissionRules.deny.length === 0) {
const legacyAllow = asStringArray(configFm.allowed_tools);
const legacyDeny = asStringArray(configFm.blocked_tools);
if (legacyAllow.length > 0 || legacyDeny.length > 0) {
permissionRules = { allow: legacyAllow, deny: legacyDeny };
if (!ctx.warnedLegacyPerms.has(name)) {
ctx.warnedLegacyPerms.add(name);
console.warn(
`Agent Fleet: "${name}" still uses legacy allowed_tools/blocked_tools in config.md. ` +
`Permission rules now live in permissions.json. Open this agent in Edit and Save to migrate.`,
);
}
}
}
// Read SKILLS.md body
let skillsBody = "";
const skillsPath = normalizePath(`${folderPath}/SKILLS.md`);
const skillsFile = ctx.vault.getAbstractFileByPath(skillsPath);
if (skillsFile instanceof TFile) {
const skillsContent = await ctx.vault.cachedRead(skillsFile);
const parsed = parseMarkdownWithFrontmatter<Record<string, unknown>>(skillsContent);
skillsBody = parsed.body;
}
// Read CONTEXT.md body
let contextBody = "";
const contextPath = normalizePath(`${folderPath}/CONTEXT.md`);
const contextFile = ctx.vault.getAbstractFileByPath(contextPath);
if (contextFile instanceof TFile) {
const contextContent = await ctx.vault.cachedRead(contextFile);
const parsed = parseMarkdownWithFrontmatter<Record<string, unknown>>(contextContent);
contextBody = parsed.body;
}
// Read HEARTBEAT.md — autonomous periodic run instruction
let heartbeatEnabled = false;
let heartbeatSchedule = "";
let heartbeatBody = "";
let heartbeatNotify = true;
let heartbeatChannel = "";
let heartbeatChannelTarget = "";
const heartbeatPath = normalizePath(`${folderPath}/HEARTBEAT.md`);
const heartbeatFile = ctx.vault.getAbstractFileByPath(heartbeatPath);
if (heartbeatFile instanceof TFile) {
const heartbeatContent = await ctx.vault.cachedRead(heartbeatFile);
const parsed = parseMarkdownWithFrontmatter<Record<string, unknown>>(heartbeatContent);
heartbeatEnabled = asBoolean(parsed.frontmatter.enabled, false);
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;
}
// Model canonical home is config.md for folder agents. agent.md's `model:`
// field is deprecated but still warned-about when it conflicts with
// config.md so users can clean up their vaults.
const agentMdModel = asString(agentFm.model);
const configModel = asString(configFm.model);
if (agentMdModel && configModel && agentMdModel !== configModel) {
if (!ctx.warnedFolderAgentModelConflict.has(name)) {
ctx.warnedFolderAgentModelConflict.add(name);
console.warn(
`Agent Fleet: "${name}" has conflicting model fields — agent.md says "${agentMdModel}", config.md says "${configModel}". config.md wins. Remove agent.md's model field or sync the values to silence this warning.`,
);
}
}
const model = configModel ?? agentMdModel ?? ctx.settings.defaultModel;
return {
filePath: agentMdFile.path,
name,
description: asString(agentFm.description),
model,
adapter: asString(configFm.adapter) ?? "claude-code",
permissionMode: asString(configFm.permission_mode) ?? "bypassPermissions",
effort: asString(configFm.effort),
maxRetries: asNumber(configFm.max_retries, 1),
skills: asStringArray(agentFm.skills),
mcpServers: asStringArray(agentFm.mcp_servers),
cwd: asString(configFm.cwd) || asString(agentFm.cwd),
enabled: asBoolean(agentFm.enabled, true),
timeout: asNumber(configFm.timeout, asNumber(agentFm.timeout, 300)),
approvalRequired: asStringArray(configFm.approval_required),
memory: asBoolean(configFm.memory, asBoolean(agentFm.memory, false)),
memoryMaxEntries: asNumber(configFm.memory_max_entries, 100),
memoryTokenBudget: resolveMemoryTokenBudget(configFm, agentFm),
reflection: parseReflectionConfig(configFm, agentFm),
autoCompactThreshold: asNumber(
configFm.auto_compact_threshold ?? agentFm.auto_compact_threshold,
85,
),
tags: asStringArray(agentFm.tags),
avatar: asString(agentFm.avatar) ?? "",
body: agentBody,
contextBody,
skillsBody,
env: parseEnvMap(configFm.env),
permissionRules,
isFolder: true,
heartbeatEnabled,
heartbeatSchedule,
heartbeatBody,
heartbeatNotify,
heartbeatChannel,
heartbeatChannelTarget,
wikiKeeper: parseWikiKeeperConfig(configFm.wiki_keeper ?? agentFm.wiki_keeper),
wikiReferences: parseWikiReferences(configFm.wiki_references ?? agentFm.wiki_references),
};
}

View file

@ -0,0 +1,598 @@
import { TFile, TFolder, normalizePath } from "obsidian";
import type { App, Vault } from "obsidian";
import type { FLEET_SUBFOLDERS } from "../constants";
import { asStringArray } from "./shared";
import {
loadFolderAgent,
loadFolderSkill,
parseAgent,
parseChannelFile,
parseSkill,
parseTask,
sidecarPermissionsPath,
} from "./entityParsers";
import type { ParsedEntity, ParserContext } from "./entityParsers";
import type {
AgentConfig,
ChannelConfig,
ChannelCredentialEntry,
FleetSettings,
FleetSnapshot,
McpServer,
SkillConfig,
TaskConfig,
ValidationIssue,
} from "../types";
/** A path-keyed entity map that notifies on every mutation. The store's
* name-keyed lookup indexes are invalidated through this callback, so no
* mutation site (load, reload, delete, clear) can ever be missed staleness
* is impossible by construction rather than by auditing call sites. */
class MutationTrackedMap<V> extends Map<string, V> {
constructor(private readonly onMutate: () => void) {
super();
}
override set(key: string, value: V): this {
this.onMutate();
return super.set(key, value);
}
override delete(key: string): boolean {
this.onMutate();
return super.delete(key);
}
override clear(): void {
this.onMutate();
super.clear();
}
}
/**
* The entity cluster extracted from the FleetRepository facade: in-memory
* agent/skill/task/channel/MCP-server maps, the load flow (flat + folder
* entities), the two-tier validation-issue bookkeeping, and cross-entity
* reference validation. The facade delegates here and keeps only wiring and
* cross-store operations. Persisting mutations (create/update/delete entity
* files) lives in EntityMutations, which drives this store's maps through
* loadFile/clearStoredFile/validateReferences.
*/
export class EntityStore {
private agents = new MutationTrackedMap<AgentConfig>(() => {
this.nameIndexesStale = true;
});
private skills = new MutationTrackedMap<SkillConfig>(() => {
this.nameIndexesStale = true;
});
private tasks = new MutationTrackedMap<TaskConfig>(() => {
this.nameIndexesStale = true;
});
private channels = new Map<string, ChannelConfig>();
private mcpServers = new Map<string, McpServer>();
/** Load-time issues (parse failures, corrupt permission/sidecar files),
* keyed by file path. Set while loading individual files and cleared only
* when that file is re-parsed or removed. */
private validationIssues = new Map<string, ValidationIssue[]>();
/** Cross-entity REFERENCE issues (missing skill/agent/credential, duplicate
* names), keyed by file path. Kept separate from {@link validationIssues}
* so {@link validateReferences} can be re-run after any mutation: it clears
* and recomputes only this map, never touching (or duplicating alongside)
* the load-time corrupt-file issues. */
private referenceIssues = new Map<string, ValidationIssue[]>();
/** True while loadAll() streams files in suppresses the per-mutation
* validateReferences() so a bulk load validates once at the end instead of
* once per file. */
private bulkLoading = false;
/** Lazily rebuilt name-keyed indexes over agents/skills/tasks. Keeps the
* FIRST entity per name (insertion order) so duplicate names resolve the
* same way the previous linear `find()` scans did. */
private nameIndexesStale = true;
private agentsByName = new Map<string, AgentConfig>();
private skillsByName = new Map<string, SkillConfig>();
private tasksById = new Map<string, TaskConfig>();
private channelCredentialGetter?: () => Record<string, ChannelCredentialEntry>;
/** Agents we've already logged a folder-model-conflict warning for. */
private warnedFolderAgentModelConflict = new Set<string>();
private warnedLegacyPerms = new Set<string>();
private readonly vault: Vault;
private readonly settings: FleetSettings;
/** Context handed to the extracted entity parsers same surface the
* parsers had as private facade methods (vault reads, issue reporting into
* {@link validationIssues}, the one-time-warning sets). */
private readonly parserCtx: ParserContext;
constructor(
app: App,
private readonly deps: {
settings: FleetSettings;
getFleetRoot: () => string;
getSubfolder: (name: (typeof FLEET_SUBFOLDERS)[number]) => string;
/** MCP registry files are parsed by McpRegistry; the store only owns the
* in-memory server map. Routed through the facade so registry parse
* errors keep landing in this store's validation-issue map. */
parseMcpServerFile: (path: string, content: string) => McpServer | null;
},
) {
this.vault = app.vault;
this.settings = deps.settings;
this.parserCtx = {
vault: this.vault,
settings: this.settings,
reportIssue: (path, message) => this.setIssue(path, message),
clearIssue: (path) => {
this.validationIssues.delete(path);
},
warnedLegacyPerms: this.warnedLegacyPerms,
warnedFolderAgentModelConflict: this.warnedFolderAgentModelConflict,
};
}
/** Inject a live credential getter so validation reads from the credential store
* instead of the (possibly empty post-migration) settings.channelCredentials. */
setChannelCredentialGetter(getter: () => Record<string, ChannelCredentialEntry>): void {
this.channelCredentialGetter = getter;
}
async loadAll(): Promise<FleetSnapshot> {
this.agents.clear();
this.skills.clear();
this.tasks.clear();
this.channels.clear();
this.mcpServers.clear();
this.validationIssues.clear();
this.referenceIssues.clear();
this.bulkLoading = true;
try {
// Load folder-based agents and skills first
await this.loadFolderAgents();
await this.loadFolderSkills();
const files = this.vault
.getMarkdownFiles()
.filter((file) => file.path.startsWith(`${this.deps.getFleetRoot()}/`));
for (const file of files) {
await this.loadFile(file);
}
} finally {
this.bulkLoading = false;
}
this.validateReferences();
return this.getSnapshot();
}
async loadFile(fileOrPath: TFile | string): Promise<void> {
await this.loadFileInternal(fileOrPath);
// Entity references may have changed (e.g. an agent's skills list edited on
// disk, a task retargeted) — recompute reference issues immediately instead
// of waiting for the next full loadAll(). Skipped inside loadAll's own loop
// (validated once at the end). Cheap: validateReferences is a pure in-memory
// pass over the loaded maps.
if (!this.bulkLoading) this.validateReferences();
}
private async loadFileInternal(fileOrPath: TFile | string): Promise<void> {
const file = typeof fileOrPath === "string" ? this.vault.getAbstractFileByPath(fileOrPath) : fileOrPath;
if (!(file instanceof TFile) || file.extension !== "md") {
return;
}
// Files inside agent folders need to reload the whole folder agent
if (this.isInsideAgentFolder(file.path)) {
await this.reloadFolderAgentContaining(file.path);
return;
}
// Files inside skill folders need to reload the whole folder skill
if (this.isInsideSkillFolder(file.path)) {
await this.reloadFolderSkillContaining(file.path);
return;
}
this.clearStoredFile(file.path);
// Channel files live under _fleet/channels/ and are parsed through a dedicated
// path that does NOT flow through parseFile(). This keeps channels out of the
// ParsedEntity union (AgentConfig | SkillConfig | TaskConfig) and lets their
// validation (type discrimination, approval_required block) live beside the
// parser.
const channelsPrefix = `${this.deps.getSubfolder("channels")}/`;
if (file.path.startsWith(channelsPrefix)) {
// Skip nested files (e.g. sessions/<id>.json). Only top-level *.md are channel configs.
const rel = file.path.slice(channelsPrefix.length);
if (!rel.includes("/")) {
const content = await this.vault.cachedRead(file);
const channel = parseChannelFile(this.parserCtx, file.path, content);
if (channel) {
this.channels.set(file.path, channel);
}
}
return;
}
// MCP server registry files live under _fleet/mcp/ and, like channels, are
// parsed through a dedicated path that does NOT flow through parseFile()
// (they're not part of the ParsedEntity union).
const mcpPrefix = `${this.deps.getSubfolder("mcp")}/`;
if (file.path.startsWith(mcpPrefix)) {
const rel = file.path.slice(mcpPrefix.length);
if (!rel.includes("/")) {
const content = await this.vault.cachedRead(file);
const server = this.deps.parseMcpServerFile(file.path, content);
if (server) {
this.mcpServers.set(file.path, server);
}
}
return;
}
const content = await this.vault.cachedRead(file);
const parsed = this.parseFile(file.path, content);
if (parsed) {
if ("taskId" in parsed) {
this.tasks.set(file.path, parsed);
} else if ("model" in parsed) {
// Flat agent: sidecar `<name>.permissions.json` wins over legacy
// frontmatter allowed_tools/blocked_tools when both exist.
if (!parsed.isFolder) {
const sidecarPath = sidecarPermissionsPath(file.path);
// clearStoredFile above only clears issues keyed on the .md path;
// drop any stale sidecar issue before re-evaluating the file.
this.validationIssues.delete(sidecarPath);
const sidecarFile = this.vault.getAbstractFileByPath(sidecarPath);
if (sidecarFile instanceof TFile) {
try {
const sidecarContent = await this.vault.cachedRead(sidecarFile);
const data = JSON.parse(sidecarContent) as Record<string, unknown>;
parsed.permissionRules = {
allow: asStringArray(data.allow),
deny: asStringArray(data.deny),
};
} catch (err) {
// Invalid sidecar JSON — keep whatever permissionRules parseAgent assigned.
console.error(
`Agent Fleet: invalid JSON in ${sidecarPath} — agent "${parsed.name}" falls back to legacy frontmatter permission rules`,
err,
);
this.setIssue(
sidecarPath,
`Invalid JSON in ${sidecarPath} for agent \`${parsed.name}\` — sidecar permission rules are NOT applied.`,
);
}
}
}
this.agents.set(file.path, parsed);
} else {
this.skills.set(file.path, parsed);
}
}
}
private async reloadFolderAgentContaining(filePath: string): Promise<void> {
const agentsDir = `${this.deps.getSubfolder("agents")}/`;
const relative = filePath.slice(agentsDir.length);
const folderName = relative.split("/")[0];
if (!folderName) return;
const folderPath = normalizePath(`${agentsDir}${folderName}`);
const agentMdPath = normalizePath(`${folderPath}/agent.md`);
// Clear any existing entry for this folder agent
this.agents.delete(agentMdPath);
const folder = this.vault.getAbstractFileByPath(folderPath);
if (!(folder instanceof TFolder)) return;
const agentMdFile = this.vault.getAbstractFileByPath(agentMdPath);
if (!(agentMdFile instanceof TFile)) return;
const agent = await loadFolderAgent(this.parserCtx, folderPath, agentMdFile);
if (agent) {
this.agents.set(agentMdPath, agent);
}
}
private isInsideAgentFolder(path: string): boolean {
const agentsDir = `${this.deps.getSubfolder("agents")}/`;
if (!path.startsWith(agentsDir)) return false;
const relative = path.slice(agentsDir.length);
return relative.includes("/");
}
private isInsideSkillFolder(path: string): boolean {
const skillsDir = `${this.deps.getSubfolder("skills")}/`;
if (!path.startsWith(skillsDir)) return false;
const relative = path.slice(skillsDir.length);
return relative.includes("/");
}
private async reloadFolderSkillContaining(filePath: string): Promise<void> {
const skillsDir = `${this.deps.getSubfolder("skills")}/`;
const relative = filePath.slice(skillsDir.length);
const folderName = relative.split("/")[0];
if (!folderName) return;
const folderPath = normalizePath(`${skillsDir}${folderName}`);
const skillMdPath = normalizePath(`${folderPath}/skill.md`);
this.skills.delete(skillMdPath);
const folder = this.vault.getAbstractFileByPath(folderPath);
if (!(folder instanceof TFolder)) return;
const skillMdFile = this.vault.getAbstractFileByPath(skillMdPath);
if (!(skillMdFile instanceof TFile)) return;
const skill = await loadFolderSkill(this.parserCtx, folderPath, skillMdFile);
if (skill) {
this.skills.set(skillMdPath, skill);
}
}
private async loadFolderSkills(): Promise<void> {
const skillsFolder = this.vault.getAbstractFileByPath(this.deps.getSubfolder("skills"));
if (!(skillsFolder instanceof TFolder)) return;
for (const child of skillsFolder.children) {
if (!(child instanceof TFolder)) continue;
const skillMdPath = normalizePath(`${child.path}/skill.md`);
const skillMdFile = this.vault.getAbstractFileByPath(skillMdPath);
if (!(skillMdFile instanceof TFile)) continue;
const skill = await loadFolderSkill(this.parserCtx, child.path, skillMdFile);
if (skill) {
this.skills.set(skillMdPath, skill);
}
}
}
private async loadFolderAgents(): Promise<void> {
const agentsFolder = this.vault.getAbstractFileByPath(this.deps.getSubfolder("agents"));
if (!(agentsFolder instanceof TFolder)) return;
for (const child of agentsFolder.children) {
if (!(child instanceof TFolder)) continue;
const agentMdPath = normalizePath(`${child.path}/agent.md`);
const agentMdFile = this.vault.getAbstractFileByPath(agentMdPath);
if (!(agentMdFile instanceof TFile)) continue;
const agent = await loadFolderAgent(this.parserCtx, child.path, agentMdFile);
if (agent) {
this.agents.set(agentMdPath, agent);
}
}
}
removeFile(path: string): void {
this.clearStoredFile(path);
// A removed entity can orphan references pointing at it — revalidate now.
if (!this.bulkLoading) this.validateReferences();
}
getSnapshot(): FleetSnapshot {
return {
agents: Array.from(this.agents.values()).sort((a, b) => a.name.localeCompare(b.name)),
skills: Array.from(this.skills.values()).sort((a, b) => a.name.localeCompare(b.name)),
tasks: Array.from(this.tasks.values()).sort((a, b) => a.taskId.localeCompare(b.taskId)),
channels: Array.from(this.channels.values()).sort((a, b) => a.name.localeCompare(b.name)),
mcpServers: Array.from(this.mcpServers.values()).sort((a, b) => a.name.localeCompare(b.name)),
validationIssues: [
...Array.from(this.validationIssues.values()).flat(),
...Array.from(this.referenceIssues.values()).flat(),
],
};
}
private rebuildNameIndexes(): void {
if (!this.nameIndexesStale) return;
this.agentsByName.clear();
this.skillsByName.clear();
this.tasksById.clear();
for (const agent of this.agents.values()) {
if (!this.agentsByName.has(agent.name)) this.agentsByName.set(agent.name, agent);
}
for (const skill of this.skills.values()) {
if (!this.skillsByName.has(skill.name)) this.skillsByName.set(skill.name, skill);
}
for (const task of this.tasks.values()) {
if (!this.tasksById.has(task.taskId)) this.tasksById.set(task.taskId, task);
}
this.nameIndexesStale = false;
}
getAgentByName(name: string): AgentConfig | undefined {
this.rebuildNameIndexes();
return this.agentsByName.get(name);
}
getSkillByName(name: string): SkillConfig | undefined {
this.rebuildNameIndexes();
return this.skillsByName.get(name);
}
getTaskById(taskId: string): TaskConfig | undefined {
this.rebuildNameIndexes();
return this.tasksById.get(taskId);
}
getTasksForAgent(agentName: string): TaskConfig[] {
return Array.from(this.tasks.values()).filter((task) => task.agent === agentName);
}
getChannelByName(name: string): ChannelConfig | undefined {
return Array.from(this.channels.values()).find((channel) => channel.name === name);
}
getChannelsForAgent(agentName: string): ChannelConfig[] {
return Array.from(this.channels.values()).filter((channel) => channel.defaultAgent === agentName);
}
/** All registered MCP servers, sorted by name. */
getMcpServers(): McpServer[] {
return Array.from(this.mcpServers.values()).sort((a, b) => a.name.localeCompare(b.name));
}
getMcpServerByName(name: string): McpServer | undefined {
return Array.from(this.mcpServers.values()).find((s) => s.name === name);
}
/** Drop every in-memory entity and issue keyed on `path`. Public within the
* repository layer: EntityMutations drives it on delete paths. */
clearStoredFile(path: string): void {
this.agents.delete(path);
this.skills.delete(path);
this.tasks.delete(path);
this.channels.delete(path);
this.mcpServers.delete(path);
this.validationIssues.delete(path);
this.referenceIssues.delete(path);
}
/** Drop every stored issue keyed under `folderPath/` — folder agents/skills
* carry issues keyed on member files (e.g. permissions.json), which
* clearStoredFile(<entity>.md) alone would leave behind after a delete. */
clearIssuesUnder(folderPath: string): void {
const prefix = `${folderPath}/`;
for (const key of [...this.validationIssues.keys()]) {
if (key.startsWith(prefix)) this.validationIssues.delete(key);
}
for (const key of [...this.referenceIssues.keys()]) {
if (key.startsWith(prefix)) this.referenceIssues.delete(key);
}
}
/** Drop both load-time and reference issues keyed on exactly `path` used
* when deleting a flat agent to clear its permissions-sidecar issues. */
clearIssuesFor(path: string): void {
this.validationIssues.delete(path);
this.referenceIssues.delete(path);
}
/** Record a LOAD-TIME issue (parse failure, corrupt permission/sidecar
* file). Public within the repository layer: McpRegistry reports its parse
* errors here (wired through the facade) so they land in the same map as
* every other parser's. */
setIssue(path: string, message: string): void {
const issues = this.validationIssues.get(path) ?? [];
issues.push({ path, message });
this.validationIssues.set(path, issues);
}
/** Record a cross-entity reference issue. Lives in {@link referenceIssues}
* (recomputed wholesale by validateReferences), NOT in the load-time
* {@link validationIssues} map. */
private setReferenceIssue(path: string, message: string): void {
const issues = this.referenceIssues.get(path) ?? [];
issues.push({ path, message });
this.referenceIssues.set(path, issues);
}
private parseFile(path: string, content: string): ParsedEntity | null {
if (path.startsWith(`${this.deps.getSubfolder("agents")}/`)) {
return parseAgent(this.parserCtx, path, content);
}
if (path.startsWith(`${this.deps.getSubfolder("skills")}/`)) {
return parseSkill(this.parserCtx, path, content);
}
if (path.startsWith(`${this.deps.getSubfolder("tasks")}/`)) {
return parseTask(this.parserCtx, path, content);
}
return null;
}
/**
* Recompute every cross-entity reference issue from the in-memory maps.
* Pure in-memory pass (no vault I/O only the loaded maps plus the injected
* credential getter), so it is cheap enough to re-run after every mutation.
* Idempotent: {@link referenceIssues} is cleared and rebuilt wholesale each
* run; load-time corrupt-file issues live in the separate
* {@link validationIssues} map and are never touched here.
*
* Public within the repository layer: EntityMutations re-runs it after each
* delete once all map mutations are done.
*/
validateReferences(): void {
this.referenceIssues.clear();
const skillNames = new Set<string>();
for (const skill of this.skills.values()) {
if (skillNames.has(skill.name)) {
this.setReferenceIssue(skill.filePath, `Duplicate skill name \`${skill.name}\`.`);
}
skillNames.add(skill.name);
}
const agentNames = new Set<string>();
for (const agent of this.agents.values()) {
if (agentNames.has(agent.name)) {
this.setReferenceIssue(agent.filePath, `Duplicate agent name \`${agent.name}\`.`);
}
agentNames.add(agent.name);
for (const skillName of agent.skills) {
if (!skillNames.has(skillName)) {
this.setReferenceIssue(agent.filePath, `Agent references missing skill \`${skillName}\`.`);
}
}
}
for (const task of this.tasks.values()) {
if (!agentNames.has(task.agent)) {
this.setReferenceIssue(task.filePath, `Task references missing agent \`${task.agent}\`.`);
}
}
const channelNames = new Set<string>();
const agentsByName = new Map<string, AgentConfig>();
for (const agent of this.agents.values()) {
agentsByName.set(agent.name, agent);
}
const credentials = this.channelCredentialGetter?.() ?? this.settings.channelCredentials ?? {};
for (const channel of this.channels.values()) {
if (channelNames.has(channel.name)) {
this.setReferenceIssue(channel.filePath, `Duplicate channel name \`${channel.name}\`.`);
}
channelNames.add(channel.name);
const boundAgent = agentsByName.get(channel.defaultAgent);
if (!boundAgent) {
this.setReferenceIssue(
channel.filePath,
`Channel \`${channel.name}\` references missing agent \`${channel.defaultAgent}\`.`,
);
} else if (boundAgent.approvalRequired.length > 0) {
// Channels have no approval UI; a channel turn needing approval would deadlock.
// Block the binding at load time — the channel loads but is treated as disabled.
this.setReferenceIssue(
channel.filePath,
`Channel \`${channel.name}\` cannot bind to agent \`${boundAgent.name}\` because the agent has \`approval_required\` set (${boundAgent.approvalRequired.join(", ")}). Clear the approval list on the agent or pick a different agent.`,
);
}
const credential = credentials[channel.credentialRef];
if (!credential) {
this.setReferenceIssue(
channel.filePath,
`Channel \`${channel.name}\` references missing credential \`${channel.credentialRef}\`. Add it under Settings → Channel Credentials.`,
);
} else if (credential.type !== channel.type) {
this.setReferenceIssue(
channel.filePath,
`Channel \`${channel.name}\` is type \`${channel.type}\` but credential \`${channel.credentialRef}\` is type \`${credential.type}\`.`,
);
}
}
const mcpNames = new Set<string>();
for (const server of this.mcpServers.values()) {
if (mcpNames.has(server.name)) {
this.setReferenceIssue(server.filePath ?? server.name, `Duplicate MCP server name \`${server.name}\`.`);
}
mcpNames.add(server.name);
}
}
}

View file

@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { App } from "obsidian";
import type { ValidationIssue } from "../types";
import { McpRegistry } from "./mcpRegistry";
import { FakeVault, makeApp } from "./testSupport";
describe("McpRegistry", () => {
let vault: FakeVault;
let issues: ValidationIssue[];
let registry: McpRegistry;
beforeEach(() => {
vault = new FakeVault();
issues = [];
registry = new McpRegistry(makeApp(vault) as App, {
getMcpDir: () => "_fleet/mcp",
getServerByName: () => undefined,
reportIssue: (path, message) => issues.push({ path, message }),
});
});
describe("parseMcpServerFile", () => {
it("parses a valid stdio server with defaults", () => {
const server = registry.parseMcpServerFile(
"_fleet/mcp/files.md",
"---\nname: files\ntransport: stdio\ncommand: npx\n---\n\nLocal file server\n",
);
expect(server).not.toBeNull();
expect(server?.type).toBe("stdio");
expect(server?.enabled).toBe(true); // default
expect(server?.source).toBe("manual"); // default
expect(server?.description).toBe("Local file server"); // body fallback
expect(issues).toHaveLength(0);
});
it("accepts `type` as a transport alias", () => {
const server = registry.parseMcpServerFile(
"_fleet/mcp/api.md",
"---\nname: api\ntype: http\nurl: https://example.com/mcp\n---\n",
);
expect(server?.type).toBe("http");
expect(issues).toHaveLength(0);
});
it("rejects a missing/invalid transport and records an issue", () => {
const server = registry.parseMcpServerFile(
"_fleet/mcp/bad.md",
"---\nname: bad\ntransport: carrier-pigeon\n---\n",
);
expect(server).toBeNull();
expect(issues).toHaveLength(1);
expect(issues[0]?.path).toBe("_fleet/mcp/bad.md");
});
it("rejects stdio without command and http without url", () => {
expect(
registry.parseMcpServerFile("_fleet/mcp/a.md", "---\nname: a\ntransport: stdio\n---\n"),
).toBeNull();
expect(
registry.parseMcpServerFile("_fleet/mcp/b.md", "---\nname: b\ntransport: http\n---\n"),
).toBeNull();
expect(issues).toHaveLength(2);
});
});
describe("saveMcpServer", () => {
it("creates a new registry file with a de-duplicated slug path", async () => {
vault.addFile("_fleet/mcp/files.md", "---\nname: files\ntransport: stdio\ncommand: old\n---\n");
const path = await registry.saveMcpServer({
name: "files",
type: "stdio",
enabled: true,
source: "manual",
command: "npx",
args: [],
status: "disconnected",
scope: "user",
tools: [],
toolDetails: [],
});
expect(path).toBe("_fleet/mcp/files-2.md"); // existing file → suffixed
expect(vault.contents.get(path)).toContain("command: npx");
});
it("rewrites in place when filePath is set", async () => {
vault.addFile("_fleet/mcp/files.md", "---\nname: files\ntransport: stdio\ncommand: old\n---\n");
const path = await registry.saveMcpServer({
name: "files",
filePath: "_fleet/mcp/files.md",
type: "stdio",
enabled: false,
source: "manual",
command: "npx",
args: [],
status: "disconnected",
scope: "user",
tools: [],
toolDetails: [],
});
expect(path).toBe("_fleet/mcp/files.md");
expect(vault.contents.get(path)).toContain("enabled: false");
});
});
});

View file

@ -0,0 +1,170 @@
import { TFile } from "obsidian";
import type { App, Vault } from "obsidian";
import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter, slugify } from "../utils/markdown";
import type { McpServer } from "../types";
import { asBoolean, asString, asStringArray, asStringMap, ensureFolder, getAvailablePath, isRecord, trashPath } from "./shared";
/**
* MCP server registry: parse + persist `_fleet/mcp/<name>.md` files.
* Extracted verbatim from FleetRepository. The facade still owns the
* in-memory server map (load flow + snapshot); parse errors are reported
* through the injected `reportIssue` so they land in the same
* validation-issue map as every other parser.
*/
export class McpRegistry {
private readonly vault: Vault;
constructor(
private readonly app: App,
private readonly deps: {
getMcpDir: () => string;
getServerByName: (name: string) => McpServer | undefined;
reportIssue: (path: string, message: string) => void;
},
) {
this.vault = app.vault;
}
/**
* Parse one `_fleet/mcp/<name>.md` registry file. The frontmatter holds the
* non-secret server definition; the body is a human description. Secrets
* (bearer/OAuth tokens, secret env values) are NEVER read here they live in
* SecretStore and are resolved at projection time.
*/
parseMcpServerFile(path: string, content: string): McpServer | null {
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
const name = asString(frontmatter.name);
if (!name) {
this.deps.reportIssue(path, "MCP server requires string field `name`.");
return null;
}
// `transport` is the canonical frontmatter key; `type` is accepted as an
// alias (matches the native config vocabulary).
const rawTransport = (asString(frontmatter.transport) ?? asString(frontmatter.type) ?? "").toLowerCase();
const validTransports: readonly string[] = ["stdio", "http", "sse"];
if (!validTransports.includes(rawTransport)) {
this.deps.reportIssue(
path,
`MCP server \`${name}\` requires \`transport\` to be one of: ${validTransports.join(", ")}.`,
);
return null;
}
const type = rawTransport as McpServer["type"];
if (type === "stdio") {
if (!asString(frontmatter.command)) {
this.deps.reportIssue(path, `MCP server \`${name}\` (stdio) requires a \`command\`.`);
return null;
}
} else if (!asString(frontmatter.url)) {
this.deps.reportIssue(path, `MCP server \`${name}\` (${type}) requires a \`url\`.`);
return null;
}
const rawAuth = (asString(frontmatter.auth) ?? "").toLowerCase();
const auth: McpServer["auth"] =
rawAuth === "bearer" || rawAuth === "oauth" || rawAuth === "none" ? rawAuth : undefined;
let oauth: McpServer["oauth"];
if (isRecord(frontmatter.oauth)) {
const o = frontmatter.oauth;
oauth = {
clientId: asString(o.client_id) ?? asString(o.clientId),
resource: asString(o.resource),
scopes: asStringArray(o.scopes),
};
}
return {
name,
filePath: path,
type,
enabled: asBoolean(frontmatter.enabled, true),
description: asString(frontmatter.description) ?? (body.trim() || undefined),
source: asString(frontmatter.source) === "imported" ? "imported" : "manual",
command: asString(frontmatter.command),
args: asStringArray(frontmatter.args),
env: asStringMap(frontmatter.env),
envSecretKeys: asStringArray(frontmatter.env_secret_keys),
url: asString(frontmatter.url),
headers: asStringMap(frontmatter.headers),
auth,
oauth,
// Runtime-only fields — filled in by an on-demand probe, not persisted.
status: "disconnected",
scope: "user",
tools: [],
toolDetails: [],
};
}
/** Build `_fleet/mcp/<name>.md` frontmatter from a server definition,
* omitting empty/runtime fields. Secrets are never written here. */
private mcpServerFrontmatter(server: McpServer): Record<string, unknown> {
const fm: Record<string, unknown> = {
name: server.name,
transport: server.type,
enabled: server.enabled,
};
if (server.source) fm.source = server.source;
if (server.type === "stdio") {
if (server.command) fm.command = server.command;
if (server.args && server.args.length > 0) fm.args = server.args;
if (server.env && Object.keys(server.env).length > 0) fm.env = server.env;
if (server.envSecretKeys && server.envSecretKeys.length > 0) fm.env_secret_keys = server.envSecretKeys;
} else {
if (server.url) fm.url = server.url;
if (server.headers && Object.keys(server.headers).length > 0) fm.headers = server.headers;
if (server.auth) fm.auth = server.auth;
if (server.oauth && (server.oauth.clientId || server.oauth.resource || (server.oauth.scopes?.length ?? 0) > 0)) {
fm.oauth = {
client_id: server.oauth.clientId || undefined,
resource: server.oauth.resource || undefined,
scopes: server.oauth.scopes && server.oauth.scopes.length > 0 ? server.oauth.scopes : undefined,
};
}
}
return fm;
}
/**
* Create or update an MCP server registry file. When `server.filePath` is set
* the existing file is rewritten in place; otherwise a new
* `_fleet/mcp/<slug>.md` is created. Returns the file path. Secrets are NOT
* handled here callers store tokens/secret env values in SecretStore.
*/
async saveMcpServer(server: McpServer, body = ""): Promise<string> {
const fm = this.mcpServerFrontmatter(server);
const content = stringifyMarkdownWithFrontmatter(fm, body.trim());
const existing = server.filePath ? this.vault.getAbstractFileByPath(server.filePath) : null;
if (existing instanceof TFile) {
await this.vault.modify(existing, content);
return existing.path;
}
const path = await getAvailablePath(this.vault, this.deps.getMcpDir(), slugify(server.name));
await ensureFolder(this.vault, this.deps.getMcpDir());
await this.vault.create(path, content);
return path;
}
/** Flip an MCP server's `enabled` flag in its registry file. */
async setMcpServerEnabled(name: string, enabled: boolean): Promise<void> {
const server = this.deps.getServerByName(name);
if (!server?.filePath) return;
const file = this.vault.getAbstractFileByPath(server.filePath);
if (!(file instanceof TFile)) return;
const content = await this.vault.cachedRead(file);
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
frontmatter.enabled = enabled;
await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, body));
}
/** Trash an MCP server's registry file. */
async deleteMcpServer(name: string): Promise<void> {
const server = this.deps.getServerByName(name);
if (!server?.filePath) return;
await trashPath(this.app, server.filePath);
}
}

View file

@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MockInstance } from "vitest";
// memoryStore references FileSystemAdapter at runtime (instanceof); the shared
// obsidian stub lacks it — extend it here, mirroring fleetRepository.test.ts.
vi.mock("obsidian", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
App: class App {},
FileSystemAdapter: class FileSystemAdapter {},
};
});
import { MEMORY_SCHEMA_VERSION, emptyWorkingMemory } from "../utils/memoryFormat";
import { MemoryStore } from "./memoryStore";
import { FakeVault, makeApp } from "./testSupport";
const WORKING = "_fleet/memory/bot/working.md";
describe("MemoryStore working-memory schema guard", () => {
let vault: FakeVault;
let store: MemoryStore;
let warnSpy: MockInstance;
beforeEach(() => {
vault = new FakeVault();
store = new MemoryStore(makeApp(vault), () => "_fleet/memory");
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("parses a current-schema working.md normally", async () => {
vault.addFile(
WORKING,
`---\nagent: bot\nschema: ${MEMORY_SCHEMA_VERSION}\n---\n\n## Observations\n- knows things\n`,
);
const wm = await store.readWorkingMemory("bot");
expect(wm?.schema).toBe(MEMORY_SCHEMA_VERSION);
expect(wm?.sections[0]?.entries[0]?.text).toBe("knows things");
expect(warnSpy).not.toHaveBeenCalled();
});
it("treats a missing schema field as current (pre-schema files keep working)", async () => {
vault.addFile(WORKING, "---\nagent: bot\n---\n\n## Observations\n- old fact\n");
const wm = await store.readWorkingMemory("bot");
expect(wm?.schema).toBe(MEMORY_SCHEMA_VERSION);
expect(wm?.sections[0]?.entries[0]?.text).toBe("old fact");
expect(warnSpy).not.toHaveBeenCalled();
});
it("keeps parsing an OLDER-but-known numeric schema (current behavior)", async () => {
vault.addFile(WORKING, "---\nagent: bot\nschema: 1\n---\n\n## Observations\n- v1 fact\n");
const wm = await store.readWorkingMemory("bot");
expect(wm?.schema).toBe(1);
expect(wm?.sections[0]?.entries[0]?.text).toBe("v1 fact");
expect(warnSpy).not.toHaveBeenCalled();
});
it("warns and returns null on a NEWER schema instead of misparsing it", async () => {
vault.addFile(
WORKING,
`---\nagent: bot\nschema: ${MEMORY_SCHEMA_VERSION + 1}\n---\n\n## Observations\n- future fact\n`,
);
expect(await store.readWorkingMemory("bot")).toBeNull();
expect(warnSpy).toHaveBeenCalled();
// getMemory (the back-compat shim) inherits the guard.
expect(await store.getMemory("bot")).toBeNull();
});
it("warns and returns null on a non-numeric schema marker", async () => {
vault.addFile(WORKING, "---\nagent: bot\nschema: v9-beta\n---\n\n## Observations\n- weird\n");
expect(await store.readWorkingMemory("bot")).toBeNull();
expect(warnSpy).toHaveBeenCalled();
});
it("refuses to overwrite a newer-schema working.md (never destroys data)", async () => {
const newer =
`---\nagent: bot\nschema: ${MEMORY_SCHEMA_VERSION + 1}\n---\n\n## Observations\n- future fact\n`;
vault.addFile(WORKING, newer);
// Simulate the capture path after the read guard fired: read → null →
// rebuild from scratch → write. The write must be a warned no-op.
const rebuilt = emptyWorkingMemory(WORKING, "bot");
await store.writeWorkingMemory("bot", rebuilt);
expect(vault.contents.get(WORKING)).toBe(newer);
expect(warnSpy).toHaveBeenCalled();
});
it("still writes normally over a current-schema file", async () => {
vault.addFile(
WORKING,
`---\nagent: bot\nschema: ${MEMORY_SCHEMA_VERSION}\n---\n\n## Observations\n- old\n`,
);
const wm = await store.readWorkingMemory("bot");
expect(wm).not.toBeNull();
await store.writeWorkingMemory("bot", {
...wm!,
sections: [{ name: "Observations", entries: [{ text: "new fact", pinned: false }] }],
});
expect(vault.contents.get(WORKING)).toContain("new fact");
expect(warnSpy).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,367 @@
import { join } from "path";
import { FileSystemAdapter, TFile, TFolder, normalizePath } from "obsidian";
import type { App, Vault } from "obsidian";
import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter, slugify } from "../utils/markdown";
import {
MEMORY_SCHEMA_VERSION,
migrateLegacyBody,
parseWorkingMemory,
renderSections,
serializeWorkingMemory,
} from "../utils/memoryFormat";
import type { MemoryFile, SkillCandidate, WorkingMemory } from "../types";
import { createFileIfMissing, ensureFolder, trashPath } from "./shared";
/**
* Agent memory store: v2 folder layout (working.md, raw/ archive, pending/
* capture inbox, candidates.json), the legacy flat-file layout, and the
* legacyv2 migration including the in-flight migration promise map.
* Extracted verbatim from FleetRepository.
*/
export class MemoryStore {
/** In-flight legacyv2 memory migrations keyed by agent name, so the
* unlocked reflection path and the lock-held capture path share a single
* migration: concurrent callers await the same promise instead of either
* double-seeding raw or returning before the migration has finished. */
private migratingMemory = new Map<string, Promise<void>>();
private readonly vault: Vault;
constructor(
private readonly app: App,
/** `_fleet/memory` (lazy — the fleet folder is a live setting). */
private readonly getMemoryRoot: () => string,
) {
this.vault = app.vault;
}
/** @deprecated Legacy flat memory file path. New layout uses
* {@link getWorkingMemoryPath}. Retained for migration + back-compat. */
getMemoryPath(agentName: string): string {
return normalizePath(`${this.getMemoryRoot()}/${slugify(agentName)}.md`);
}
/** Folder holding an agent's v2 memory: working.md, candidates.md, raw/. */
getMemoryDir(agentName: string): string {
return normalizePath(`${this.getMemoryRoot()}/${slugify(agentName)}`);
}
/** Path to the curated, injected working-memory file (§6.2). */
getWorkingMemoryPath(agentName: string): string {
return normalizePath(`${this.getMemoryDir(agentName)}/working.md`);
}
/** Path to a day's append-only raw archive (§6.3). */
getRawMemoryPath(agentName: string, dateIso: string): string {
const day = dateIso.slice(0, 10); // YYYY-MM-DD
return normalizePath(`${this.getMemoryDir(agentName)}/raw/${day}.md`);
}
/**
* Read an agent's working memory as the structured v2 object. Resolves either
* layout: the v2 `working.md` if present, otherwise the legacy flat file
* migrated in-memory (the first write persists the folder layout). Returns
* null when the agent has no memory yet.
*/
async readWorkingMemory(agentName: string): Promise<WorkingMemory | null> {
const workingPath = this.getWorkingMemoryPath(agentName);
const workingFile = this.vault.getAbstractFileByPath(workingPath);
if (workingFile instanceof TFile) {
const content = await this.vault.cachedRead(workingFile);
// Schema guard: a working.md written by a NEWER plugin version (or with a
// mangled `schema` field) must not be parsed as if it were the current
// format — fields could be silently misread. Fail soft the way the other
// corrupt-file reads here do (warn + treat as absent); the matching guard
// in writeWorkingMemory keeps the file from being clobbered afterwards.
if (this.hasIncompatibleSchema(content, workingPath)) return null;
return parseWorkingMemory(content, workingPath, agentName);
}
const legacyPath = this.getMemoryPath(agentName);
const legacyFile = this.vault.getAbstractFileByPath(legacyPath);
if (legacyFile instanceof TFile) {
const content = await this.vault.cachedRead(legacyFile);
const { body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
return migrateLegacyBody(body, workingPath, agentName);
}
return null;
}
/**
* Persist working memory to `working.md`, creating the folder layout and
* trashing the legacy flat file if one still exists (completes migration).
*/
async writeWorkingMemory(agentName: string, wm: WorkingMemory): Promise<void> {
const workingPath = this.getWorkingMemoryPath(agentName);
await ensureFolder(this.vault, this.getMemoryDir(agentName));
const content = serializeWorkingMemory(wm);
const existing = this.vault.getAbstractFileByPath(workingPath);
if (existing instanceof TFile) {
// Write-side schema guard (pairs with readWorkingMemory): if the on-disk
// file carries a newer/unknown schema, readWorkingMemory returned null and
// `wm` was rebuilt from scratch — writing it would DESTROY the newer
// file's data. Skip the write instead; captures still land in the
// append-only raw archive, so no ground truth is lost.
if (this.hasIncompatibleSchema(await this.vault.cachedRead(existing), workingPath)) return;
await this.vault.modify(existing, content);
} else {
await createFileIfMissing(this.vault, workingPath, content);
}
// Complete migration: retire the legacy flat file once v2 exists.
const legacyPath = this.getMemoryPath(agentName);
if (legacyPath !== workingPath && this.vault.getAbstractFileByPath(legacyPath)) {
await trashPath(this.app, legacyPath);
}
}
/**
* True when the file's frontmatter `schema` is unrecognized: newer than this
* plugin's {@link MEMORY_SCHEMA_VERSION} or not a finite number at all. A
* MISSING field stays compatible pre-schema files have always parsed as the
* current version (parseWorkingMemory defaults it), and changing that would
* orphan them. An older-but-known numeric schema also stays compatible
* (current behavior: parse it; the value is preserved on write). Warns once
* per call so both the read and write guards are loud in the console.
*/
private hasIncompatibleSchema(content: string, path: string): boolean {
const { frontmatter } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
const raw = frontmatter.schema;
if (raw === undefined) return false;
if (typeof raw === "number" && Number.isFinite(raw) && raw <= MEMORY_SCHEMA_VERSION) return false;
console.warn(
`Agent Fleet: ${path} has unrecognized memory schema ${JSON.stringify(raw)} ` +
`(this plugin supports up to ${MEMORY_SCHEMA_VERSION}) — treating working memory as ` +
`unavailable and leaving the file untouched. Update the plugin to use this memory.`,
);
return true;
}
/**
* One-time migration of an agent's legacy flat memory file to the v2 folder
* layout. Crucially, the legacy content is SEEDED INTO THE RAW ARCHIVE first,
* so "ground truth is never destroyed" (§ design overview, §13.1) holds even
* if the first reflection later summarizes the working copy. Without this, a
* reflection running before any capture could permanently drop pre-v2 memory.
* No-op when already migrated (working.md exists) or there is no legacy file.
*/
async migrateLegacyMemory(agentName: string): Promise<void> {
// A concurrent in-flight migration of the same agent (the reflection path
// calls this unlocked while a capture may run it under the MemoryWriter
// lock) is awaited rather than skipped — an early return would let the
// second caller proceed before the migration had actually completed.
const inFlight = this.migratingMemory.get(agentName);
if (inFlight) return inFlight;
const workingPath = this.getWorkingMemoryPath(agentName);
if (this.vault.getAbstractFileByPath(workingPath)) return; // already v2
const legacyPath = this.getMemoryPath(agentName);
const legacyFile = this.vault.getAbstractFileByPath(legacyPath);
if (!(legacyFile instanceof TFile)) return;
const migration = (async () => {
const content = await this.vault.cachedRead(legacyFile);
const { body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
const nowIso = new Date().toISOString();
// 1. Seed the raw archive with the legacy content (permanent ground truth).
const seedLines = body
.split("\n")
.map((l) => l.replace(/^\s*[-*]\s+/, "").trim())
.filter((l) => l.length > 0 && !/^#{1,6}\s/.test(l));
const rawLines = [
`- ${nowIso} [migrated] Imported ${seedLines.length} legacy memory ` +
`entr${seedLines.length === 1 ? "y" : "ies"} (pre-v2).`,
...seedLines.map((t) => `- ${nowIso} [migrated] ${t}`),
];
await this.appendRawMemory(agentName, rawLines, nowIso);
// 2. Write the v2 working memory; writeWorkingMemory trashes the legacy file.
const wm = migrateLegacyBody(body, workingPath, agentName, nowIso);
await this.writeWorkingMemory(agentName, wm);
})();
this.migratingMemory.set(agentName, migration);
try {
await migration;
} finally {
this.migratingMemory.delete(agentName);
}
}
/** Vault-relative path to the agent's MCP-tool capture inbox directory (§7.5).
* Each capture is written as its own JSON file inside it. */
getPendingDir(agentName: string): string {
return normalizePath(`${this.getMemoryDir(agentName)}/pending`);
}
/** Absolute on-disk path to the pending inbox dir, for the MCP server's env.
* Null when the vault is not a local filesystem (e.g. mobile). */
getPendingDirAbsolutePath(agentName: string): string | null {
const adapter = this.vault.adapter;
if (!(adapter instanceof FileSystemAdapter)) return null;
return join(adapter.getBasePath(), this.getPendingDir(agentName));
}
/** Ensure the agent's memory folder exists. */
async ensureMemoryDir(agentName: string): Promise<void> {
await ensureFolder(this.vault, this.getMemoryDir(agentName));
}
/**
* Drain the MCP-tool capture inbox: read every JSON file the external server
* wrote, delete each one, and return their lines. Goes through the raw vault
* ADAPTER (not the TFile cache) because those files are created out-of-process
* and may not be in Obsidian's metadata index yet. Reading + deleting whole
* files means we never truncate a file mid-append, so a capture landing during
* a drain is just picked up by the next drain no read-then-clear loss.
* Caller serializes via the MemoryWriter lock.
*/
async readAndClearPending(agentName: string): Promise<string[]> {
const adapter = this.vault.adapter;
const dir = this.getPendingDir(agentName);
let files: string[];
try {
if (!(await adapter.exists(dir))) return [];
files = (await adapter.list(dir)).files;
} catch {
return [];
}
const lines: string[] = [];
for (const filePath of files) {
if (!filePath.endsWith(".json")) continue; // ignore .tmp partials
try {
const content = await adapter.read(filePath);
// Remove BEFORE folding the lines in: if remove() fails we skip this
// file (retried next drain) rather than folding it now and re-folding
// the same capture next time — which would duplicate raw-archive lines.
await adapter.remove(filePath);
for (const l of content.split("\n")) if (l.trim().length > 0) lines.push(l);
} catch (err) {
// skip an unreadable / not-yet-removable file (picked up next drain)
console.warn(`Agent Fleet: skipping pending capture file ${filePath} (retried next drain)`, err);
}
}
return lines;
}
/** Read the recent raw archive (last `days` daily files) for reflection. */
async readRecentRaw(agentName: string, days = 2): Promise<string> {
const dir = normalizePath(`${this.getMemoryDir(agentName)}/raw`);
const folder = this.vault.getAbstractFileByPath(dir);
if (!(folder instanceof TFolder)) return "";
const files = folder.children
.filter((c): c is TFile => c instanceof TFile && c.extension === "md")
.sort((a, b) => b.name.localeCompare(a.name))
.slice(0, days);
const parts: string[] = [];
for (const f of files.reverse()) {
parts.push(`### ${f.basename}\n${await this.vault.cachedRead(f)}`);
}
return parts.join("\n\n");
}
/** Path to the agent's skill-candidate ledger. */
getCandidatesPath(agentName: string): string {
return normalizePath(`${this.getMemoryDir(agentName)}/candidates.json`);
}
async readCandidates(agentName: string): Promise<SkillCandidate[]> {
const path = this.getCandidatesPath(agentName);
const file = this.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return [];
try {
const parsed = JSON.parse(await this.vault.cachedRead(file)) as unknown;
return Array.isArray(parsed) ? (parsed as SkillCandidate[]) : [];
} catch (err) {
console.warn(`Agent Fleet: invalid JSON in ${path} — treating skill-candidate ledger as empty`, err);
return [];
}
}
async writeCandidates(agentName: string, candidates: SkillCandidate[]): Promise<void> {
const path = this.getCandidatesPath(agentName);
await ensureFolder(this.vault, this.getMemoryDir(agentName));
const content = JSON.stringify(candidates, null, 2);
const file = this.vault.getAbstractFileByPath(path);
if (file instanceof TFile) await this.vault.modify(file, content);
else await createFileIfMissing(this.vault, path, content);
}
/** Append timestamped lines to the day's raw archive (§6.3). Ground truth
* append-only, never injected. Caller serializes via the MemoryWriter lock. */
async appendRawMemory(agentName: string, lines: string[], dateIso: string): Promise<void> {
if (lines.length === 0) return;
const path = this.getRawMemoryPath(agentName, dateIso);
await ensureFolder(this.vault, path.replace(/\/[^/]+$/, ""));
const block = lines.map((l) => l.trimEnd()).join("\n");
const file = this.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
const existing = await this.vault.cachedRead(file);
await this.vault.modify(file, `${existing.trimEnd()}\n${block}\n`);
} else {
await createFileIfMissing(this.vault, path, `${block}\n`);
}
}
/**
* @deprecated Back-compat shim. Returns a flat {@link MemoryFile} view,
* preferring the v2 working memory (rendered body) and falling back to the
* legacy flat file. New code should use {@link readWorkingMemory}.
*/
async getMemory(agentName: string): Promise<MemoryFile | null> {
const wm = await this.readWorkingMemory(agentName);
if (wm) {
return {
filePath: wm.filePath,
agent: wm.agent,
lastUpdated: wm.lastUpdated,
body: renderSections(wm.sections),
};
}
return null;
}
/**
* @deprecated Dead code no remaining callers. Memory capture now flows
* through MemoryWriter (per-agent FIFO lock) into the v2 layout. This method
* does an UNSERIALIZED read-modify-write of the legacy flat memory file, so
* concurrent calls can lose appends; do not resurrect it without routing it
* through MemoryWriter's lock.
*/
async appendMemory(agentName: string, entries: string[]): Promise<void> {
if (entries.length === 0) {
return;
}
const path = this.getMemoryPath(agentName);
const file = this.vault.getAbstractFileByPath(path);
const timestamp = new Date().toISOString();
const entryBlock = entries.map((entry) => `- ${entry.trim()}`).join("\n");
if (file instanceof TFile) {
const existing = await this.getMemory(agentName);
const body = `${existing?.body.trim() || "## Learned Context"}\n\n${entryBlock}`.trim();
await this.vault.modify(
file,
stringifyMarkdownWithFrontmatter(
{
agent: agentName,
last_updated: timestamp,
},
body,
),
);
return;
}
await createFileIfMissing(this.vault,
path,
stringifyMarkdownWithFrontmatter(
{
agent: agentName,
last_updated: timestamp,
},
`## Learned Context\n\n${entryBlock}`,
),
);
}
}

View file

@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { SkillConfig, SkillProposal } from "../types";
import { ProposalStore } from "./proposalStore";
import { FakeVault, makeApp } from "./testSupport";
function proposal(overrides: Partial<SkillProposal>): SkillProposal {
return {
id: "p1",
type: "skill_create",
agent: "bot",
status: "pending",
created: "2026-07-01T00:00:00Z",
evidence: [],
rationale: "recurring pattern",
body: "Do the thing.",
...overrides,
};
}
describe("ProposalStore", () => {
let vault: FakeVault;
let skills: Map<string, SkillConfig>;
let store: ProposalStore;
beforeEach(() => {
vault = new FakeVault();
skills = new Map();
store = new ProposalStore(makeApp(vault), {
getFleetRoot: () => "_fleet",
getSkillsDir: () => "_fleet/skills",
getSkillByName: (name) => skills.get(name),
});
});
it("write → read → setStatus round-trips through the proposal file", async () => {
await store.writeProposal(proposal({ id: "p1" }));
expect(vault.files.has("_fleet/proposals/p1.md")).toBe(true);
const read = await store.readProposal("p1");
expect(read?.status).toBe("pending");
expect(read?.agent).toBe("bot");
await store.setProposalStatus("p1", "accepted");
expect((await store.readProposal("p1"))?.status).toBe("accepted");
});
it("applyProposal (skill_create) uses the de-duplicated filename as the skill name", async () => {
vault.addFile("_fleet/skills/my-skill.md", "---\nname: my-skill\n---\n\nExisting\n");
const path = await store.applyProposal(proposal({ targetSkill: "My Skill" }));
expect(path).toBe("_fleet/skills/my-skill-2.md");
// Frontmatter name matches the de-duplicated file name, not the original.
expect(vault.contents.get(path ?? "")).toContain("name: my-skill-2");
});
it("applyProposal (skill_modify) appends an addendum to the target skill", async () => {
const file = vault.addFile("_fleet/skills/target.md", "---\nname: target\n---\n\nOriginal body\n");
skills.set("target", {
filePath: file.path,
name: "target",
tags: [],
body: "Original body",
toolsBody: "",
referencesBody: "",
examplesBody: "",
isFolder: false,
});
const path = await store.applyProposal(
proposal({ type: "skill_modify", targetSkill: "target", body: "New guidance." }),
);
expect(path).toBe("_fleet/skills/target.md");
const content = vault.contents.get("_fleet/skills/target.md") ?? "";
expect(content).toContain("Original body");
expect(content).toContain("## Proposed update");
expect(content).toContain("New guidance.");
});
it("applyProposal (skill_modify) is a no-op when the target skill is missing", async () => {
expect(await store.applyProposal(proposal({ type: "skill_modify", targetSkill: "ghost" }))).toBeNull();
});
it("deleteProposal trashes the file; listProposals sorts newest first", async () => {
await store.writeProposal(proposal({ id: "old", created: "2026-01-01T00:00:00Z" }));
await store.writeProposal(proposal({ id: "new", created: "2026-07-01T00:00:00Z" }));
const list = await store.listProposals();
expect(list.map((p) => p.id)).toEqual(["new", "old"]);
await store.deleteProposal("old");
expect(await store.readProposal("old")).toBeNull();
expect((await store.listProposals()).map((p) => p.id)).toEqual(["new"]);
});
});

View file

@ -0,0 +1,136 @@
import { TFile, TFolder, normalizePath } from "obsidian";
import type { App, Vault } from "obsidian";
import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter, slugify } from "../utils/markdown";
import type { SkillConfig, SkillProposal } from "../types";
import { asString, asStringArray, createFileIfMissing, ensureFolder, getAvailablePath, trashPath } from "./shared";
/**
* Skill proposals (memory skills, §9): `_fleet/proposals/<id>.md` files.
* Extracted verbatim from FleetRepository. `applyProposal` needs the live
* skill index and skills directory, injected as callbacks so the store never
* holds a stale snapshot.
*/
export class ProposalStore {
private readonly vault: Vault;
constructor(
private readonly app: App,
private readonly deps: {
getFleetRoot: () => string;
getSkillsDir: () => string;
getSkillByName: (name: string) => SkillConfig | undefined;
},
) {
this.vault = app.vault;
}
getProposalsDir(): string {
return normalizePath(`${this.deps.getFleetRoot()}/proposals`);
}
/** List all proposals, newest first. */
async listProposals(): Promise<SkillProposal[]> {
const folder = this.vault.getAbstractFileByPath(this.getProposalsDir());
if (!(folder instanceof TFolder)) return [];
const out: SkillProposal[] = [];
for (const child of folder.children) {
if (!(child instanceof TFile) || child.extension !== "md") continue;
const p = await this.readProposal(child.basename);
if (p) out.push(p);
}
out.sort((a, b) => b.created.localeCompare(a.created));
return out;
}
async readProposal(id: string): Promise<SkillProposal | null> {
const path = normalizePath(`${this.getProposalsDir()}/${id}.md`);
const file = this.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return null;
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(
await this.vault.cachedRead(file),
);
const type = frontmatter.type === "skill_modify" ? "skill_modify" : "skill_create";
const status =
frontmatter.status === "accepted" || frontmatter.status === "rejected"
? frontmatter.status
: "pending";
return {
id,
type,
agent: asString(frontmatter.agent) ?? "",
status,
created: asString(frontmatter.created) ?? "",
targetSkill: asString(frontmatter.target_skill),
candidate: asString(frontmatter.candidate),
evidence: asStringArray(frontmatter.evidence),
rationale: asString(frontmatter.rationale) ?? "",
body,
};
}
async writeProposal(p: SkillProposal): Promise<void> {
await ensureFolder(this.vault, this.getProposalsDir());
const path = normalizePath(`${this.getProposalsDir()}/${p.id}.md`);
const fm: Record<string, unknown> = {
id: p.id,
type: p.type,
agent: p.agent,
status: p.status,
created: p.created,
target_skill: p.targetSkill || undefined,
candidate: p.candidate || undefined,
evidence: p.evidence.length ? p.evidence : undefined,
rationale: p.rationale || undefined,
};
const content = stringifyMarkdownWithFrontmatter(fm, p.body || "");
const file = this.vault.getAbstractFileByPath(path);
if (file instanceof TFile) await this.vault.modify(file, content);
else await createFileIfMissing(this.vault, path, content);
}
async setProposalStatus(id: string, status: SkillProposal["status"]): Promise<void> {
const p = await this.readProposal(id);
if (!p) return;
p.status = status;
await this.writeProposal(p);
}
/** Apply an accepted proposal: create the new skill (skill_create) or append
* the proposed change to the target skill (skill_modify). Returns the path of
* the affected skill, or null on no-op. */
async applyProposal(p: SkillProposal): Promise<string | null> {
if (p.type === "skill_create") {
const name = p.targetSkill || `learned-${slugify(p.rationale).slice(0, 24) || "skill"}`;
const path = await getAvailablePath(this.vault, this.deps.getSkillsDir(), slugify(name));
// Use the actual (de-duplicated) filename as the skill name so two
// proposals for the same pattern can't mint colliding skill names —
// getAvailablePath only de-dupes the file path, not the frontmatter name.
const finalName = path.split("/").pop()?.replace(/\.md$/, "") || slugify(name);
const fm = {
name: finalName,
description: p.rationale || `Auto-proposed from recurring pattern for ${p.agent}.`,
tags: ["proposed"],
};
await this.vault.create(path, stringifyMarkdownWithFrontmatter(fm, p.body || "Skill instructions go here."));
return path;
}
// skill_modify: append the proposed change to the target skill as an addendum.
if (p.targetSkill) {
const skill = this.deps.getSkillByName(p.targetSkill);
if (skill) {
const file = this.vault.getAbstractFileByPath(skill.filePath);
if (file instanceof TFile) {
const existing = await this.vault.cachedRead(file);
const addendum = `\n\n## Proposed update (${new Date().toISOString().slice(0, 10)})\n${p.body}`;
await this.vault.modify(file, `${existing.trimEnd()}${addendum}\n`);
return skill.filePath;
}
}
}
return null;
}
async deleteProposal(id: string): Promise<void> {
await trashPath(this.app, normalizePath(`${this.getProposalsDir()}/${id}.md`));
}
}

View file

@ -0,0 +1,245 @@
import { TFile, TFolder, normalizePath } from "obsidian";
import type { App, Vault } from "obsidian";
import { DEFAULT_SETTINGS } from "../constants";
import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter, slugify } from "../utils/markdown";
import { splitLines } from "../utils/platform";
import type { RunLogData } from "../types";
import { asNumber, asString, asStringArray, collectMarkdownChildren, ensureFolder, isRecord } from "./shared";
/**
* Run-log store: reads/writes `_fleet/runs/YYYY-MM-DD/*.md` files and owns
* the parsed-run cache. Extracted verbatim from FleetRepository the facade
* delegates its public run-log methods here.
*/
export class RunLogStore {
/** Parsed run-log cache keyed by file path, invalidated on mtime/size
* mismatch. `vault.cachedRead` already avoids disk I/O, but re-parsing
* every run markdown on each dashboard refresh was still O(runs). Cached
* objects are shared callers must not mutate them (none do today). */
private runLogCache = new Map<string, { mtime: number; size: number; run: RunLogData }>();
private readonly vault: Vault;
constructor(
app: App,
private readonly getRunsRoot: () => string,
) {
this.vault = app.vault;
}
async listRecentRuns(limit = 50): Promise<RunLogData[]> {
const runsFolder = this.vault.getAbstractFileByPath(this.getRunsRoot());
if (!(runsFolder instanceof TFolder)) {
return [];
}
const files: TFile[] = [];
collectMarkdownChildren(runsFolder, files);
files.sort((a, b) => b.path.localeCompare(a.path));
const selected = files.slice(0, limit);
const parsed: RunLogData[] = [];
for (const file of selected) {
const run = await this.readRunLog(file);
if (run) {
parsed.push(run);
}
}
return parsed.sort((a, b) => b.started.localeCompare(a.started));
}
/** Return all runs whose date folder is on or after `sinceDate`. Used by
* the dashboard chart so a busy fleet can't push older days out of the
* visible window the way the count-capped `listRecentRuns` does.
* Walks only the relevant date subfolders, so cost is bounded by the
* window size, not the total number of historical runs. */
async listRunsSince(sinceDate: Date): Promise<RunLogData[]> {
const runsFolder = this.vault.getAbstractFileByPath(this.getRunsRoot());
if (!(runsFolder instanceof TFolder)) {
return [];
}
const sinceStr = `${sinceDate.getFullYear()}-${String(sinceDate.getMonth() + 1).padStart(2, "0")}-${String(sinceDate.getDate()).padStart(2, "0")}`;
const files: TFile[] = [];
for (const child of runsFolder.children) {
if (!(child instanceof TFolder)) continue;
// Date folders are named `YYYY-MM-DD`; lexicographic >= matches calendar >=.
if (child.name < sinceStr) continue;
collectMarkdownChildren(child, files);
}
const parsed: RunLogData[] = [];
for (const file of files) {
const run = await this.readRunLog(file);
if (run) {
parsed.push(run);
}
}
return parsed.sort((a, b) => b.started.localeCompare(a.started));
}
async readRunLog(file: TFile): Promise<RunLogData | null> {
const cached = this.runLogCache.get(file.path);
if (cached && cached.mtime === file.stat.mtime && cached.size === file.stat.size) {
return cached.run;
}
const content = await this.vault.cachedRead(file);
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
// `## Prompt` terminates at either `## Result` (new format) or
// `## Output` (legacy format). `## Result` is optional and missing on
// runs created before finalResult was extracted — those runs fall back
// to rendering `output` in the panel.
const promptMatch = body.match(/## Prompt\n([\s\S]*?)(?:\n## Result\n|\n## Output\n|$)/);
const resultMatch = body.match(/## Result\n([\s\S]*?)(?:\n## Output\n|$)/);
const outputMatch = body.match(/## Output\n([\s\S]*?)(?:\n## Tools Used\n|$)/);
const toolsMatch = body.match(/## Tools Used\n([\s\S]*?)(?:\n## STDERR\n|$)/);
const run: RunLogData = {
filePath: file.path,
runId: asString(frontmatter.run_id) ?? file.basename,
agent: asString(frontmatter.agent) ?? "unknown",
task: asString(frontmatter.task) ?? "unknown",
status: (asString(frontmatter.status) as RunLogData["status"]) ?? "failure",
started: asString(frontmatter.started) ?? new Date(file.stat.ctime).toISOString(),
completed: asString(frontmatter.completed),
durationSeconds: asNumber(frontmatter.duration_seconds, 0),
tokensUsed: typeof frontmatter.tokens_used === "number" ? frontmatter.tokens_used : undefined,
costUsd: typeof frontmatter.cost_usd === "number" ? frontmatter.cost_usd : undefined,
model: asString(frontmatter.model) ?? DEFAULT_SETTINGS.defaultModel,
modelSource: ((): RunLogData["modelSource"] => {
const raw = asString(frontmatter.model_source);
if (raw === "task" || raw === "agent" || raw === "settings" || raw === "cli-default") return raw;
return undefined;
})(),
concreteModel: asString(frontmatter.resolved_concrete_model),
exitCode: typeof frontmatter.exit_code === "number" ? frontmatter.exit_code : null,
tags: asStringArray(frontmatter.tags),
prompt: promptMatch?.[1]?.trim() ?? "",
output: outputMatch?.[1]?.trim() ?? "",
finalResult: resultMatch?.[1]?.trim() || undefined,
toolsUsed: toolsMatch?.[1]
? splitLines(toolsMatch[1])
.map((line) => line.replace(/^- /, "").trim())
.filter(Boolean)
: [],
approvals: this.parseApprovals(frontmatter.approvals),
};
// Cheap unbounded-growth guard; a full reset just costs one re-parse pass.
if (this.runLogCache.size >= 5000) this.runLogCache.clear();
this.runLogCache.set(file.path, { mtime: file.stat.mtime, size: file.stat.size, run });
return run;
}
async writeRunLog(run: RunLogData): Promise<string> {
const started = new Date(run.started);
const dateFolder = normalizePath(`${this.getRunsRoot()}/${started.toISOString().slice(0, 10)}`);
await ensureFolder(this.vault, dateFolder);
const filename = `${started.toISOString().slice(11, 19).replace(/:/g, "")}-${slugify(run.agent)}-${slugify(run.task)}.md`;
const path = normalizePath(`${dateFolder}/${filename}`);
const content = stringifyMarkdownWithFrontmatter(
{
run_id: run.runId,
agent: run.agent,
task: run.task,
status: run.status,
started: run.started,
completed: run.completed,
duration_seconds: run.durationSeconds,
tokens_used: run.tokensUsed,
cost_usd: run.costUsd,
model: run.model,
model_source: run.modelSource,
resolved_concrete_model: run.concreteModel,
exit_code: run.exitCode,
tags: run.tags,
approvals: run.approvals,
},
[
"## Prompt",
"",
run.prompt.trim(),
"",
// `## Result` carries the final answer without narration, from the
// CLI's `type: "result"` event. Omitted when absent so legacy-format
// run files stay identical.
...(run.finalResult && run.finalResult.trim()
? ["## Result", "", run.finalResult.trim(), ""]
: []),
"## Output",
"",
run.output.trim() || "(no output)",
"",
"## Tools Used",
"",
...(run.toolsUsed.length > 0 ? run.toolsUsed.map((tool) => `- ${tool}`) : ["- none"]),
...(run.stderr ? ["", "## STDERR", "", run.stderr.trim()] : []),
].join("\n"),
);
const existing = this.vault.getAbstractFileByPath(path);
if (existing instanceof TFile) {
await this.vault.modify(existing, content);
} else {
await this.vault.create(path, content);
}
return path;
}
async setApprovalDecision(runPath: string, tool: string, decision: "approved" | "rejected"): Promise<void> {
const file = this.vault.getAbstractFileByPath(runPath);
if (!(file instanceof TFile)) {
return;
}
const content = await this.vault.cachedRead(file);
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
const approvals = (this.parseApprovals(frontmatter.approvals) ?? []).map((approval) =>
approval.tool === tool
? {
...approval,
status: decision,
resolvedAt: new Date().toISOString(),
}
: approval,
);
await this.vault.modify(
file,
stringifyMarkdownWithFrontmatter(
{
...frontmatter,
approvals,
},
body,
),
);
}
private parseApprovals(value: unknown): RunLogData["approvals"] {
if (!Array.isArray(value)) {
return undefined;
}
return value.flatMap((item) => {
if (!isRecord(item) || !asString(item.tool)) {
return [];
}
const tool = asString(item.tool);
if (!tool) {
return [];
}
return [
{
tool,
command: asString(item.command),
reason: asString(item.reason),
status: (asString(item.status) as "pending" | "approved" | "rejected") ?? "pending",
resolvedAt: asString(item.resolvedAt),
note: asString(item.note),
},
];
});
}
}

108
src/repository/shared.ts Normal file
View file

@ -0,0 +1,108 @@
import { TFile, TFolder, normalizePath } from "obsidian";
import type { App, Vault } from "obsidian";
// ═══════════════════════════════════════════════════════
// Frontmatter coercion helpers — shared by the FleetRepository facade's
// entity parsers and the extracted stores. All are tolerant: bad shapes
// coerce to a safe fallback instead of throwing.
// ═══════════════════════════════════════════════════════
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export function asBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
export function asNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
export function asStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
/** Coerce a frontmatter value into a `Record<string,string>`, dropping any
* non-string values. Returns undefined when there's nothing usable so callers
* can omit the field entirely. */
export function asStringMap(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) return undefined;
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(value)) {
if (typeof v === "string") out[k] = v;
}
return Object.keys(out).length > 0 ? out : undefined;
}
// ═══════════════════════════════════════════════════════
// Vault helpers — tolerant create/trash/list primitives shared by the
// facade and the stores.
// ═══════════════════════════════════════════════════════
/** Create a folder if it doesn't exist; tolerate a concurrent create. */
export async function ensureFolder(vault: Vault, path: string): Promise<void> {
if (vault.getAbstractFileByPath(path)) {
return;
}
try {
await vault.createFolder(path);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("Folder already exists")) {
throw error;
}
}
}
/** Create a file only when absent; tolerate a concurrent create. */
export async function createFileIfMissing(vault: Vault, path: string, content: string): Promise<void> {
if (vault.getAbstractFileByPath(path)) {
return;
}
try {
await vault.create(path, content);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("File already exists")) {
throw error;
}
}
}
/** Trash the file/folder at `path` if it exists (no-op otherwise). */
export async function trashPath(app: App, path: string): Promise<void> {
const file = app.vault.getAbstractFileByPath(path);
if (file) {
await app.fileManager.trashFile(file);
}
}
/** Recursively collect every markdown TFile under `folder` into `acc`. */
export function collectMarkdownChildren(folder: TFolder, acc: TFile[]): void {
for (const child of folder.children) {
if (child instanceof TFile && child.extension === "md") {
acc.push(child);
}
if (child instanceof TFolder) {
collectMarkdownChildren(child, acc);
}
}
}
/** First free `<folder>/<baseName>[-N].md` path (N starts at 2). */
export async function getAvailablePath(vault: Vault, folder: string, baseName: string): Promise<string> {
let attempt = 0;
while (true) {
const suffix = attempt === 0 ? "" : `-${attempt + 1}`;
const candidate = normalizePath(`${folder}/${baseName}${suffix}.md`);
if (!vault.getAbstractFileByPath(candidate)) {
return candidate;
}
attempt += 1;
}
}

View file

@ -0,0 +1,109 @@
/**
* Test-only fakes for the repository store tests. NOT imported by production
* code (esbuild never bundles it). Mirrors the FakeVault pattern used in
* src/fleetRepository.test.ts against the `test-support/obsidian.ts` stub.
*/
import type { App } from "obsidian";
import { TFile, TFolder } from "obsidian";
/** Minimal in-memory vault: enough surface for the store paths under test. */
export class FakeVault {
files = new Map<string, TFile>();
contents = new Map<string, string>();
folders = new Map<string, TFolder>();
private clock = 1;
addFile(path: string, content: string): TFile {
const existing = this.files.get(path);
if (existing) {
this.contents.set(path, content);
existing.stat.mtime = this.clock++;
existing.stat.size = content.length;
return existing;
}
const file = new TFile();
file.path = path;
const base = path.split("/").pop() ?? "";
file.basename = base.replace(/\.[^.]+$/, "");
file.extension = base.split(".").pop() ?? "";
file.stat = { ctime: this.clock, mtime: this.clock++, size: content.length };
this.files.set(path, file);
this.contents.set(path, content);
this.ensureFolderChain(path.slice(0, path.lastIndexOf("/")));
return file;
}
private ensureFolderChain(path: string): void {
if (!path || this.folders.has(path)) return;
const folder = new TFolder();
folder.path = path;
this.folders.set(path, folder);
const parent = path.slice(0, path.lastIndexOf("/"));
if (parent) this.ensureFolderChain(parent);
}
getAbstractFileByPath(path: string): TFile | TFolder | null {
const file = this.files.get(path);
if (file) return file;
const folder = this.folders.get(path);
if (!folder) return null;
const children: Array<TFile | TFolder> = [];
for (const candidate of [...this.folders.values(), ...this.files.values()]) {
if (!candidate.path.startsWith(`${path}/`)) continue;
if (candidate.path.slice(path.length + 1).includes("/")) continue;
children.push(candidate);
}
folder.children = children;
return folder;
}
async cachedRead(file: TFile): Promise<string> {
const content = this.contents.get(file.path);
if (content === undefined) throw new Error(`no such file: ${file.path}`);
return content;
}
async create(path: string, content: string): Promise<TFile> {
if (this.files.has(path)) throw new Error("File already exists");
return this.addFile(path, content);
}
async createFolder(path: string): Promise<void> {
if (this.folders.has(path)) throw new Error("Folder already exists");
this.ensureFolderChain(path);
}
async modify(file: TFile, content: string): Promise<void> {
this.addFile(file.path, content);
}
getMarkdownFiles(): TFile[] {
return [...this.files.values()].filter((f) => f.extension === "md");
}
removeTree(path: string): void {
this.files.delete(path);
this.contents.delete(path);
this.folders.delete(path);
for (const p of [...this.files.keys()]) {
if (p.startsWith(`${path}/`)) {
this.files.delete(p);
this.contents.delete(p);
}
}
for (const p of [...this.folders.keys()]) {
if (p.startsWith(`${path}/`)) this.folders.delete(p);
}
}
}
export function makeApp(vault: FakeVault): App {
return {
vault,
fileManager: {
trashFile: async (file: TFile | TFolder) => {
vault.removeTree(file.path);
},
},
} as unknown as App;
}

View file

@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { App } from "obsidian";
import type { UsageRecord } from "../types";
import { UsageLedger } from "./usageLedger";
/** Adapter-level fake — the ledger goes through vault.adapter, not TFiles. */
class FakeAdapterVault {
data = new Map<string, string>();
adapter = {
exists: async (p: string): Promise<boolean> =>
this.data.has(p) || [...this.data.keys()].some((k) => k.startsWith(`${p}/`)),
list: async (p: string) => ({
files: [...this.data.keys()].filter(
(k) => k.startsWith(`${p}/`) && !k.slice(p.length + 1).includes("/"),
),
folders: [] as string[],
}),
read: async (p: string): Promise<string> => {
const c = this.data.get(p);
if (c === undefined) throw new Error(`missing: ${p}`);
return c;
},
write: async (p: string, c: string): Promise<void> => {
this.data.set(p, c);
},
append: async (p: string, c: string): Promise<void> => {
this.data.set(p, (this.data.get(p) ?? "") + c);
},
};
getAbstractFileByPath(_path: string): null {
return null;
}
async createFolder(_path: string): Promise<void> {
/* folders are implicit in the adapter fake */
}
}
function record(ts: string, agent = "bot"): UsageRecord {
return { ts, agent, source: "chat", tokensUsed: 10 } as unknown as UsageRecord;
}
describe("UsageLedger", () => {
let vault: FakeAdapterVault;
let ledger: UsageLedger;
beforeEach(() => {
vault = new FakeAdapterVault();
ledger = new UsageLedger({ vault } as unknown as App, () => "_fleet/usage");
});
it("appends one JSONL line per record into the day's file", async () => {
await ledger.appendUsage(record("2026-07-01T10:00:00Z"));
await ledger.appendUsage(record("2026-07-01T11:00:00Z"));
await ledger.appendUsage(record("2026-07-02T09:00:00Z"));
const day1 = vault.data.get("_fleet/usage/2026-07-01.jsonl") ?? "";
expect(day1.trim().split("\n")).toHaveLength(2);
expect(vault.data.has("_fleet/usage/2026-07-02.jsonl")).toBe(true);
});
it("readUsageSince filters by ledger file date and skips corrupt lines", async () => {
vault.data.set("_fleet/usage/2026-06-01.jsonl", `${JSON.stringify(record("2026-06-01T00:00:00Z"))}\n`);
vault.data.set(
"_fleet/usage/2026-07-01.jsonl",
`${JSON.stringify(record("2026-07-01T00:00:00Z"))}\n{corrupt\n${JSON.stringify(record("2026-07-01T01:00:00Z"))}\n`,
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const out = await ledger.readUsageSince(new Date("2026-06-15T00:00:00Z"));
expect(out).toHaveLength(2); // June file excluded, corrupt line skipped
expect(out.every((r) => r.ts.startsWith("2026-07-01"))).toBe(true);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it("migrateUsageLedgerCosts runs once and is guarded by the marker file", async () => {
vault.data.set("_fleet/usage/2026-07-01.jsonl", `${JSON.stringify(record("2026-07-01T00:00:00Z"))}\n`);
const first = await ledger.migrateUsageLedgerCosts();
expect(first).not.toBeNull();
expect(first?.rows).toBe(1);
expect(vault.data.has("_fleet/usage/.cost-delta-v1")).toBe(true);
const second = await ledger.migrateUsageLedgerCosts();
expect(second).toBeNull(); // marker guard
});
it("migrateUsageLedgerCosts is a no-op when the usage dir doesn't exist", async () => {
expect(await ledger.migrateUsageLedgerCosts()).toBeNull();
expect(vault.data.size).toBe(0);
});
});

View file

@ -0,0 +1,132 @@
import { normalizePath } from "obsidian";
import type { App, Vault } from "obsidian";
import { deltaizeCumulativeCosts } from "../utils/usageMigration";
import type { UsageRecord } from "../types";
import { ensureFolder } from "./shared";
/**
* Usage ledger: append-only chat/channel token+cost records in
* `_fleet/usage/YYYY-MM-DD.jsonl`, plus the one-time cumulative-cost repair
* migration. Extracted verbatim from FleetRepository.
*/
export class UsageLedger {
private readonly vault: Vault;
constructor(
app: App,
private readonly getUsageDir: () => string,
) {
this.vault = app.vault;
}
private usageLedgerPath(ts: string): string {
return normalizePath(`${this.getUsageDir()}/${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 ensureFolder(this.vault, this.getUsageDir());
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.getUsageDir();
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 (err) {
// skip a corrupt line rather than failing the whole read
console.warn(`Agent Fleet: skipping corrupt usage record in ${filePath}`, err);
}
}
}
return out;
}
/**
* One-time repair of historical usage rows that stored Claude's CUMULATIVE
* `total_cost_usd` as a per-turn cost (see `deltaizeCumulativeCosts`). Reads
* every `*.jsonl` ledger file, reconstructs per-turn costs per agent across
* the whole ledger (a process can't be assumed to stay within one day), and
* rewrites each file in place. Guarded by a marker file so it runs at most
* once; idempotent and fail-soft (any error leaves the ledger untouched).
*/
async migrateUsageLedgerCosts(): Promise<{ files: number; rows: number; changed: number } | null> {
const dir = this.getUsageDir();
const adapter = this.vault.adapter;
if (!(await adapter.exists(dir))) return null;
const marker = normalizePath(`${dir}/.cost-delta-v1`);
if (await adapter.exists(marker)) return null;
try {
const listing = await adapter.list(dir);
const files: Array<{ path: string; records: UsageRecord[] }> = [];
const all: UsageRecord[] = [];
for (const filePath of listing.files) {
if (!filePath.endsWith(".jsonl")) continue;
let content: string;
try {
content = await adapter.read(filePath);
} catch {
continue;
}
const records: UsageRecord[] = [];
for (const raw of content.split("\n")) {
const trimmed = raw.trim();
if (!trimmed) continue;
try {
records.push(JSON.parse(trimmed) as UsageRecord);
} catch (err) {
// skip a corrupt line rather than failing the whole migration
console.warn(`Agent Fleet: skipping corrupt usage record in ${filePath} during cost migration`, err);
}
}
files.push({ path: filePath, records });
all.push(...records);
}
// Mutates `costUsd` on the same objects held in `files[].records`.
const changed = deltaizeCumulativeCosts(all);
if (changed > 0) {
for (const { path, records } of files) {
if (records.length === 0) continue;
const body = records.map((r) => JSON.stringify(r)).join("\n") + "\n";
await adapter.write(path, body);
}
}
await adapter.write(marker, `migrated ${all.length} rows; corrected ${changed}\n`);
return { files: files.length, rows: all.length, changed };
} catch (err) {
console.error("Agent Fleet: usage-ledger cost migration failed (ledger left untouched)", err);
return null;
}
}
}

View file

@ -224,6 +224,22 @@ export class ChannelManager {
await Promise.allSettled(Array.from(this.conversationLocks.values()));
}
// Drain in-flight turns too — they run OUTSIDE the conversation locks
// (chained via turnTails), so a mid-stream turn would otherwise race the
// hibernation below. Race against a timeout so a stuck turn can't hang
// shutdown forever.
if (this.turnTails.size > 0) {
let timer: number | null = null;
const timeout = new Promise<void>((resolve) => {
timer = window.setTimeout(resolve, 10_000);
});
await Promise.race([
Promise.allSettled(Array.from(this.turnTails.values())).then(() => undefined),
timeout,
]);
if (timer !== null) window.clearTimeout(timer);
}
// Hibernate all sessions (no pending turns — if there are, swallow the rejection).
for (const entry of this.sessions.values()) {
try {
@ -440,7 +456,10 @@ export class ChannelManager {
"_Rate limit exceeded. Please slow down and try again in a few minutes._",
);
} catch (err) {
console.warn(`Agent Fleet: rate-limit reply failed on ${channel.name}`, err);
console.warn(
`Agent Fleet: rate-limit reply failed on ${channel.name} (user ${msg.externalUserId}, conversation ${msg.conversationId})`,
err,
);
}
return;
}

View file

@ -0,0 +1,27 @@
/**
* Tiny exponential backoff shared by the channel adapters' reconnect/poll loops.
* Delay sequence: base, base*2, base*4, capped at `cap`; `reset()` returns to
* base (adapters call it on a successful connection / poll).
*/
export class ExponentialBackoff {
private readonly base: number;
private readonly cap: number;
private delayMs: number;
constructor(base = 1000, cap = 30_000) {
this.base = base;
this.cap = cap;
this.delayMs = base;
}
/** Return the current delay and advance to the next (doubled, capped) one. */
nextDelay(): number {
const delay = this.delayMs;
this.delayMs = Math.min(this.cap, this.delayMs * 2);
return delay;
}
reset(): void {
this.delayMs = this.base;
}
}

View file

@ -13,6 +13,8 @@ import type {
InboundMessage,
StatusHandler,
} from "./adapter";
import { splitText } from "./formatter";
import { ExponentialBackoff } from "./backoff";
/**
* Discord adapter using the Gateway (WebSocket) for inbound events and the REST
@ -79,7 +81,7 @@ export class DiscordAdapter implements ChannelAdapter {
private ws: WebSocket | null = null;
private status: ChannelStatus = "stopped";
private stopping = false;
private backoffMs = 1000;
private readonly backoff = new ExponentialBackoff();
private reconnectTimer: number | null = null;
// Gateway session state (for RESUME).
@ -124,7 +126,7 @@ export class DiscordAdapter implements ChannelAdapter {
async start(): Promise<void> {
this.stopping = false;
this.backoffMs = 1000;
this.backoff.reset();
this.canResume = false;
await this.connect();
}
@ -411,14 +413,14 @@ export class DiscordAdapter implements ChannelAdapter {
this.selfUserId = ready.user?.id ?? null;
this.applicationId = ready.application?.id ?? null;
this.canResume = true;
this.backoffMs = 1000;
this.backoff.reset();
this.setStatus("connected");
void this.registerAgentsCommand();
return;
}
if (type === "RESUMED") {
this.backoffMs = 1000;
this.backoff.reset();
this.setStatus("connected");
return;
}
@ -485,8 +487,7 @@ export class DiscordAdapter implements ChannelAdapter {
private scheduleReconnect(): void {
if (this.stopping) return;
if (this.reconnectTimer) return;
const delay = this.backoffMs;
this.backoffMs = Math.min(30_000, this.backoffMs * 2);
const delay = this.backoff.nextDelay();
console.warn(`Agent Fleet: Discord channel ${this.config.name} scheduling reconnect in ${delay}ms`);
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null;
@ -835,18 +836,3 @@ export function describeDiscordError(rawBody: string | undefined): string {
}
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

@ -175,6 +175,28 @@ export function splitForTransport(text: string, limit: number = SLACK_TEXT_LIMIT
return chunks;
}
/**
* Plain chunker for transports that render standard markdown (Discord, Telegram):
* prefer a paragraph break (`\n\n`), then a line break (`\n`), then a hard split
* at the limit. Unlike `splitForTransport` it is NOT fence-aware deliberately,
* since these transports tolerate a fence broken across messages and the callers
* relied on this exact cut behavior before extraction.
*/
export 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;
}
/** Count the number of triple-backtick occurrences in a string. */
function countFences(s: string): number {
let n = 0;

View file

@ -13,6 +13,7 @@ import type {
StatusHandler,
} from "./adapter";
import { markdownToMrkdwn } from "./formatter";
import { ExponentialBackoff } from "./backoff";
/**
* Slack Socket Mode adapter.
@ -108,7 +109,7 @@ export class SlackAdapter implements ChannelAdapter {
private ws: WebSocket | null = null;
private status: ChannelStatus = "stopped";
private stopping = false;
private backoffMs = 1000;
private readonly backoff = new ExponentialBackoff();
private reconnectTimer: number | null = null;
private readonly inboundHandlers = new Set<InboundHandler>();
@ -383,7 +384,7 @@ export class SlackAdapter implements ChannelAdapter {
// `hello` — initial handshake completed; we're ready to receive events.
if (envelope.type === "hello") {
this.backoffMs = 1000; // reset backoff on a successful connection
this.backoff.reset(); // reset backoff on a successful connection
this.setStatus("connected");
return;
}
@ -656,8 +657,7 @@ export class SlackAdapter implements ChannelAdapter {
private scheduleReconnect(): void {
if (this.stopping) return;
if (this.reconnectTimer) return;
const delay = this.backoffMs;
this.backoffMs = Math.min(30_000, this.backoffMs * 2);
const delay = this.backoff.nextDelay();
console.warn(
`Agent Fleet: Slack channel ${this.config.name} scheduling reconnect in ${delay}ms`,
);
@ -749,10 +749,15 @@ export class SlackAdapter implements ChannelAdapter {
console.warn(`Agent Fleet: Slack send queue error for ${channel}`, err);
});
this.sendQueues.set(channel, wrapped);
await next;
// GC: if no subsequent send chained onto this channel, the queue is idle
if (this.sendQueues.get(channel) === wrapped) {
this.sendQueues.delete(channel);
try {
await next;
} finally {
// GC: if no subsequent send chained onto this channel, the queue is idle.
// Runs even when the send throws so a failed channel doesn't leave a
// settled promise behind in sendQueues.
if (this.sendQueues.get(channel) === wrapped) {
this.sendQueues.delete(channel);
}
}
}
}

View file

@ -12,6 +12,8 @@ import type {
InboundMessage,
StatusHandler,
} from "./adapter";
import { splitText } from "./formatter";
import { ExponentialBackoff } from "./backoff";
/**
* Telegram Bot API adapter using long-polling (getUpdates).
@ -39,7 +41,7 @@ export class TelegramAdapter implements ChannelAdapter {
private stopping = false;
private pollOffset = 0;
private pollTimer: number | null = null;
private backoffMs = 1000;
private readonly backoff = new ExponentialBackoff();
private typingIntervals = new Map<string, number>();
/** AbortController for the current long-poll request — lets stop() cancel a 30s wait. */
@ -268,7 +270,7 @@ export class TelegramAdapter implements ChannelAdapter {
}
}
this.backoffMs = 1000; // Reset on success
this.backoff.reset(); // Reset on success
if (this.status !== "connected") this.setStatus("connected");
} catch (err) {
// Abort errors from stop() are expected — don't log or backoff
@ -278,9 +280,8 @@ export class TelegramAdapter implements ChannelAdapter {
const msg = err instanceof Error ? err.message : String(err);
this.setStatus(msg.includes("401") || msg.includes("Unauthorized") ? "needs-auth" : "error");
}
// Backoff
await new Promise((r) => window.setTimeout(r, this.backoffMs));
this.backoffMs = Math.min(30_000, this.backoffMs * 2);
// Backoff — wait the current delay; the next failure waits double.
await new Promise((r) => window.setTimeout(r, this.backoff.nextDelay()));
}
// Schedule next poll
@ -594,18 +595,3 @@ function threadIdFromConversationId(conversationId: string): string | undefined
if (parts[2] === "topic" && parts[3]) return parts[3];
return undefined;
}
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

@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import { ChatSession } from "./chatSession";
import type { AgentConfig, FleetSettings } from "../types";
import { ExecutionManager } from "./executionManager";
import type { AgentConfig, FleetSettings, SkillConfig, TaskConfig, WorkingMemory } from "../types";
import type { FleetRepository } from "../fleetRepository";
import { MEMORY_CAPTURE_INSTRUCTION } from "../utils/memoryFormat";
// ChatSession imports Vault from "obsidian" (type-only, erased at runtime) and
// uses TFile/normalizePath which the test stub provides. We don't drive any
@ -171,6 +173,195 @@ describe("ChatSession.buildBasePrompt", () => {
});
});
// ─── Characterization fixtures for the prompt-parity tests below.
// Mirrors the fixtures in executionManager.test.ts (kept separate so the
// two test files don't import each other's registered tests). ───
function makeTask(overrides: Partial<TaskConfig> = {}): TaskConfig {
return {
filePath: "_fleet/tasks/summarize.md",
taskId: "summarize",
agent: "test-agent",
type: "recurring",
priority: "medium",
enabled: true,
created: "2026-01-01",
runCount: 0,
catchUp: false,
tags: [],
body: "Summarize the news.",
...overrides,
};
}
function makeSkill(): SkillConfig {
return {
filePath: "_fleet/skills/research.md",
name: "research",
tags: [],
body: "Research things thoroughly.",
toolsBody: "Use WebSearch.",
referencesBody: "See RESEARCH.md.",
examplesBody: "Example: find competitors.",
isFolder: false,
};
}
function makeWorkingMemory(): WorkingMemory {
return {
filePath: "_fleet/memory/test-agent.md",
agent: "test-agent",
schema: 2,
tokenEstimate: 0,
sections: [
{ name: "Preferences", entries: [{ text: "Prefers concise answers", pinned: true }] },
{
name: "Recent",
entries: [{ text: "Deploy uses GitHub Actions", pinned: false, source: "run", date: "2026-06-30" }],
},
],
};
}
function makeKeeperAgent(): AgentConfig {
return makeAgent({
name: "wiki-keeper-acme",
filePath: "_fleet/agents/wiki-keeper-acme.md",
wikiKeeper: {
scopeRoot: "Acme",
inboxPath: "wiki/inbox",
archivePath: "wiki/archive",
failedPath: "wiki/failed",
topicsRoot: "wiki/topics",
indexPath: "wiki/index.md",
logPath: "wiki/log.md",
watchedFolders: [],
excludePatterns: [],
watchedSince: "",
fileSubstantiveAnswers: false,
obsidianUrlScheme: false,
maxTokensPerIngest: 4000,
maxTokensPerRefresh: 4000,
dedupSimilarityThreshold: 0.8,
summaryStaleDays: 30,
indexSplitThreshold: 50,
stateFile: ".wiki-state.json",
},
});
}
/** The fully-loaded agent used by the byte-exact characterization tests. */
function makeFullAgent(): AgentConfig {
return makeAgent({
skills: ["research"],
skillsBody: "Custom agent skill notes.",
contextBody: "Working on Project Apollo.",
memory: true,
wikiReferences: [{ agent: "wiki-keeper-acme" }],
});
}
function makeFullRepoStub(): FleetRepository {
const skills = [makeSkill()];
const agents = [makeKeeperAgent()];
const wm = makeWorkingMemory();
return {
getSkillByName: (name: string) => skills.find((s) => s.name === name),
readWorkingMemory: async () => wm,
getAgentByName: (name: string) => agents.find((a) => a.name === name),
} as unknown as FleetRepository;
}
async function callBuildBasePrompt(session: ChatSession): Promise<string> {
return (session as unknown as { buildBasePrompt(): Promise<string> }).buildBasePrompt();
}
// Characterization tests — they capture the CURRENT byte-exact prompt output of
// the chat path (and its parity with the one-shot run path) so the shared
// prompt-assembly extraction is provably behavior-preserving.
describe("ChatSession.buildBasePrompt — characterization / run-path parity", () => {
it("base prompt + '## Task' framing is byte-identical to ExecutionManager.buildPrompt for the same agent", async () => {
const repo = makeFullRepoStub();
const agent = makeFullAgent();
const session = new ChatSession(agent, makeSettings(), repo, vaultStub);
const basePrompt = await callBuildBasePrompt(session);
// sendMessage frames the first turn as `${basePrompt}\n\n## Task\n${messageText}`
const chatFirstTurn = `${basePrompt}\n\n## Task\nSummarize the news.`;
const manager = new ExecutionManager(makeSettings(), repo);
const runPrompt = await manager.buildPrompt(agent, makeTask({ body: "Summarize the news." }));
expect(chatFirstTurn).toBe(runPrompt);
});
it("fully-loaded agent with channel context: byte-exact section order (memory → channel → wiki)", async () => {
const repo = makeFullRepoStub();
const session = new ChatSession(makeFullAgent(), makeSettings(), repo, vaultStub, {
channelName: "my-slack",
conversationId: "slack:T1:C1:U1",
channelContext: "You are being contacted via Slack.",
});
const prompt = await callBuildBasePrompt(session);
// The channel-context section sits between memory and wiki access —
// this exact ordering is load-bearing (captured pre-refactor).
const memorySection =
`## Memory\n${MEMORY_CAPTURE_INSTRUCTION}\n\n### What you've learned so far\n` +
"## Preferences\n- [pin] Prefers concise answers\n\n" +
"## Recent (uncurated)\n- Deploy uses GitHub Actions <!-- src:run 2026-06-30 -->";
expect(prompt).toContain(
`${memorySection}\n\n## Channel Context\nYou are being contacted via Slack.\n\n## Wiki Access\n`,
);
expect(prompt.startsWith("You are a helpful test agent.\n\n## Skill: research\n")).toBe(true);
});
it("thread mode section comes after wiki access (last section)", async () => {
const repo = makeFullRepoStub();
const agent = makeFullAgent();
const parent = new ChatSession(agent, makeSettings(), repo, vaultStub);
parent.messages = [
{ id: "m0", role: "user", content: "hi", timestamp: "t0" },
{ id: "m1", role: "assistant", content: "hello there", timestamp: "t1" },
];
const thread = new ChatSession(agent, makeSettings(), repo, vaultStub, {
threadAnchorId: "m1",
parentSession: parent,
});
(thread as unknown as { threadAnchorIndex: number }).threadAnchorIndex = 1;
const prompt = await callBuildBasePrompt(thread);
const wikiIdx = prompt.indexOf("## Wiki Access");
const threadIdx = prompt.indexOf("## Thread Mode");
expect(wikiIdx).toBeGreaterThan(-1);
expect(threadIdx).toBeGreaterThan(wikiIdx);
// Replay content is exact: preamble, then a "## Conversation so far" replay.
expect(prompt.endsWith(
"## Thread Mode\n" +
"You are continuing a side thread from this conversation. The user is " +
"following up on one of your earlier replies and wants to explore " +
"something specific without adding to the main thread. Your answers " +
"here stay in this thread only and will NOT be added back to the " +
"main conversation.\n\n" +
"## Conversation so far\nUser: hi\nAssistant: hello there",
)).toBe(true);
});
it("memory-enabled chat agent gets the memory section unconditionally (no chat-side suppression)", async () => {
const repo = makeFullRepoStub();
const session = new ChatSession(makeAgent({ memory: true }), makeSettings(), repo, vaultStub);
const prompt = await callBuildBasePrompt(session);
expect(prompt).toBe(
"You are a helpful test agent.\n\n" +
`## Memory\n${MEMORY_CAPTURE_INSTRUCTION}\n\n### What you've learned so far\n` +
"## Preferences\n- [pin] Prefers concise answers\n\n" +
"## Recent (uncurated)\n- Deploy uses GitHub Actions <!-- src:run 2026-06-30 -->",
);
});
});
describe("ChatSession.hibernate / clearSessionId", () => {
it("hibernate refuses to run while a turn is streaming", () => {
const session = new ChatSession(makeAgent(), makeSettings(), makeRepositoryStub(), vaultStub);
@ -450,6 +641,94 @@ describe("ChatSession.detachProcessListeners", () => {
});
});
describe("ChatSession.handleStdout — partial-line buffer cap", () => {
type StdoutInternals = { stdoutBuffer: string; handleStdout(chunk: string): void };
it("keeps a small incomplete trailing line buffered", () => {
const session = new ChatSession(makeAgent(), makeSettings(), makeRepositoryStub(), vaultStub);
const s = session as unknown as StdoutInternals;
s.handleStdout('{"type":"sys');
expect(s.stdoutBuffer).toBe('{"type":"sys');
});
it("drops a pathological oversized partial line instead of buffering unboundedly", () => {
const session = new ChatSession(makeAgent(), makeSettings(), makeRepositoryStub(), vaultStub);
const s = session as unknown as StdoutInternals;
// One giant chunk with no newline — must not be retained.
s.handleStdout("x".repeat(10 * 1024 * 1024 + 1));
expect(s.stdoutBuffer).toBe("");
// Subsequent well-formed lines still parse (session id is captured).
s.handleStdout('{"type":"system","session_id":"s-after-drop"}\n');
expect(
(session as unknown as { claudeSessionId: string | null }).claudeSessionId,
).toBe("s-after-drop");
});
});
describe("ChatSession.handleProcessClose — streaming reset between turns", () => {
it("resets streaming state even when no turn resolve is pending", () => {
const session = new ChatSession(makeAgent(), makeSettings(), makeRepositoryStub(), vaultStub);
type Internals = {
pendingTurns: number;
setStreaming(active: boolean): void;
handleProcessClose(): void;
turnResolve: unknown;
};
const s = session as unknown as Internals;
// Simulate a wedged state: spinner on, no resolver to end the turn.
s.setStreaming(true);
s.pendingTurns = 1;
expect(s.turnResolve).toBeNull();
s.handleProcessClose();
expect(session.isStreaming).toBe(false);
expect(session.pendingTurnCount).toBe(0);
expect(session.isProcessAlive).toBe(false);
});
});
describe("ChatSession codex queue — failed follow-up start is reported, not silent", () => {
it("emits an error event naming the dropped queued messages when startCodexTurn rejects", async () => {
const session = new ChatSession(
makeAgent({ adapter: "codex" }),
makeSettings(),
makeRepositoryStub(),
vaultStub,
);
type Internals = {
codexQueue: string[];
pendingTurns: number;
activeOnEvent: ((ev: { type: string; errorMessage?: string }) => void) | null;
startCodexTurn(text: string): Promise<void>;
handleTurnEnd(): void;
turnReject: ((e: Error) => void) | null;
};
const s = session as unknown as Internals;
const events: Array<{ type: string; errorMessage?: string }> = [];
const rejections: Error[] = [];
s.activeOnEvent = (ev) => events.push(ev);
s.turnReject = (e) => rejections.push(e);
s.codexQueue = ["queued follow-up", "second follow-up"];
s.pendingTurns = 3;
s.startCodexTurn = () => Promise.reject(new Error("spawn ENOENT"));
s.handleTurnEnd();
// Let the startCodexTurn rejection propagate through the catch handler.
await new Promise((r) => setTimeout(r, 0));
const errEvent = events.find((e) => e.type === "error");
// Both the shifted message and the one still queued are reported.
expect(errEvent?.errorMessage).toMatch(/dropping 2 queued messages/);
expect(errEvent?.errorMessage).toContain("spawn ENOENT");
// The turn promise still rejects (handleProcessError ran) and the queue
// is cleared — no stale entries linger for the next turn.
expect(rejections.map((e) => e.message)).toEqual(["spawn ENOENT"]);
expect(s.codexQueue).toEqual([]);
expect(session.isStreaming).toBe(false);
});
});
describe("ChatSession.refreshAgent — picks up post-construction permission edits", () => {
it("swaps in the latest AgentConfig from the repository when invoked", () => {
const constructionTime = makeAgent({

View file

@ -7,10 +7,10 @@ import type { FleetRepository } from "../fleetRepository";
import { slugify } from "../utils/markdown";
import { resolveModel, shouldPassModelFlag } from "./../utils/modelResolution";
import { spawnCli, splitLines } from "../utils/platform";
import { buildWikiReferencesContext } from "../utils/wikiReferences";
import { buildMemorySection, extractCaptures, redactRememberForDisplay, stripRememberTags } from "../utils/memoryFormat";
import { extractCaptures, redactRememberForDisplay, stripRememberTags } from "../utils/memoryFormat";
import { MemoryWriter } from "./memoryWriter";
import type { McpAuthManager } from "./mcpAuth";
import { buildAgentPromptSections } from "./promptAssembly";
import {
installMcpProjection,
resolveProjectedServers,
@ -176,6 +176,11 @@ export class ChatSession {
private claudeResumeAttempted = false;
private vault: Vault;
private stdoutBuffer = "";
/** Cap on the buffered partial stdout line. Stream-json is line-delimited,
* so the buffer only ever holds one incomplete line a line this size
* means the CLI is emitting something pathological, and buffering it
* further would grow memory unboundedly. */
private static readonly STDOUT_LINE_CAP = 10 * 1024 * 1024;
/** Bound event handlers for the current process — kept so we can removeListener on kill. */
private processListeners: {
onStdout: (chunk: Buffer | string) => void;
@ -779,6 +784,15 @@ export class ChatSession {
this.stdoutBuffer += chunk.toString();
const lines = splitLines(this.stdoutBuffer);
this.stdoutBuffer = lines.pop() ?? ""; // keep incomplete trailing line
if (this.stdoutBuffer.length > ChatSession.STDOUT_LINE_CAP) {
// Drop the oversized partial line. When its tail (and newline) finally
// arrives, the leftover fragment fails JSON.parse and is skipped, so
// line-based parsing of subsequent events continues unharmed.
console.warn(
`Agent Fleet: chat stdout line exceeded ${ChatSession.STDOUT_LINE_CAP} chars — dropping partial line`,
);
this.stdoutBuffer = "";
}
for (const line of lines) {
const trimmed = line.trim();
@ -1151,6 +1165,18 @@ export class ChatSession {
if (next !== undefined) {
this.armWatchdog();
void this.startCodexTurn(next).catch((err: unknown) => {
// The dequeued message never reached the CLI and handleProcessError
// clears the rest of the queue — report what was dropped so the
// failure isn't silent. (Re-queuing and retrying here could loop
// forever on a persistent spawn failure, so we drop-and-report.)
const dropped = 1 + this.codexQueue.length;
this.activeOnEvent?.({
type: "error",
content: "",
errorMessage:
`failed to start queued Codex turn — dropping ${dropped} queued ` +
`message${dropped === 1 ? "" : "s"}: ${err instanceof Error ? err.message : String(err)}`,
});
this.handleProcessError(err instanceof Error ? err : new Error(String(err)));
});
return;
@ -1205,14 +1231,16 @@ export class ChatSession {
this.mcpProjection = null;
// If a turn was pending, resolve with whatever we accumulated
if (this.turnResolve) {
const resolve = this.turnResolve;
let result: { text: string; toolCalls: ToolCall[] } | null = null;
if (resolve) {
// Resumed turn that produced nothing → the session is almost certainly
// expired/missing. Drop the id so the next turn starts fresh instead of
// re-resuming a dead session and staying silent.
if (this.claudeResumeAttempted && !this.turnResponseText.trim()) {
this.clearSessionId();
}
const result = { text: this.turnResponseText, toolCalls: [...this.turnToolCalls] };
result = { text: this.turnResponseText, toolCalls: [...this.turnToolCalls] };
if (this.turnResponseText.trim()) {
this.messages.push({
@ -1223,19 +1251,22 @@ export class ChatSession {
toolCalls: this.turnToolCalls.length > 0 ? [...this.turnToolCalls] : undefined,
});
}
}
this.pendingTurns = 0;
this.turnResponseText = "";
this.turnToolCalls = [];
this.clearWatchdog();
this.setStreaming(false);
// Reset streaming state unconditionally — a process closing between turns
// (no pending resolve) must still drop the spinner, otherwise isStreaming
// wedges at true with no process left to end the turn.
this.pendingTurns = 0;
this.turnResponseText = "";
this.turnToolCalls = [];
this.clearWatchdog();
this.setStreaming(false);
if (resolve && result) {
void this.persist();
const resolve = this.turnResolve;
this.turnResolve = null;
this.turnReject = null;
resolve?.(result);
resolve(result);
}
}
@ -1447,7 +1478,13 @@ export class ChatSession {
proc.stdin!.write(invocation.stdinPayload ?? messageText);
proc.stdin!.end();
} catch (err) {
// Detach BEFORE killing so the kill's close event can't fire
// handleCodexProcessClose and double-settle the turn the caller is
// about to fail (it would also leak the listeners otherwise).
this.detachProcessListeners();
try { proc.kill(); } catch { /* ignore */ }
this.process = null;
this.isProcessAlive = false;
throw err instanceof Error ? err : new Error(String(err));
}
}
@ -1628,42 +1665,15 @@ export class ChatSession {
* memory entirely for channel-bound agents.
*/
private async buildBasePrompt(): Promise<string> {
const sections: string[] = [this.agent.body.trim()];
for (const skillName of this.agent.skills) {
const skill = this.repository.getSkillByName(skillName);
if (skill) {
const parts = [skill.body.trim()];
if (skill.toolsBody.trim()) parts.push(`### Tools\n${skill.toolsBody.trim()}`);
if (skill.referencesBody.trim()) parts.push(`### References\n${skill.referencesBody.trim()}`);
if (skill.examplesBody.trim()) parts.push(`### Examples\n${skill.examplesBody.trim()}`);
sections.push(`## Skill: ${skill.name}\n${parts.join("\n\n")}`);
}
}
if (this.agent.skillsBody.trim()) {
sections.push(`## Agent Skills\n${this.agent.skillsBody.trim()}`);
}
if (this.agent.contextBody.trim()) {
sections.push(`## Agent Context\n${this.agent.contextBody.trim()}`);
}
if (this.agent.memory) {
const wm = await this.repository.readWorkingMemory(this.agent.name);
const memorySection = buildMemorySection(this.agent, wm);
if (memorySection) sections.push(memorySection);
}
// Channel context appended LAST so it takes priority over earlier sections
// without shadowing the agent's identity.
if (this.channelContext && this.channelContext.trim()) {
sections.push(`## Channel Context\n${this.channelContext.trim()}`);
}
// Wiki references — consumer-mode access to one or more Wiki Keeper scopes.
const wikiContext = buildWikiReferencesContext(this.agent, this.repository);
if (wikiContext) sections.push(wikiContext);
// Common sections (body/skills/context/memory/wiki) come from the shared
// assembly so the chat and one-shot run paths cannot drift. The chat-only
// channel context travels as an option because it sits between memory and
// wiki access; the thread-mode replay below is appended caller-side since
// it is always the final section.
const sections = await buildAgentPromptSections(this.repository, this.agent, {
memoryActive: this.agent.memory,
channelContext: this.channelContext,
});
// Thread mode: append soft-fork preamble + replay parent history up to
// and including the anchor message. See CHAT_THREADING_DESIGN.md §4.2.

View file

@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { extractConcreteModel, extractRememberEntries } from "./executionManager";
import { ExecutionManager, extractConcreteModel, extractRememberEntries } from "./executionManager";
import type { AgentConfig, FleetSettings, SkillConfig, TaskConfig, WorkingMemory } from "../types";
import type { FleetRepository } from "../fleetRepository";
import { MEMORY_CAPTURE_INSTRUCTION } from "../utils/memoryFormat";
describe("execution helpers", () => {
it("extracts remember directives", () => {
@ -53,3 +56,304 @@ Middle
});
});
});
// ─── Shared prompt-assembly test fixtures (also used by chatSession.test.ts's
// cross-path parity test) ───
function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
return {
filePath: "_fleet/agents/test-agent.md",
name: "test-agent",
description: "An agent for testing",
model: "default",
adapter: "claude-code",
permissionMode: "bypassPermissions",
maxRetries: 1,
skills: [],
mcpServers: [],
enabled: true,
timeout: 300,
approvalRequired: [],
memory: false,
memoryMaxEntries: 100,
memoryTokenBudget: 1500,
reflection: { enabled: false, schedule: "0 3 * * *", recurrenceThreshold: 3, proposeSkills: false },
tags: [],
avatar: "",
body: "You are a helpful test agent.",
contextBody: "",
skillsBody: "",
env: {},
permissionRules: { allow: [], deny: [] },
isFolder: false,
heartbeatEnabled: false,
heartbeatSchedule: "",
heartbeatBody: "",
heartbeatNotify: true,
heartbeatChannel: "",
heartbeatChannelTarget: "",
...overrides,
};
}
function makeSettings(overrides: Partial<FleetSettings> = {}): FleetSettings {
return {
fleetFolder: "_fleet",
claudeCliPath: "claude",
codexCliPath: "codex",
defaultModel: "default",
awsRegion: "us-east-1",
maxConcurrentRuns: 2,
runLogRetentionDays: 30,
catchUpMissedTasks: true,
notificationLevel: "all",
showStatusBar: true,
mcpApiKeys: {},
mcpTokens: {},
channelCredentials: {},
maxConcurrentChannelSessions: 5,
channelIdleTimeoutMinutes: 15,
channelRateLimitPerConversation: 20,
channelRateLimitWindowMinutes: 5,
chatWatchdogMinutes: 10,
defaultFileHashes: {},
...overrides,
};
}
function makeTask(overrides: Partial<TaskConfig> = {}): TaskConfig {
return {
filePath: "_fleet/tasks/summarize.md",
taskId: "summarize",
agent: "test-agent",
type: "recurring",
priority: "medium",
enabled: true,
created: "2026-01-01",
runCount: 0,
catchUp: false,
tags: [],
body: "Summarize the news.",
...overrides,
};
}
function makeSkill(overrides: Partial<SkillConfig> = {}): SkillConfig {
return {
filePath: "_fleet/skills/research.md",
name: "research",
tags: [],
body: "Research things thoroughly.",
toolsBody: "Use WebSearch.",
referencesBody: "See RESEARCH.md.",
examplesBody: "Example: find competitors.",
isFolder: false,
...overrides,
};
}
function makeWorkingMemory(): WorkingMemory {
return {
filePath: "_fleet/memory/test-agent.md",
agent: "test-agent",
schema: 2,
tokenEstimate: 0,
sections: [
{ name: "Preferences", entries: [{ text: "Prefers concise answers", pinned: true }] },
{
name: "Recent",
entries: [{ text: "Deploy uses GitHub Actions", pinned: false, source: "run", date: "2026-06-30" }],
},
],
};
}
/** A wiki-keeper agent that `wikiReferences: [{ agent: "wiki-keeper-acme" }]` resolves to. */
function makeKeeperAgent(): AgentConfig {
return makeAgent({
name: "wiki-keeper-acme",
filePath: "_fleet/agents/wiki-keeper-acme.md",
wikiKeeper: {
scopeRoot: "Acme",
inboxPath: "wiki/inbox",
archivePath: "wiki/archive",
failedPath: "wiki/failed",
topicsRoot: "wiki/topics",
indexPath: "wiki/index.md",
logPath: "wiki/log.md",
watchedFolders: [],
excludePatterns: [],
watchedSince: "",
fileSubstantiveAnswers: false,
obsidianUrlScheme: false,
maxTokensPerIngest: 4000,
maxTokensPerRefresh: 4000,
dedupSimilarityThreshold: 0.8,
summaryStaleDays: 30,
indexSplitThreshold: 50,
stateFile: ".wiki-state.json",
},
});
}
function makePromptRepoStub(opts: {
skills?: SkillConfig[];
workingMemory?: WorkingMemory | null;
agents?: AgentConfig[];
} = {}): FleetRepository {
return {
getSkillByName: (name: string) => opts.skills?.find((s) => s.name === name),
readWorkingMemory: async () => opts.workingMemory ?? null,
getAgentByName: (name: string) => opts.agents?.find((a) => a.name === name),
} as unknown as FleetRepository;
}
/** The exact `## Memory` block buildPrompt emits for {@link makeWorkingMemory}. */
const EXPECTED_MEMORY_SECTION =
`## Memory\n${MEMORY_CAPTURE_INSTRUCTION}\n\n### What you've learned so far\n` +
"## Preferences\n- [pin] Prefers concise answers\n\n" +
"## Recent (uncurated)\n- Deploy uses GitHub Actions <!-- src:run 2026-06-30 -->";
/** The exact `## Wiki Access` block for {@link makeKeeperAgent}. */
const EXPECTED_WIKI_SECTION = [
"## Wiki Access",
"You have read access to the following wikis maintained by other agents. " +
"Use the `wiki-query` skill in consumer mode when the user asks something " +
"a wiki may already cover.",
"",
"### Wiki: `wiki-keeper-acme`",
"- scope root: `Acme`",
"- topics: `Acme/wiki/topics/`",
"- index: `Acme/wiki/index.md`",
"- inbox: `Acme/wiki/inbox/`",
"",
"### Rules",
"- **Cite every factual claim** from a wiki using `[[<topics-path>/<page>]]`. " +
"If a claim has no page, say the wiki doesn't cover it — do not fabricate.",
"- **When the user shares something durable** that isn't yet in a wiki " +
"(a decision, a new entity mention, a competitor change, a meeting outcome), " +
"write a short markdown file to the relevant wiki's inbox at " +
"`<inbox>/YYYY-MM-DD-<slug>.md` with a one-line note + the source. " +
"The wiki keeper files it canonically on its next ingest.",
"- **Do NOT write to `<topics-path>/` directly.** The wiki keeper is the " +
"canonical curator of topic pages. Use the inbox as your deposit point.",
"- **When the question spans multiple wikis**, be explicit about which " +
"scope each cited page belongs to.",
].join("\n");
const EXPECTED_SKILL_SECTION =
"## Skill: research\nResearch things thoroughly.\n\n" +
"### Tools\nUse WebSearch.\n\n" +
"### References\nSee RESEARCH.md.\n\n" +
"### Examples\nExample: find competitors.";
/** The fully-loaded agent used by the byte-exact characterization tests. */
function makeFullAgent(): AgentConfig {
return makeAgent({
skills: ["research"],
skillsBody: "Custom agent skill notes.",
contextBody: "Working on Project Apollo.",
memory: true,
wikiReferences: [{ agent: "wiki-keeper-acme" }],
});
}
function makeFullRepoStub(): FleetRepository {
return makePromptRepoStub({
skills: [makeSkill()],
workingMemory: makeWorkingMemory(),
agents: [makeKeeperAgent()],
});
}
// Characterization tests — these capture the CURRENT byte-exact prompt output
// of the one-shot run path so the shared prompt-assembly extraction is provably
// behavior-preserving. Do not "improve" the expected strings; they are the spec.
describe("ExecutionManager.buildPrompt — characterization", () => {
function makeManager(repo: FleetRepository): ExecutionManager {
return new ExecutionManager(makeSettings(), repo);
}
it("assembles body + skill + agent skills + context + memory + wiki + task, byte-exact", async () => {
const manager = makeManager(makeFullRepoStub());
const prompt = await manager.buildPrompt(makeFullAgent(), makeTask());
expect(prompt).toBe(
[
"You are a helpful test agent.",
EXPECTED_SKILL_SECTION,
"## Agent Skills\nCustom agent skill notes.",
"## Agent Context\nWorking on Project Apollo.",
EXPECTED_MEMORY_SECTION,
EXPECTED_WIKI_SECTION,
"## Task\nSummarize the news.",
].join("\n\n"),
);
});
it("minimal agent: just body + task", async () => {
const manager = makeManager(makePromptRepoStub());
const prompt = await manager.buildPrompt(makeAgent(), makeTask());
expect(prompt).toBe("You are a helpful test agent.\n\n## Task\nSummarize the news.");
});
it("promptOverride (heartbeat path) replaces the task body and is trimmed", async () => {
const manager = makeManager(makePromptRepoStub());
const prompt = await manager.buildPrompt(
makeAgent(),
makeTask(),
" Check all site monitors and report anomalies.\n",
);
expect(prompt).toBe(
"You are a helpful test agent.\n\n## Task\nCheck all site monitors and report anomalies.",
);
});
it("memory enabled but no working-memory file yet → fresh-agent placeholder", async () => {
const manager = makeManager(makePromptRepoStub({ workingMemory: null }));
const prompt = await manager.buildPrompt(makeAgent({ memory: true }), makeTask());
expect(prompt).toBe(
[
"You are a helpful test agent.",
`## Memory\n${MEMORY_CAPTURE_INSTRUCTION}\n\n### What you've learned so far\nNothing yet — this is a fresh agent.`,
"## Task\nSummarize the news.",
].join("\n\n"),
);
});
it("memoryActive=false (reflection suppression) omits the memory section even for a memory agent", async () => {
const manager = makeManager(makePromptRepoStub({ workingMemory: makeWorkingMemory() }));
const prompt = await manager.buildPrompt(makeAgent({ memory: true }), makeTask(), undefined, false);
expect(prompt).toBe("You are a helpful test agent.\n\n## Task\nSummarize the news.");
});
it("unknown skill names are silently skipped", async () => {
const manager = makeManager(makePromptRepoStub({ skills: [makeSkill()] }));
const prompt = await manager.buildPrompt(
makeAgent({ skills: ["missing-skill", "research"] }),
makeTask(),
);
expect(prompt).toBe(
["You are a helpful test agent.", EXPECTED_SKILL_SECTION, "## Task\nSummarize the news."].join("\n\n"),
);
});
it("skill sub-bodies are optional — empty ones drop their heading", async () => {
const skill = makeSkill({ toolsBody: "", referencesBody: " ", examplesBody: "" });
const manager = makeManager(makePromptRepoStub({ skills: [skill] }));
const prompt = await manager.buildPrompt(makeAgent({ skills: ["research"] }), makeTask());
expect(prompt).toBe(
[
"You are a helpful test agent.",
"## Skill: research\nResearch things thoroughly.",
"## Task\nSummarize the news.",
].join("\n\n"),
);
});
it("empty agent body is filtered out (no leading blank section)", async () => {
const manager = makeManager(makePromptRepoStub());
const prompt = await manager.buildPrompt(makeAgent({ body: " " }), makeTask());
expect(prompt).toBe("## Task\nSummarize the news.");
});
});

View file

@ -5,9 +5,8 @@ import type { FleetRepository } from "../fleetRepository";
import { getAdapter } from "../adapters";
import { resolveModel, shouldPassModelFlag } from "../utils/modelResolution";
import { spawnCli, splitLines } from "../utils/platform";
import { buildWikiReferencesContext } from "../utils/wikiReferences";
import { buildMemorySection } from "../utils/memoryFormat";
import type { McpAuthManager } from "./mcpAuth";
import { buildAgentPromptSections } from "./promptAssembly";
import {
installMcpProjection,
resolveProjectedServers,
@ -18,6 +17,12 @@ import {
// Claude Code adapter when execution became adapter-dispatched.
export { extractConcreteModel, extractFinalResult } from "../adapters/claudeCodeAdapter";
// Output caps — a runaway CLI (e.g. a tool looping on huge output) would
// otherwise grow these buffers unbounded until the renderer OOMs. Exceeding
// either cap kills the process and fails the run, mirroring the timeout path.
const MAX_STDOUT_LENGTH = 10 * 1024 * 1024;
const MAX_STDERR_LENGTH = 1024 * 1024;
export function extractRememberEntries(output: string): string[] {
const matches = output.matchAll(/\[REMEMBER\]([\s\S]*?)\[\/REMEMBER\]/g);
return Array.from(matches)
@ -51,40 +56,10 @@ export class ExecutionManager {
promptOverride?: string,
memoryActive = agent.memory,
): Promise<string> {
const sections: string[] = [agent.body.trim()];
// Shared skills
for (const skillName of agent.skills) {
const skill = this.repository.getSkillByName(skillName);
if (skill) {
const parts = [skill.body.trim()];
if (skill.toolsBody.trim()) parts.push(`### Tools\n${skill.toolsBody.trim()}`);
if (skill.referencesBody.trim()) parts.push(`### References\n${skill.referencesBody.trim()}`);
if (skill.examplesBody.trim()) parts.push(`### Examples\n${skill.examplesBody.trim()}`);
sections.push(`## Skill: ${skill.name}\n${parts.join("\n\n")}`);
}
}
// Agent-specific skills (from SKILLS.md in folder agents)
if (agent.skillsBody.trim()) {
sections.push(`## Agent Skills\n${agent.skillsBody.trim()}`);
}
// Agent context (from CONTEXT.md in folder agents)
if (agent.contextBody.trim()) {
sections.push(`## Agent Context\n${agent.contextBody.trim()}`);
}
if (memoryActive) {
const wm = await this.repository.readWorkingMemory(agent.name);
const memorySection = buildMemorySection(agent, wm);
if (memorySection) sections.push(memorySection);
}
// Wiki references — consumer-mode access to one or more Wiki Keeper scopes.
const wikiContext = buildWikiReferencesContext(agent, this.repository);
if (wikiContext) sections.push(wikiContext);
// Common sections (body/skills/context/memory/wiki) come from the shared
// assembly so the run and chat paths cannot drift; only the `## Task`
// framing is run-specific.
const sections = await buildAgentPromptSections(this.repository, agent, { memoryActive });
sections.push(`## Task\n${(promptOverride ?? task.body).trim()}`);
return sections.filter(Boolean).join("\n\n");
}
@ -179,6 +154,7 @@ export class ExecutionManager {
let stdout = "";
let stderr = "";
let timedOut = false;
let outputLimitError: string | null = null;
const timer = window.setTimeout(() => {
timedOut = true;
@ -186,8 +162,14 @@ export class ExecutionManager {
}, agent.timeout * 1000);
proc.stdout!.on("data", (chunk: Buffer | string) => {
if (outputLimitError) return; // already killed — drop buffered chunks
const text = chunk.toString();
stdout += text;
if (stdout.length > MAX_STDOUT_LENGTH) {
outputLimitError = `Run output exceeded the ${MAX_STDOUT_LENGTH / (1024 * 1024)}MB stdout limit — process killed.`;
proc.kill();
return;
}
if (useStreaming && onOutput) {
// Parse stream lines for displayable content
for (const line of splitLines(text)) {
@ -198,7 +180,12 @@ export class ExecutionManager {
});
proc.stderr!.on("data", (chunk: Buffer | string) => {
if (outputLimitError) return;
stderr += chunk.toString();
if (stderr.length > MAX_STDERR_LENGTH) {
outputLimitError = `Run stderr exceeded the ${MAX_STDERR_LENGTH / (1024 * 1024)}MB limit — process killed.`;
proc.kill();
}
});
proc.on("error", (error) => {
@ -210,6 +197,11 @@ export class ExecutionManager {
window.clearTimeout(timer);
this.runningProcesses.delete(agent.name);
if (outputLimitError) {
reject(new Error(outputLimitError));
return;
}
const parsed = adapter.parseExecOutput(stdout, stderr, useStreaming);
resolve({

View file

@ -0,0 +1,238 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { FleetRepository } from "../fleetRepository";
import type { RunLogData } from "../types";
import { DEFAULT_SETTINGS } from "../constants";
import { FleetRuntime } from "./fleetRuntime";
// The obsidian test stub doesn't export Notice; provide one that records
// messages so notify() behavior can be asserted.
const notices = vi.hoisted(() => [] as Array<{ message: string; timeout?: number }>);
vi.mock("obsidian", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
Notice: class {
constructor(message: string, timeout?: number) {
notices.push({ message, timeout });
}
},
};
});
/** Private members exercised directly (they have no public trigger that
* doesn't spawn a CLI). */
interface RuntimeInternals {
refreshRunCaches(): Promise<void>;
emitRunOutput(agentName: string, chunk: string): void;
resetRunOutput(agentName: string): void;
clearRunOutput(agentName: string): void;
notify(run: RunLogData, capturedCount?: number): void;
recentRuns: RunLogData[];
}
function makeRun(overrides: Partial<RunLogData> = {}): RunLogData {
return {
runId: `run-${Math.random().toString(36).slice(2)}`,
agent: "scout",
task: "task-1",
status: "success",
started: new Date().toISOString(),
completed: new Date().toISOString(),
durationSeconds: 1,
model: "default",
exitCode: 0,
tags: [],
prompt: "do the thing",
output: "All done.",
toolsUsed: [],
...overrides,
};
}
function makeRuntime(getRuns: () => RunLogData[] = () => []) {
const repository = {
listRecentRuns: vi.fn(async () => getRuns()),
listRunsSince: vi.fn(async () => []),
readUsageSince: vi.fn(async () => []),
} as unknown as FleetRepository;
const runtime = new FleetRuntime(repository, { ...DEFAULT_SETTINGS });
const internals = runtime as unknown as RuntimeInternals;
return { runtime, internals };
}
beforeEach(() => {
notices.length = 0;
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-01T12:00:00"));
});
afterEach(() => {
vi.useRealTimers();
});
describe("getFleetStatus", () => {
it("counts today's runs and pending approvals", async () => {
const runs = [
makeRun({ started: "2026-07-01T08:00:00" }),
makeRun({ started: "2026-07-01T09:30:00" }),
makeRun({ started: "2026-06-30T23:59:00" }), // yesterday — excluded
makeRun({
started: "2026-07-01T10:00:00",
status: "pending_approval",
approvals: [
{ tool: "Bash", status: "pending" },
{ tool: "Write", status: "approved" },
],
}),
];
const { internals, runtime } = makeRuntime(() => [...runs]);
await internals.refreshRunCaches();
expect(runtime.getFleetStatus()).toEqual({ running: 0, pending: 1, completedToday: 3 });
});
it("caches on run-list identity: in-place mutation is invisible, refresh is picked up", async () => {
const data = [makeRun({ started: "2026-07-01T08:00:00" })];
const { internals, runtime } = makeRuntime(() => [...data]);
await internals.refreshRunCaches();
expect(runtime.getFleetStatus().completedToday).toBe(1);
// Mutating the cached array in place is NOT a supported mutation point
// (recentRuns is only ever replaced wholesale by refreshRunCaches) — the
// cache deliberately keys on array identity, so this stays at 1.
internals.recentRuns.push(makeRun({ started: "2026-07-01T09:00:00" }));
expect(runtime.getFleetStatus().completedToday).toBe(1);
// Recording a run goes through refreshRunCaches → new array → recompute.
data.push(makeRun({ started: "2026-07-01T09:00:00" }));
await internals.refreshRunCaches();
expect(runtime.getFleetStatus().completedToday).toBe(2);
});
it("invalidates the cache when the local day rolls over", async () => {
const { internals, runtime } = makeRuntime(() => [makeRun({ started: "2026-07-01T23:00:00" })]);
vi.setSystemTime(new Date("2026-07-01T23:30:00"));
await internals.refreshRunCaches();
expect(runtime.getFleetStatus().completedToday).toBe(1);
vi.setSystemTime(new Date("2026-07-02T00:01:00"));
expect(runtime.getFleetStatus().completedToday).toBe(0);
});
});
describe("run output batching", () => {
it("appends the buffer eagerly but flushes listeners at most every 100ms", () => {
const { internals, runtime } = makeRuntime();
const received: string[] = [];
runtime.onRunOutput("scout", (chunk) => received.push(chunk));
internals.emitRunOutput("scout", "one ");
internals.emitRunOutput("scout", "two");
// Buffer is current immediately; listeners haven't been pinged yet.
expect(runtime.getRunOutputBuffer("scout")).toBe("one two");
expect(received).toEqual([]);
vi.advanceTimersByTime(100);
expect(received).toEqual(["one two"]);
// A later chunk starts a fresh batch.
internals.emitRunOutput("scout", " three");
expect(received).toEqual(["one two"]);
vi.advanceTimersByTime(100);
expect(received).toEqual(["one two", " three"]);
});
it("flushes pending chunks on run end before tearing down", () => {
const { internals, runtime } = makeRuntime();
const received: string[] = [];
runtime.onRunOutput("scout", (chunk) => received.push(chunk));
internals.emitRunOutput("scout", "tail");
internals.clearRunOutput("scout");
expect(received).toEqual(["tail"]);
expect(runtime.getRunOutputBuffer("scout")).toBe("");
// No dangling timer fires after teardown.
vi.advanceTimersByTime(200);
expect(received).toEqual(["tail"]);
});
it("does not double-deliver pending chunks to a subscriber that got the buffer snapshot", () => {
const { internals, runtime } = makeRuntime();
const first: string[] = [];
const second: string[] = [];
runtime.onRunOutput("scout", (chunk) => first.push(chunk));
internals.emitRunOutput("scout", "hello");
// Subscribing flushes the batch to existing listeners, then hands the new
// listener the full buffer — so nothing is delivered twice.
runtime.onRunOutput("scout", (chunk) => second.push(chunk));
expect(first).toEqual(["hello"]);
expect(second).toEqual(["hello"]);
vi.advanceTimersByTime(200);
expect(first).toEqual(["hello"]);
expect(second).toEqual(["hello"]);
});
it("resetRunOutput drops stale batches so the next run starts clean", () => {
const { internals, runtime } = makeRuntime();
const received: string[] = [];
runtime.onRunOutput("scout", (chunk) => received.push(chunk));
internals.emitRunOutput("scout", "stale");
internals.resetRunOutput("scout");
expect(runtime.getRunOutputBuffer("scout")).toBe("");
vi.advanceTimersByTime(200);
expect(received).toEqual([]);
internals.emitRunOutput("scout", "fresh");
vi.advanceTimersByTime(100);
expect(received).toEqual(["fresh"]);
});
it("stops delivering after unsubscribe", () => {
const { internals, runtime } = makeRuntime();
const received: string[] = [];
const unsub = runtime.onRunOutput("scout", (chunk) => received.push(chunk));
internals.emitRunOutput("scout", "a");
vi.advanceTimersByTime(100);
unsub();
internals.emitRunOutput("scout", "b");
vi.advanceTimersByTime(100);
expect(received).toEqual(["a"]);
});
});
describe("notify memory-capture suffix", () => {
it("appends the captured count to the success notice", () => {
const { internals } = makeRuntime();
internals.notify(makeRun({ output: "Report ready." }), 3);
expect(notices).toHaveLength(1);
expect(notices[0]?.message).toBe("✅ scout: Report ready. · captured 3 memory facts");
});
it("uses singular wording for one capture", () => {
const { internals } = makeRuntime();
internals.notify(makeRun({ output: "Report ready." }), 1);
expect(notices[0]?.message).toContain("· captured 1 memory fact");
expect(notices[0]?.message).not.toContain("memory facts");
});
it("leaves the notice unchanged when nothing was captured", () => {
const { internals } = makeRuntime();
internals.notify(makeRun({ output: "Report ready." }));
expect(notices[0]?.message).toBe("✅ scout: Report ready.");
});
it("does not append the suffix to failure notices", () => {
const { internals } = makeRuntime();
internals.notify(makeRun({ status: "failure", exitCode: 1, output: "boom" }), 2);
expect(notices[0]?.message).toBe("❌ scout: boom");
});
});

View file

@ -54,6 +54,21 @@ export class FleetRuntime {
private statusChangeListeners = new Set<() => void>();
private runOutputListeners = new Map<string, Set<(chunk: string) => void>>();
private runOutputBuffers = new Map<string, string>();
/** Chunks accumulated since the last listener flush, per agent. Live output
* is fanned out to listeners at most every OUTPUT_FLUSH_INTERVAL_MS (plus a
* final flush at run end) so large/chatty CLI output doesn't cause a render
* per stdout chunk. `runOutputBuffers` is still appended eagerly, so
* getRunOutputBuffer() is always current. */
private runOutputPending = new Map<string, string>();
private runOutputFlushTimers = new Map<string, ReturnType<typeof setTimeout>>();
private static readonly OUTPUT_FLUSH_INTERVAL_MS = 100;
/** Cached expensive parts of getFleetStatus() it's called on every render.
* Keyed on the `recentRuns` array identity (refreshRunCaches() replaces the
* array wholesale and nothing mutates it in place, so a changed reference
* means runs/approvals changed) plus the local date, so `completedToday`
* rolls over at midnight. `running` is recomputed each call (it depends on
* runtimeState and is O(#agents)). */
private fleetStatusCache: { runs: RunLogData[]; today: string; completedToday: number; pending: number } | null = null;
/** Heartbeat cron jobs, keyed by agent name. Separate from task scheduler jobs. */
private heartbeatJobs = new Map<string, Cron>();
/** Nightly reflection cron jobs, keyed by agent name (§8). */
@ -183,17 +198,22 @@ export class FleetRuntime {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const completedToday = this.recentRuns.filter((run) => {
const d = new Date(run.started);
const runDate = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
return runDate === today;
}).length;
let cache = this.fleetStatusCache;
if (!cache || cache.runs !== this.recentRuns || cache.today !== today) {
const completedToday = this.recentRuns.filter((run) => {
const d = new Date(run.started);
const runDate = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
return runDate === today;
}).length;
const pending = this.recentRuns.flatMap((run) => run.approvals ?? []).filter((item) => item.status === "pending").length;
cache = { runs: this.recentRuns, today, completedToday, pending };
this.fleetStatusCache = cache;
}
const running = Array.from(this.runtimeState.values()).filter((state) => state.status === "running").length;
const pending = this.recentRuns.flatMap((run) => run.approvals ?? []).filter((item) => item.status === "pending").length;
return {
running,
pending,
completedToday,
pending: cache.pending,
completedToday: cache.completedToday,
};
}
@ -203,6 +223,10 @@ export class FleetRuntime {
}
onRunOutput(agentName: string, callback: (chunk: string) => void): () => void {
// Flush any batched chunks to the *existing* listeners first: the snapshot
// handed to the new listener below already contains them (the buffer is
// appended eagerly), so leaving them pending would deliver them twice.
this.flushRunOutput(agentName);
let listeners = this.runOutputListeners.get(agentName);
if (!listeners) {
listeners = new Set();
@ -220,6 +244,56 @@ export class FleetRuntime {
return this.runOutputBuffers.get(agentName) ?? "";
}
/** Record a live output chunk: append to the (always-current) buffer, and
* batch listener notification so a chatty CLI doesn't trigger a dashboard
* render per stdout chunk. Listeners receive the concatenated pending
* chunks at most every OUTPUT_FLUSH_INTERVAL_MS. */
private emitRunOutput(agentName: string, chunk: string): void {
this.runOutputBuffers.set(agentName, (this.runOutputBuffers.get(agentName) ?? "") + chunk);
this.runOutputPending.set(agentName, (this.runOutputPending.get(agentName) ?? "") + chunk);
if (!this.runOutputFlushTimers.has(agentName)) {
this.runOutputFlushTimers.set(
agentName,
setTimeout(() => this.flushRunOutput(agentName), FleetRuntime.OUTPUT_FLUSH_INTERVAL_MS),
);
}
}
/** Deliver batched chunks (concatenated, in order) to listeners now. */
private flushRunOutput(agentName: string): void {
const timer = this.runOutputFlushTimers.get(agentName);
if (timer !== undefined) {
clearTimeout(timer);
this.runOutputFlushTimers.delete(agentName);
}
const pending = this.runOutputPending.get(agentName);
if (!pending) return;
this.runOutputPending.delete(agentName);
const listeners = this.runOutputListeners.get(agentName);
if (listeners) {
for (const listener of listeners) listener(pending);
}
}
/** Reset live-output state at run start (clears any stale batch/timer). */
private resetRunOutput(agentName: string): void {
const timer = this.runOutputFlushTimers.get(agentName);
if (timer !== undefined) {
clearTimeout(timer);
this.runOutputFlushTimers.delete(agentName);
}
this.runOutputPending.delete(agentName);
this.runOutputBuffers.set(agentName, "");
}
/** Final flush + teardown of live-output state at run end, so listeners see
* the tail of the output before the buffer is dropped. */
private clearRunOutput(agentName: string): void {
this.flushRunOutput(agentName);
this.runOutputBuffers.delete(agentName);
this.runOutputListeners.delete(agentName);
}
async handleVaultChange(file: TFile): Promise<void> {
await this.repository.loadFile(file);
this.snapshot = this.repository.getSnapshot();
@ -353,6 +427,11 @@ export class FleetRuntime {
}
this.reflectionJobs.clear();
this.reflectionsInFlight.clear();
for (const [, timer] of this.runOutputFlushTimers) {
clearTimeout(timer);
}
this.runOutputFlushTimers.clear();
this.runOutputPending.clear();
this.scheduler.shutdown();
}
@ -401,10 +480,17 @@ export class FleetRuntime {
// is at least 10 seconds after registration.
if (Date.now() - this.heartbeatRegisteredAt < 10_000) return;
// Prevent duplicate runs if the previous heartbeat is still in-flight
// Prevent duplicate runs if the previous heartbeat is still in-flight.
// The check-and-add is synchronous (no await in between), so concurrent
// ticks can't both pass the guard.
if (this.heartbeatsInFlight.has(agentName)) return;
this.heartbeatsInFlight.add(agentName);
// The guard must outlive enqueue(): the scheduler resolves it when the run
// is queued/started, not when it completes. runPendingTask clears the flag
// once the heartbeat run actually finishes; clear here only when no run was
// enqueued (agent gone/disabled, or enqueue threw).
let enqueued = false;
try {
const agent = this.repository.getAgentByName(agentName);
if (!agent || !agent.enabled || !agent.heartbeatBody.trim()) return;
@ -427,8 +513,9 @@ export class FleetRuntime {
reason: "heartbeat",
promptOverride: agent.heartbeatBody.trim(),
});
enqueued = true;
} finally {
this.heartbeatsInFlight.delete(agentName);
if (!enqueued) this.heartbeatsInFlight.delete(agentName);
}
}
@ -558,7 +645,7 @@ export class FleetRuntime {
currentTaskId: reflectionTaskId,
runStarted: started,
});
this.runOutputBuffers.set(agentName, "");
this.resetRunOutput(agentName);
this.emitStatusChange();
try {
// Guarantee the legacy→v2 migration (and its raw-archive seeding) has run
@ -595,14 +682,9 @@ export class FleetRuntime {
// 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, (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 }),
this.executor.execute(agent, task, prompt, (chunk) => this.emitRunOutput(agentName, chunk), {
suppressMemoryCapture: true,
}),
);
const parsed = parseReflectionOutput(result.outputText);
@ -654,6 +736,9 @@ export class FleetRuntime {
currentRunId: result.runId,
lastRun: { ...run, filePath: runPath },
});
// Reflection runs bypass runPendingTask, so surface failures here —
// otherwise a broken nightly reflection is invisible outside run logs.
if (runStatus === "failure") this.notify(run);
return { ok: applied, message };
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
@ -683,11 +768,11 @@ export class FleetRuntime {
console.warn(`Agent Fleet: failed to write reflection run log for "${agentName}"`, writeErr);
}
this.runtimeState.set(agentName, { status: "error", lastRun });
this.notify(run);
return { ok: false, message: `Reflection failed: ${msg}` };
} finally {
this.reflectionsInFlight.delete(agentName);
this.runOutputBuffers.delete(agentName);
this.runOutputListeners.delete(agentName);
this.clearRunOutput(agentName);
this.emitStatusChange();
}
}
@ -732,27 +817,27 @@ export class FleetRuntime {
}
private async runPendingTask({ task, promptOverride }: PendingRun): Promise<void> {
// Release the cron dedup guard once the heartbeat run is truly done — see
// runHeartbeat. Manual heartbeat-tagged runs delete a flag that isn't set,
// which is harmless.
const clearHeartbeatGuard = () => {
if (task.tags.includes("heartbeat")) this.heartbeatsInFlight.delete(task.agent);
};
const agent = this.repository.getAgentByName(task.agent);
if (!agent || !agent.enabled) {
clearHeartbeatGuard();
return;
}
const started = new Date().toISOString();
this.runtimeState.set(agent.name, { status: "running", currentTaskId: task.taskId, runStarted: started });
this.runOutputBuffers.set(agent.name, "");
this.resetRunOutput(agent.name);
this.emitStatusChange();
try {
const result = await this.executor.execute(agent, task, promptOverride, (chunk) => {
const current = this.runOutputBuffers.get(agent.name) ?? "";
this.runOutputBuffers.set(agent.name, current + chunk);
const listeners = this.runOutputListeners.get(agent.name);
if (listeners) {
for (const listener of listeners) {
listener(chunk);
}
}
});
const result = await this.executor.execute(agent, task, promptOverride, (chunk) =>
this.emitRunOutput(agent.name, chunk),
);
const wasAborted = this.consumeAborted(agent.name);
const approvals = wasAborted ? [] : this.buildApprovals(agent, result.toolsUsed);
const runStatus = wasAborted ? "cancelled" : this.resolveRunStatus(result, approvals);
@ -785,12 +870,14 @@ export class FleetRuntime {
runCount: task.runCount + 1,
});
let capturedCount = 0;
if (agent.memory) {
const isHeartbeat = task.tags.includes("heartbeat");
const source = isHeartbeat ? "heartbeat" : `task:${task.taskId}`;
const entries = extractCaptures(result.outputText);
try {
await this.memoryWriter.capture(agent, entries, source, new Date().toISOString());
capturedCount = entries.length;
} catch (err) {
console.warn(`Agent Fleet: failed to append memory for "${agent.name}"`, err);
}
@ -833,7 +920,7 @@ export class FleetRuntime {
lastRun: { ...run, filePath: runPath },
});
if (!wasAborted) this.notify(run);
if (!wasAborted) this.notify(run, capturedCount);
} catch (error) {
const wasAborted = this.consumeAborted(agent.name);
const runStatus = wasAborted ? "cancelled" : "failure";
@ -862,6 +949,7 @@ export class FleetRuntime {
});
if (!wasAborted) this.notify(run);
} finally {
clearHeartbeatGuard();
// Fold any `remember` MCP-tool captures from this run into memory (§7.5).
if (agent.memory) {
try {
@ -870,8 +958,7 @@ export class FleetRuntime {
console.warn(`Agent Fleet: failed to drain pending memory for "${agent.name}"`, err);
}
}
this.runOutputBuffers.delete(agent.name);
this.runOutputListeners.delete(agent.name);
this.clearRunOutput(agent.name);
this.emitStatusChange();
}
}
@ -901,7 +988,7 @@ export class FleetRuntime {
return result.exitCode === 0 ? "success" : "failure";
}
private notify(run: RunLogData): void {
private notify(run: RunLogData, capturedCount = 0): void {
if (this.settings.notificationLevel === "none") {
return;
}
@ -915,9 +1002,12 @@ export class FleetRuntime {
.find((l) => l && !l.startsWith("{") && !l.startsWith("[")) ?? "";
const preview = firstLine.slice(0, 120) || run.status;
// Surface memory captures on successful runs so they aren't drained silently.
const captureSuffix =
capturedCount > 0 ? ` · captured ${capturedCount} memory fact${capturedCount === 1 ? "" : "s"}` : "";
const message =
run.status === "success"
? `${run.agent}: ${preview}`
? `${run.agent}: ${preview}${captureSuffix}`
: run.status === "pending_approval"
? `🔵 ${run.agent} needs approval: ${(run.approvals ?? [])[0]?.tool ?? "tool action"}`
: `${run.agent}: ${preview}`;

View file

@ -360,44 +360,55 @@ export class McpManager {
let onError: ((err: Error) => void) | null = null;
const server = http.createServer((req, res) => {
const reqUrl = new URL(req.url ?? "/", "http://localhost");
if (reqUrl.pathname !== "/callback") {
res.writeHead(404);
res.end();
return;
}
// A throw here (bad URL, dead socket on writeHead, …) would otherwise be
// an uncaught exception AND leave waitForCode hanging until its timeout —
// catch it, settle the promise, and best-effort close the response.
try {
const reqUrl = new URL(req.url ?? "/", "http://localhost");
if (reqUrl.pathname !== "/callback") {
res.writeHead(404);
res.end();
return;
}
const error = reqUrl.searchParams.get("error");
if (error) {
const desc = reqUrl.searchParams.get("error_description") ?? error;
res.writeHead(200, { "Content-Type": "text/html" });
res.end(
"<html><body style='font-family:system-ui;text-align:center;padding:60px'>"
+ "<h2>Authorization Failed</h2><p>" + desc + "</p>"
+ "<p style='color:#888'>You can close this tab.</p>"
+ "</body></html>",
);
onError?.(new Error(`OAuth denied: ${desc}`));
return;
}
const code = reqUrl.searchParams.get("code");
const state = reqUrl.searchParams.get("state");
if (!code || !state) {
res.writeHead(400);
res.end("Missing code or state");
return;
}
const error = reqUrl.searchParams.get("error");
if (error) {
const desc = reqUrl.searchParams.get("error_description") ?? error;
res.writeHead(200, { "Content-Type": "text/html" });
res.end(
"<html><body style='font-family:system-ui;text-align:center;padding:60px'>"
+ "<h2>Authorization Failed</h2><p>" + desc + "</p>"
+ "<p style='color:#888'>You can close this tab.</p>"
+ "<h2 style='color:#22c55e'>Authenticated!</h2>"
+ "<p>You can close this tab and return to Obsidian.</p>"
+ "<script>window.setTimeout(()=>window.close(),2000)</script>"
+ "</body></html>",
);
onError?.(new Error(`OAuth denied: ${desc}`));
return;
onResult?.(code, state);
} catch (err) {
try {
if (!res.headersSent) res.writeHead(500);
res.end();
} catch { /* ignore */ }
onError?.(err instanceof Error ? err : new Error(String(err)));
}
const code = reqUrl.searchParams.get("code");
const state = reqUrl.searchParams.get("state");
if (!code || !state) {
res.writeHead(400);
res.end("Missing code or state");
return;
}
res.writeHead(200, { "Content-Type": "text/html" });
res.end(
"<html><body style='font-family:system-ui;text-align:center;padding:60px'>"
+ "<h2 style='color:#22c55e'>Authenticated!</h2>"
+ "<p>You can close this tab and return to Obsidian.</p>"
+ "<script>window.setTimeout(()=>window.close(),2000)</script>"
+ "</body></html>",
);
onResult?.(code, state);
});
const port = await new Promise<number>((resolve, reject) => {
@ -433,7 +444,13 @@ export class McpManager {
reject(err);
};
}),
close: () => { try { server.close(); } catch { /* ignore */ } },
close: () => {
// Drop lingering keep-alive connections first — server.close() alone
// waits for them, which would keep the port bound and break the next
// auth attempt with "address already in use".
try { server.closeAllConnections(); } catch { /* ignore */ }
try { server.close(); } catch { /* ignore */ }
},
};
}
@ -512,7 +529,12 @@ export class McpManager {
});
req.on("error", reject);
const timer = window.setTimeout(() => { req.destroy(); reject(new Error("OAuth request timed out")); }, 15000);
const timer = window.setTimeout(() => {
// destroy() can throw on an already-broken socket — never let that
// skip the reject or leave the socket lingering.
try { req.destroy(); } catch { /* ignore */ }
reject(new Error("OAuth request timed out"));
}, 15000);
req.on("close", () => window.clearTimeout(timer));
if (body) req.write(body);
req.end();

View file

@ -23,6 +23,7 @@
// write failure returns null so the run proceeds with no fleet MCP rather than
// aborting, and one bad server is dropped (logged) without poisoning the rest.
import { randomUUID } from "crypto";
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "fs";
import { join } from "path";
import type { McpServer, McpTransport } from "../types";
@ -56,9 +57,6 @@ export interface McpProjection {
tempFiles: string[];
}
/** Monotonic suffix so concurrent installs in the same cwd never collide. */
let projectionSeq = 0;
/**
* Descriptor for the per-run `remember` capture tool, fed through the same
* projection pipe as any other stdio server. `AF_PENDING_DIR` / `AF_SOURCE` are
@ -254,7 +252,9 @@ export function installMcpProjection(
const claudeDir = join(cwd, ".claude");
try {
if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
const token = `${process.pid}-${Date.now()}-${projectionSeq++}`;
// Random token so concurrent installs — even across processes — never
// collide. (pid+time+counter could repeat across two plugin processes.)
const token = randomUUID();
// Prepare each server (materialize inline scripts). Drop any that fail so a
// single broken definition doesn't take down the whole run.

View file

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentConfig, MemorySection, WorkingMemory } from "../types";
import { appendEntries, emptyWorkingMemory } from "../utils/memoryFormat";
import { MemoryWriter, type MemoryStore } from "./memoryWriter";
@ -198,6 +198,44 @@ describe("MemoryWriter", () => {
expect(store.migrated).toEqual(["Agent A"]);
});
it("a hung store write does not wedge subsequent captures (lock slot times out)", async () => {
vi.useFakeTimers();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const store = new FakeStore();
// First write hangs forever; later writes behave normally.
let hangNext = true;
const realWrite = store.writeWorkingMemory.bind(store);
store.writeWorkingMemory = (name, wm) => {
if (hangNext) {
hangNext = false;
return new Promise<void>(() => {}); // never settles
}
return realWrite(name, wm);
};
const w = new MemoryWriter(store);
// Fire-and-forget, mirroring how ChatSession invokes capture. This one
// wedges inside writeWorkingMemory and never settles.
void w.capture(agent(), [{ text: "stuck fact" }], "chat", "2026-06-13T10:00:00Z");
// Let the first task reach the hung write before queuing the second.
await vi.advanceTimersByTimeAsync(0);
const second = w.capture(agent(), [{ text: "later fact" }], "chat", "2026-06-13T10:00:01Z");
// Nothing moves until the stuck task's lock slot times out.
await vi.advanceTimersByTimeAsync(9_999);
expect(store.entryTexts("Agent A")).toEqual([]);
await vi.advanceTimersByTimeAsync(10_000);
await second;
expect(store.entryTexts("Agent A")).toContain("later fact");
expect(warn).toHaveBeenCalledWith(expect.stringContaining("releasing its lock slot"));
} finally {
warn.mockRestore();
vi.useRealTimers();
}
});
it("enforces the hard cap, spilling oldest entries to raw-only", async () => {
const store = new FakeStore();
const w = new MemoryWriter(store);

View file

@ -190,18 +190,36 @@ export class MemoryWriter {
await this.store.writeWorkingMemory(agent.name, next);
}
/** Per-task timeout for the lock chain one hung vault write must not
* wedge every later capture for the agent forever. */
private static readonly LOCK_TIMEOUT_MS = 10_000;
/** Run `fn` after any in-flight memory op for `key` completes. FIFO. */
private withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
const prev = MemoryWriter.locks.get(key) ?? Promise.resolve();
const next = prev.then(fn, fn);
// Keep the chain alive even if fn rejects, but don't swallow the caller's error.
MemoryWriter.locks.set(
key,
next.then(
() => undefined,
() => undefined,
),
);
// The chain link resolves when fn settles OR its timeout fires, so the
// chain survives both rejections (without swallowing the caller's error)
// and hangs. A timed-out task keeps running detached — it can't be
// cancelled — but its lock slot is released so later captures proceed.
let release!: () => void;
const link = new Promise<void>((resolve) => { release = resolve; });
const run = (): Promise<T> => {
const timer = setTimeout(() => {
console.warn(
`Agent Fleet: memory write for "${key}" still pending after ` +
`${MemoryWriter.LOCK_TIMEOUT_MS}ms — releasing its lock slot`,
);
release();
}, MemoryWriter.LOCK_TIMEOUT_MS);
const result = fn();
result.then(
() => { clearTimeout(timer); release(); },
() => { clearTimeout(timer); release(); },
);
return result;
};
const next = prev.then(run, run);
MemoryWriter.locks.set(key, link);
return next;
}
}

View file

@ -0,0 +1,80 @@
// Shared agent-prompt assembly for the one-shot run path (ExecutionManager)
// and the chat path (ChatSession). Both paths inject the same core context —
// agent body, shared skills, agent-specific skills/context, working memory,
// and wiki references — and this module is the single source of truth for
// that sequence so the two cannot silently diverge.
//
// Genuinely path-specific sections stay with their callers:
// - ExecutionManager appends the `## Task` section (task body or override).
// - ChatSession appends the `## Thread Mode` replay (side threads) and frames
// the first user message as the `## Task` section; its chat-only
// `## Channel Context` section travels through `opts.channelContext`
// because it sits BETWEEN memory and wiki access in the sequence.
import type { AgentConfig } from "../types";
import type { FleetRepository } from "../fleetRepository";
import { buildWikiReferencesContext } from "../utils/wikiReferences";
import { buildMemorySection } from "../utils/memoryFormat";
export interface AgentPromptOptions {
/** Whether the `## Memory` section is injected. The run path passes
* `agent.memory && !suppressMemoryCapture` (reflection runs must not see
* or be told to capture into the memory they are consolidating); the chat
* path passes `agent.memory` unchanged. */
memoryActive: boolean;
/** Chat-only: channel instructions (e.g. "you are talking via Slack"),
* injected after memory and before wiki access. Omitted/blank = no section. */
channelContext?: string;
}
/**
* Build the ordered common prompt sections for an agent. Returns the raw
* section array (possibly containing an empty agent body) callers append
* their path-specific sections and then `filter(Boolean).join("\n\n")`.
*/
export async function buildAgentPromptSections(
repository: FleetRepository,
agent: AgentConfig,
opts: AgentPromptOptions,
): Promise<string[]> {
const sections: string[] = [agent.body.trim()];
// Shared skills
for (const skillName of agent.skills) {
const skill = repository.getSkillByName(skillName);
if (skill) {
const parts = [skill.body.trim()];
if (skill.toolsBody.trim()) parts.push(`### Tools\n${skill.toolsBody.trim()}`);
if (skill.referencesBody.trim()) parts.push(`### References\n${skill.referencesBody.trim()}`);
if (skill.examplesBody.trim()) parts.push(`### Examples\n${skill.examplesBody.trim()}`);
sections.push(`## Skill: ${skill.name}\n${parts.join("\n\n")}`);
}
}
// Agent-specific skills (from SKILLS.md in folder agents)
if (agent.skillsBody.trim()) {
sections.push(`## Agent Skills\n${agent.skillsBody.trim()}`);
}
// Agent context (from CONTEXT.md in folder agents)
if (agent.contextBody.trim()) {
sections.push(`## Agent Context\n${agent.contextBody.trim()}`);
}
if (opts.memoryActive) {
const wm = await repository.readWorkingMemory(agent.name);
const memorySection = buildMemorySection(agent, wm);
if (memorySection) sections.push(memorySection);
}
// Channel context (chat-only) is appended after the agent's own sections so
// it takes priority without shadowing the agent's identity.
if (opts.channelContext && opts.channelContext.trim()) {
sections.push(`## Channel Context\n${opts.channelContext.trim()}`);
}
// Wiki references — consumer-mode access to one or more Wiki Keeper scopes.
const wikiContext = buildWikiReferencesContext(agent, repository);
if (wikiContext) sections.push(wikiContext);
return sections;
}

View file

@ -117,6 +117,7 @@ function handle(req) {
}
`;
import { randomUUID } from "crypto";
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "fs";
import { join } from "path";
import { normalizeAdapter } from "../adapters";
@ -134,17 +135,15 @@ export interface RememberToolInstall {
tempFiles: string[];
}
/** Monotonic suffix so concurrent installs in the same cwd never collide. */
let installSeq = 0;
/**
* Write the per-run temp MCP server script + config into `<cwd>/.claude` and
* return the config path (for `--mcp-config`) plus the temp files to clean up.
* Returns null when there is no absolute pending dir (e.g. mobile vault).
*
* Filenames are made UNIQUE per install (pid + time + counter) so two agents or
* a task+chat running in the same cwd (the default vault root) can't clobber
* each other's config or have one run's cleanup delete a peer's live files.
* Filenames are made UNIQUE per install (random UUID) so two agents or a
* task+chat running in the same cwd (the default vault root) even across
* processes can't clobber each other's config or have one run's cleanup
* delete a peer's live files.
*/
export function installRememberTool(
cwd: string,
@ -158,7 +157,7 @@ export function installRememberTool(
try {
const claudeDir = join(cwd, ".claude");
if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
const token = `${process.pid}-${Date.now()}-${installSeq++}`;
const token = randomUUID();
const scriptPath = join(claudeDir, `af-remember-mcp.${token}.cjs`);
const configPath = join(claudeDir, `af-remember-mcp.${token}.json`);
writeFileSync(scriptPath, REMEMBER_MCP_SERVER_SOURCE, "utf-8");

View file

@ -89,16 +89,34 @@ export function writeClaudeSettingsFile(
}
/** Best-effort cleanup. Restores the user's pre-existing settings.local.json
* if one was backed up, otherwise removes the file we wrote. Errors swallow. */
* if one was backed up, otherwise removes the file we wrote. Never throws
* (this runs in spawn handlers), but failures are logged: a leftover
* agent-written settings file silently contaminates the next Claude run in
* that directory. If restoring the backup fails, we fall back to deleting
* the file so at least the agent's temporary config doesn't persist. */
export function restoreClaudeSettingsFile(state: ClaudeSettingsState | null): void {
if (!state) return;
try {
if (state.backupContent !== null) {
if (state.backupContent !== null) {
try {
writeFileSync(state.path, state.backupContent, "utf-8");
} else if (existsSync(state.path)) {
return;
} catch (err) {
console.warn(
`Agent Fleet: failed to restore the previous ${state.path} ` +
`(${err instanceof Error ? err.message : String(err)}); ` +
`deleting the temporary agent settings file instead so it doesn't affect later runs.`,
);
}
}
try {
if (existsSync(state.path)) {
unlinkSync(state.path);
}
} catch {
// Best-effort cleanup — don't crash a spawn handler over this.
} catch (err) {
console.warn(
`Agent Fleet: failed to remove the temporary agent settings file at ${state.path} ` +
`(${err instanceof Error ? err.message : String(err)}). ` +
`Stale agent permissions may leak into the next Claude run in this directory — delete it manually.`,
);
}
}

View file

@ -1894,7 +1894,7 @@ export class AgentChatView extends ItemView {
// timeout, etc). Render a red error bubble so the user knows
// exactly what happened; state clean-up is handled by the session.
const msg = event.errorMessage?.trim() || "The agent's run ended with an error.";
this.addBubble("error", `Error: ${msg}`);
this.addErrorBubble(msg);
} else if (event.type === "compacted") {
// /compact completed — not a bubble anymore. The session writes
// `stats.lastCompact` and re-emits stats, so the inline notice
@ -1930,11 +1930,19 @@ export class AgentChatView extends ItemView {
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (msg !== "Aborted") {
this.addBubble("error", `Error: ${msg}`);
this.addErrorBubble(msg);
}
}
}
/** Render a red error bubble, plus a muted one-line recovery hint below
* the message when the error matches a known failure mode. */
private addErrorBubble(msg: string): void {
const bubble = this.addBubble("error", `Error: ${msg}`);
const hint = recoveryHintFor(msg);
if (hint) bubble.createDiv({ cls: "af-chat-error-hint", text: hint });
}
/** "+ New chat" creates a brand-new conversation alongside any existing
* ones, leaving every other chat's history intact. */
private async handleNewChat(): Promise<void> {
@ -1989,12 +1997,34 @@ export class AgentChatView extends ItemView {
}).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
if (msg !== "Aborted") {
this.addBubble("error", `Error: ${msg}`);
this.addErrorBubble(msg);
}
});
}
}
/** Pattern-match common CLI/API failures and return a one-line recovery
* hint, or null when the error isn't recognized (no hint rendered). */
export function recoveryHintFor(errorText: string): string | null {
const t = errorText.toLowerCase();
if (/context\s*(window|length)|prompt is too long|too many tokens|token limit|maximum context|context.{0,20}exceed/.test(t)) {
return "Try a smaller model, or start a new session to clear history.";
}
if (/rate.?limit|\b429\b|overloaded|too many requests/.test(t)) {
return "Wait a minute and retry, or check your API quota.";
}
if (/\b401\b|unauthorized|api.?key|authentication|invalid.{0,10}credential|not logged in/.test(t)) {
return "Check your API key / CLI login (run the CLI's login command in a terminal).";
}
if (/timed out|time.?out|watchdog/.test(t)) {
return "A tool may be stuck. Retry, or raise the Chat Watchdog Timeout in Settings.";
}
if (/enoent|command not found|no such file or directory/.test(t)) {
return "CLI binary not found — check the CLI path in Settings.";
}
return null;
}
function formatTokens(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k`;
return `${n}`;

File diff suppressed because it is too large Load diff

1299
src/views/forms/agentForm.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,420 @@
import { Notice, setIcon } from "obsidian";
import { ConfirmModal } from "../../modals/confirmModal";
import type AgentFleetPlugin from "../../main";
import type { ChannelConfig } from "../../types";
import { slugify, stringifyMarkdownWithFrontmatter } from "../../utils/markdown";
import { createIcon } from "../../utils/icons";
/**
* Map a channel status to the existing `af-agent-card-avatar` color class so that
* channel cards borrow the same visual language as agent cards (green=healthy,
* blue=transitioning, red=broken, gray=off).
*/
export function channelStatusToAvatarClass(status: string): string {
switch (status) {
case "connected":
return "idle"; // green
case "connecting":
case "reconnecting":
return "pending"; // blue
case "needs-auth":
case "error":
return "error"; // red
case "stopped":
case "disabled":
default:
return "disabled"; // gray
}
}
/** View helpers the channel form borrows from the dashboard so the extracted
* markup stays byte-identical to what the view used to render inline. */
export interface ChannelFormDeps {
plugin: AgentFleetPlugin;
/** Navigate the dashboard to another page (post-save / cancel / delete). */
navigate: (page: "channels" | "edit-channel", context?: string) => void;
/** Append a small info icon with a hover tooltip to a label element. */
addTooltip: (labelEl: HTMLElement, text: string) => void;
/** Standard labelled text-input form row. */
createFormField: (
container: HTMLElement,
label: string,
placeholder: string,
desc: string,
onChange: (v: string) => void,
initialValue?: string,
) => void;
/** Disable a submit button while an async save is in flight; returns a restore fn. */
markSubmitBusy: (
btn: HTMLButtonElement,
busyLabel: string,
iconName: string,
label: string,
) => () => void;
}
/**
* Shared channel form used by both the Create Channel and Edit Channel pages.
* Pass `channel` to render in edit mode (pre-filled fields, read-only name,
* delete button, "Save Changes" submit); omit it for create mode.
*/
export function renderChannelForm(
page: HTMLElement,
deps: ChannelFormDeps,
channel?: ChannelConfig,
): void {
const { plugin } = deps;
const snapshot = plugin.runtime.getSnapshot();
const credentials = plugin.channelCredentials.list();
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
if (channel) {
const status = plugin.channelManager?.getChannelStatus(channel.name) ?? "disabled";
const avatarEl = headerLeft.createDiv({ cls: `af-agent-card-avatar ${channelStatusToAvatarClass(status)}` });
setIcon(avatarEl, "radio");
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Channel: ${channel.name}` });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: `Status: ${status}` });
} else {
const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
setIcon(avatar, "plus");
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Channel" });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Connect an external chat transport to an agent" });
}
header.createDiv({ cls: "af-detail-header-actions" });
// Form state (pre-filled in edit mode)
const state = {
name: "",
type: (channel ? channel.type : "slack") as ChannelConfig["type"],
defaultAgent: channel ? channel.defaultAgent : (snapshot.agents[0]?.name ?? ""),
allowedAgents: channel ? [...channel.allowedAgents] : ([] as string[]),
credentialRef: channel ? channel.credentialRef : (credentials[0]?.ref ?? ""),
allowedUsers: channel ? channel.allowedUsers.join("\n") : "",
perUserSessions: channel ? channel.perUserSessions : true,
channelContext: channel ? channel.channelContext : "",
enabled: channel ? channel.enabled : true,
tags: channel ? channel.tags.join(", ") : "",
body: channel ? channel.body : "",
transportJson: channel && Object.keys(channel.transport).length > 0
? JSON.stringify(channel.transport, null, 2)
: "",
};
const form = page.createDiv({ cls: "af-create-form" });
// ─── Channel Details ───
const detailsSection = form.createDiv({ cls: "af-create-section" });
const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" });
const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(detailsIcon, "radio");
detailsHeader.createSpan({ text: "Channel Details" });
if (channel) {
// Name (read-only)
const nameRow = detailsSection.createDiv({ cls: "af-form-row" });
nameRow.createDiv({ cls: "af-form-label", text: "Name" });
const nameInput = nameRow.createEl("input", {
cls: "af-form-input",
attr: { type: "text", value: channel.name, disabled: "true" },
});
nameInput.setCssStyles({ opacity: "0.6" });
} else {
deps.createFormField(detailsSection, "Name", "my-slack", "Unique identifier for this channel", (v) => { state.name = v; });
}
// Type dropdown — only Slack is supported in v1; others shown as disabled
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", "discord"] as const) {
const opt = typeSelect.createEl("option", { text: t, attr: { value: t } });
if (channel && t === channel.type) opt.selected = true;
}
typeSelect.addEventListener("change", () => { state.type = typeSelect.value as ChannelConfig["type"]; });
// Credential dropdown
const credRow = detailsSection.createDiv({ cls: "af-form-row" });
const credLabel = credRow.createDiv({ cls: "af-form-label" });
credLabel.setText("Credential");
deps.addTooltip(credLabel, "Configured in Settings → Agent Fleet → Channel Credentials");
const credSelect = credRow.createEl("select", { cls: "af-form-select" });
if (credentials.length === 0) {
credSelect.createEl("option", { text: "(no credentials configured)", attr: { value: "" } });
}
for (const c of credentials) {
const opt = credSelect.createEl("option", { text: `${c.ref} (${c.entry.type})`, attr: { value: c.ref } });
if (channel && c.ref === channel.credentialRef) opt.selected = true;
}
credSelect.addEventListener("change", () => { state.credentialRef = credSelect.value; });
// Enabled toggle
const enabledRow = detailsSection.createDiv({ cls: "af-form-row af-form-row-toggle" });
enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" });
const enabledToggle = enabledRow.createDiv({ cls: `af-agent-card-toggle${state.enabled ? " on" : ""}` });
enabledToggle.onclick = () => {
const isOn = enabledToggle.hasClass("on");
enabledToggle.toggleClass("on", !isOn);
state.enabled = !isOn;
};
// ─── Agent Routing ───
const agentSection = form.createDiv({ cls: "af-create-section" });
const agentHeader = agentSection.createDiv({ cls: "af-create-section-header" });
const agentIcon = agentHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(agentIcon, "bot");
agentHeader.createSpan({ text: "Agent Routing" });
// Default agent dropdown
const defaultRow = agentSection.createDiv({ cls: "af-form-row" });
const defaultLabel = defaultRow.createDiv({ cls: "af-form-label" });
defaultLabel.setText("Default agent");
deps.addTooltip(defaultLabel, "Used when no @agent-name prefix is given in a message");
const defaultSelect = defaultRow.createEl("select", { cls: "af-form-select" });
for (const a of snapshot.agents) {
const opt = defaultSelect.createEl("option", { text: a.name, attr: { value: a.name } });
if (channel && a.name === channel.defaultAgent) opt.selected = true;
}
defaultSelect.addEventListener("change", () => { state.defaultAgent = defaultSelect.value; });
// Allowed agents checkboxes
const allowedRow = agentSection.createDiv({ cls: "af-form-row" });
const allowedLabel = allowedRow.createDiv({ cls: "af-form-label" });
allowedLabel.setText("Allowed agents");
deps.addTooltip(allowedLabel, "Agents reachable via @prefix. Leave unchecked to allow all agents.");
const checkboxContainer = allowedRow.createDiv({ cls: "af-form-checkboxes" });
for (const a of snapshot.agents) {
const label = checkboxContainer.createEl("label", { cls: "af-form-checkbox-label" });
const cb = label.createEl("input", { attr: { type: "checkbox", value: a.name } });
if (channel && channel.allowedAgents.includes(a.name)) cb.checked = true;
label.appendText(` ${a.name}`);
cb.addEventListener("change", () => {
if (cb.checked) {
if (!state.allowedAgents.includes(a.name)) state.allowedAgents.push(a.name);
} else {
state.allowedAgents = state.allowedAgents.filter((n) => n !== a.name);
}
});
}
// Per-user sessions toggle
const perUserRow = agentSection.createDiv({ cls: "af-form-row af-form-row-toggle" });
const perUserLabel = perUserRow.createDiv({ cls: "af-form-label" });
perUserLabel.setText("Per-user sessions");
deps.addTooltip(perUserLabel, "Each external user gets their own isolated Claude session");
const perUserToggle = perUserRow.createDiv({ cls: `af-agent-card-toggle${state.perUserSessions ? " on" : ""}` });
perUserToggle.onclick = () => {
const isOn = perUserToggle.hasClass("on");
perUserToggle.toggleClass("on", !isOn);
state.perUserSessions = !isOn;
};
// ─── Access Control ───
const accessSection = form.createDiv({ cls: "af-create-section" });
const accessHeader = accessSection.createDiv({ cls: "af-create-section-header" });
const accessIcon = accessHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(accessIcon, "shield-check");
accessHeader.createSpan({ text: "Access Control" });
const usersLabel = accessSection.createDiv({ cls: "af-form-label" });
usersLabel.setText("Allowed users");
deps.addTooltip(
usersLabel,
channel
? "Slack user IDs (U...), one per line. Only listed users can reach the bot."
: "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" },
});
if (channel) usersTextarea.value = channel.allowedUsers.join("\n");
usersTextarea.addEventListener("input", () => { state.allowedUsers = usersTextarea.value; });
// ─── Channel Context ───
const contextSection = form.createDiv({ cls: "af-create-section" });
const contextHeader = contextSection.createDiv({ cls: "af-create-section-header" });
const contextIcon = contextHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(contextIcon, "message-square");
const contextHeaderLabel = contextHeader.createSpan({ text: "Channel Context" });
deps.addTooltip(contextHeaderLabel, "Extra instructions appended to the agent's system prompt when reached through this channel");
const contextTextarea = contextSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "You are being contacted via Slack. Keep replies concise...", rows: "6" },
});
if (channel) contextTextarea.value = channel.channelContext;
contextTextarea.addEventListener("input", () => { state.channelContext = contextTextarea.value; });
deps.createFormField(
detailsSection, "Tags", "ops, internal", "Comma-separated metadata",
(v) => { state.tags = v; },
channel ? channel.tags.join(", ") : undefined,
);
// ─── Advanced ───
const advSection = form.createDiv({ cls: "af-create-section" });
const advHeader = advSection.createDiv({ cls: "af-create-section-header" });
const advIcon = advHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(advIcon, "settings");
const advHeaderLabel = advHeader.createSpan({ text: "Advanced" });
deps.addTooltip(advHeaderLabel, "Markdown body (shown in the channel detail page) and transport-specific overrides");
const bodyLabel = advSection.createDiv({ cls: "af-form-label" });
bodyLabel.setText("Body (markdown)");
if (!channel) {
deps.addTooltip(bodyLabel, "Free-form notes for this channel. Shown in the channel detail page; not sent to the agent.");
}
const bodyTextarea = advSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "Notes, runbook snippets, escalation contacts…", rows: "4" },
});
if (channel) bodyTextarea.value = channel.body;
bodyTextarea.addEventListener("input", () => { state.body = bodyTextarea.value; });
const transportLabel = advSection.createDiv({ cls: "af-form-label" });
transportLabel.setText("Transport config (JSON)");
deps.addTooltip(
transportLabel,
channel
? "Optional JSON object for transport-specific overrides (e.g. Slack socket_mode). Leave blank for defaults."
: "Optional JSON object for transport-specific overrides (e.g. Slack socket_mode, telegram webhook settings). Leave blank for defaults.",
);
const transportTextarea = advSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: '{\n "socket_mode": true\n}', rows: "4" },
});
if (channel) transportTextarea.value = state.transportJson;
transportTextarea.addEventListener("input", () => { state.transportJson = transportTextarea.value; });
// ─── Footer ───
const footer = page.createDiv({ cls: "af-create-footer" });
if (channel) {
const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" });
createIcon(deleteBtn, "trash-2", "af-btn-icon");
deleteBtn.appendText(" Delete");
deleteBtn.onclick = () => {
new ConfirmModal(plugin.app, {
title: `Delete channel "${channel.name}"?`,
body: "The channel file will be moved to your system trash and can be recovered.",
confirmText: "Delete",
danger: true,
onConfirm: async () => {
await plugin.repository.deleteChannel(channel.name);
new Notice(`Channel "${channel.name}" deleted.`);
await new Promise((r) => window.setTimeout(r, 200));
await plugin.refreshFromVault();
deps.navigate("channels");
},
}).open();
};
footer.createDiv({ cls: "af-toolbar-spacer" });
}
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
cancelBtn.onclick = () => deps.navigate("channels");
const submitBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
createIcon(submitBtn, channel ? "check" : "plus", "af-btn-icon");
submitBtn.appendText(channel ? " Save Changes" : " Create Channel");
const parseUsers = (s: string) => s.split(/[\n,]+/).map((t) => t.trim()).filter(Boolean);
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
/** Parse the transport JSON textarea; returns `null` (after a Notice) when invalid. */
const parseTransport = (): Record<string, unknown> | undefined | null => {
if (!state.transportJson.trim()) return undefined;
try {
const parsed: unknown = JSON.parse(state.transportJson);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
new Notice("Transport config must be a JSON object.");
return null;
} catch (err) {
new Notice(`Transport JSON is invalid: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
};
if (channel) {
// Edit mode: update the existing channel file in place.
submitBtn.onclick = async () => {
let transport = parseTransport();
if (transport === null) return;
if (transport === undefined) transport = {};
const restoreSubmit = deps.markSubmitBusy(submitBtn, "Saving...", "check", "Save Changes");
try {
await plugin.repository.updateChannel(channel.name, {
type: state.type,
default_agent: state.defaultAgent,
allowed_agents: state.allowedAgents.length > 0 ? state.allowedAgents : [],
enabled: state.enabled,
credential_ref: state.credentialRef,
allowed_users: parseUsers(state.allowedUsers),
per_user_sessions: state.perUserSessions,
channel_context: state.channelContext.trim(),
tags: parseTags(state.tags),
body: state.body.trim(),
transport,
});
new Notice(`Channel "${channel.name}" updated.`);
await plugin.refreshFromVault();
deps.navigate("channels");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to update channel: ${msg}`);
restoreSubmit();
}
};
} else {
// Create mode: write a new channel file.
submitBtn.onclick = async () => {
const name = state.name.trim();
if (!name) { new Notice("Channel name is required."); return; }
if (!state.credentialRef) { new Notice("Select a credential."); return; }
const transport = parseTransport();
if (transport === null) return;
const frontmatter: Record<string, unknown> = {
name: slugify(name),
type: state.type,
default_agent: state.defaultAgent,
allowed_agents: state.allowedAgents.length > 0 ? state.allowedAgents : undefined,
enabled: state.enabled,
credential_ref: state.credentialRef,
allowed_users: parseUsers(state.allowedUsers),
per_user_sessions: state.perUserSessions,
channel_context: state.channelContext.trim() || undefined,
tags: parseTags(state.tags).length > 0 ? parseTags(state.tags) : undefined,
transport,
};
const restoreSubmit = deps.markSubmitBusy(submitBtn, "Creating...", "plus", "Create Channel");
try {
const channelSlug = slugify(name);
const path = await plugin.repository.getAvailablePath(
plugin.repository.getSubfolder("channels"),
channelSlug,
);
await plugin.app.vault.create(
path,
stringifyMarkdownWithFrontmatter(frontmatter, state.body.trim()),
);
new Notice(`Channel "${channelSlug}" created.`);
await plugin.refreshFromVault();
deps.navigate("edit-channel", channelSlug);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to create channel: ${msg}`);
restoreSubmit();
}
};
}
}

251
src/views/forms/mcpForm.ts Normal file
View file

@ -0,0 +1,251 @@
import { Notice, setIcon } from "obsidian";
import type { McpServer } from "../../types";
import { splitLines } from "../../utils/platform";
import { createIcon } from "../../utils/icons";
import type { DashboardFormDeps } from "./shared";
/** View helpers the MCP server form borrows from the dashboard. */
export interface McpFormDeps extends DashboardFormDeps {
/** Navigate the dashboard to another page (post-save / cancel). */
navigate: (page: "mcp") => void;
}
/**
* The "Add MCP Server" registration form: stdio (local process) or http/sse
* (remote) transports, with optional bearer-token/OAuth auth. Secrets go to
* the OS keychain via `plugin.mcpAuth`, never into the vault file.
*/
export function renderAddMcpServerForm(page: HTMLElement, deps: McpFormDeps): void {
const { plugin } = deps;
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
setIcon(avatar, "plus");
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: "Add MCP Server" });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Register a new MCP server for agents to use" });
header.createDiv({ cls: "af-detail-header-actions" });
// Form state
const state: {
name: string;
transport: "stdio" | "http" | "sse";
description: string;
command: string;
args: string;
envVars: string;
url: string;
headers: string;
auth: "none" | "bearer" | "oauth";
bearerToken: string;
} = {
name: "",
transport: "stdio",
description: "",
command: "",
args: "",
envVars: "",
url: "",
headers: "",
auth: "none",
bearerToken: "",
};
const form = page.createDiv({ cls: "af-create-form" });
// ─── Server Details ───
const detailsSection = form.createDiv({ cls: "af-create-section" });
const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" });
const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(detailsIcon, "plug");
detailsHeader.createSpan({ text: "Server Details" });
deps.createFormField(detailsSection, "Name", "my-server", "Unique name for this MCP server", (v) => { state.name = v; });
// Transport dropdown
const transportRow = detailsSection.createDiv({ cls: "af-form-row" });
const transportLabel = transportRow.createDiv({ cls: "af-form-label" });
transportLabel.setText("Transport");
deps.addTooltip(transportLabel, "stdio: local process, http/sse: remote server");
const transportSelect = transportRow.createEl("select", { cls: "af-form-select" });
transportSelect.createEl("option", { text: "stdio", attr: { value: "stdio" } });
transportSelect.createEl("option", { text: "http", attr: { value: "http" } });
transportSelect.createEl("option", { text: "sse", attr: { value: "sse" } });
deps.createFormField(detailsSection, "Description", "What this server does (optional)", "Shown on the server card", (v) => { state.description = v; });
// ─── stdio fields ───
const stdioSection = form.createDiv({ cls: "af-create-section" });
const stdioHeader = stdioSection.createDiv({ cls: "af-create-section-header" });
const stdioIcon = stdioHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(stdioIcon, "terminal");
stdioHeader.createSpan({ text: "Process Configuration" });
deps.createFormField(stdioSection, "Command", "npx @anthropic-ai/mcp-server-memory", "The command to run", (v) => { state.command = v; });
deps.createFormField(stdioSection, "Arguments", "--port 3000", "Space-separated arguments (optional)", (v) => { state.args = v; });
const envLabel = stdioSection.createDiv({ cls: "af-form-label" });
envLabel.setText("Environment variables");
deps.addTooltip(envLabel, "One KEY=VALUE per line");
const envTextarea = stdioSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "API_KEY=sk-...\nDEBUG=true", rows: "3" },
});
envTextarea.addEventListener("input", () => { state.envVars = envTextarea.value; });
// ─── http/sse fields ───
const httpSection = form.createDiv({ cls: "af-create-section" });
const httpHeader = httpSection.createDiv({ cls: "af-create-section-header" });
const httpIcon = httpHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(httpIcon, "globe");
httpHeader.createSpan({ text: "Remote Server Configuration" });
deps.createFormField(httpSection, "URL", "https://mcp.example.com/sse", "Server endpoint URL", (v) => { state.url = v; });
const headersLabel = httpSection.createDiv({ cls: "af-form-label" });
headersLabel.setText("Custom headers");
deps.addTooltip(headersLabel, "One Header: Value per line (optional, non-secret)");
const headersTextarea = httpSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "X-Custom-Header: value", rows: "2" },
});
headersTextarea.addEventListener("input", () => { state.headers = headersTextarea.value; });
// Auth dropdown
const authRow = httpSection.createDiv({ cls: "af-form-row" });
const authLabel = authRow.createDiv({ cls: "af-form-label" });
authLabel.setText("Authentication");
deps.addTooltip(authLabel, "none, a static bearer token, or OAuth (authenticate after saving)");
const authSelect = authRow.createEl("select", { cls: "af-form-select" });
authSelect.createEl("option", { text: "None", attr: { value: "none" } });
authSelect.createEl("option", { text: "Bearer token", attr: { value: "bearer" } });
authSelect.createEl("option", { text: "OAuth", attr: { value: "oauth" } });
// Bearer token field (shown only for auth=bearer). Stored in the keychain,
// never in the vault.
const bearerRow = httpSection.createDiv({ cls: "af-form-row" });
const bearerLabel = bearerRow.createDiv({ cls: "af-form-label" });
bearerLabel.setText("Bearer token");
deps.addTooltip(bearerLabel, "Stored securely in the OS keychain, never written to the vault");
const bearerInput = bearerRow.createEl("input", { cls: "af-form-input", attr: { type: "password", placeholder: "sk-…" } });
bearerInput.addEventListener("input", () => { state.bearerToken = bearerInput.value; });
const updateAuthVisibility = () => {
bearerRow.setCssStyles({ display: state.auth === "bearer" ? "" : "none" });
};
authSelect.addEventListener("change", () => {
state.auth = authSelect.value as "none" | "bearer" | "oauth";
updateAuthVisibility();
});
updateAuthVisibility();
// Toggle section visibility based on transport
const updateTransportVisibility = () => {
stdioSection.setCssStyles({ display: state.transport === "stdio" ? "" : "none" });
httpSection.setCssStyles({ display: state.transport !== "stdio" ? "" : "none" });
};
transportSelect.addEventListener("change", () => {
state.transport = transportSelect.value as "stdio" | "http" | "sse";
updateTransportVisibility();
});
updateTransportVisibility();
// ─── Footer ───
const footer = page.createDiv({ cls: "af-create-footer" });
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
cancelBtn.onclick = () => deps.navigate("mcp");
const submitBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
createIcon(submitBtn, "plus", "af-btn-icon");
submitBtn.appendText(" Add Server");
submitBtn.onclick = async () => {
const name = state.name.trim();
if (!name) { new Notice("Server name is required."); return; }
if (state.transport === "stdio") {
if (!state.command.trim()) { new Notice("Command is required for stdio servers."); return; }
} else {
if (!state.url.trim()) { new Notice("URL is required for HTTP/SSE servers."); return; }
}
// Parse env vars
const envVars: Record<string, string> = {};
if (state.envVars.trim()) {
for (const line of splitLines(state.envVars)) {
const trimmed = line.trim();
if (!trimmed) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx <= 0) { new Notice(`Invalid env var: ${trimmed}`); return; }
envVars[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
}
}
// Parse headers
const headers: Record<string, string> = {};
if (state.headers.trim()) {
for (const line of splitLines(state.headers)) {
const trimmed = line.trim();
if (!trimmed) continue;
const colonIdx = trimmed.indexOf(":");
if (colonIdx <= 0) { new Notice(`Invalid header: ${trimmed}`); return; }
headers[trimmed.slice(0, colonIdx).trim()] = trimmed.slice(colonIdx + 1).trim();
}
}
// Parse args
const args = state.args.trim() ? state.args.trim().split(/\s+/) : undefined;
if (plugin.repository.getMcpServerByName(name)) {
new Notice(`An MCP server named "${name}" already exists.`);
return;
}
if (state.transport !== "stdio" && state.auth === "bearer" && !state.bearerToken.trim()) {
new Notice("Enter a bearer token, or choose a different auth method.");
return;
}
submitBtn.disabled = true;
submitBtn.setText("Adding...");
// Build the registry definition (non-secret fields only). The bearer
// token, if any, goes to the keychain — never into the vault file.
const server: McpServer = {
name,
type: state.transport,
enabled: true,
source: "manual",
status: "disconnected",
scope: "user",
tools: [],
toolDetails: [],
};
if (state.transport === "stdio") {
server.command = state.command.trim();
if (args) server.args = args;
if (Object.keys(envVars).length > 0) server.env = envVars;
} else {
server.url = state.url.trim();
if (Object.keys(headers).length > 0) server.headers = headers;
server.auth = state.auth;
}
try {
await plugin.repository.saveMcpServer(server, state.description.trim());
if (state.transport !== "stdio" && state.auth === "bearer" && state.bearerToken.trim()) {
plugin.mcpAuth.storeStaticToken(name, state.bearerToken.trim());
}
new Notice(`Server "${name}" added.`);
await plugin.refreshFromVault();
deps.navigate("mcp");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to add server: ${msg}`);
submitBtn.disabled = false;
submitBtn.setText("");
createIcon(submitBtn, "plus", "af-btn-icon");
submitBtn.appendText(" Add Server");
}
};
}

68
src/views/forms/shared.ts Normal file
View file

@ -0,0 +1,68 @@
import type AgentFleetPlugin from "../../main";
/**
* View helpers every extracted form page borrows from the dashboard so the
* extracted markup stays byte-identical to what the view used to render
* inline. Each form module extends this with its own (narrowly typed)
* `navigate` delegate and any page-specific extras.
*/
export interface DashboardFormDeps {
plugin: AgentFleetPlugin;
/** Append a small info icon with a hover tooltip to a label element. */
addTooltip: (labelEl: HTMLElement, text: string) => void;
/** Standard labelled text-input form row. */
createFormField: (
container: HTMLElement,
label: string,
placeholder: string,
desc: string,
onChange: (v: string) => void,
initialValue?: string,
) => void;
/** Disable a submit button while an async save is in flight; returns a restore fn. */
markSubmitBusy: (
btn: HTMLButtonElement,
busyLabel: string,
iconName: string,
label: string,
) => () => void;
}
/** Decompose a cron expression into the schedule-picker widget's components. */
export function parseCronComponents(cron: string): {
freq: string;
hour: number;
minute: number;
days: number[];
dayOfMonth: number;
} {
const defaults = { freq: "daily", hour: 9, minute: 0, days: [1], dayOfMonth: 1 };
if (!cron?.trim()) return defaults;
const shortcutMap: Record<string, string> = {
"*/5 * * * *": "every_5m",
"*/15 * * * *": "every_15m",
"*/30 * * * *": "every_30m",
"0 * * * *": "every_hour",
"0 */2 * * *": "every_2h",
};
if (shortcutMap[cron]) return { ...defaults, freq: shortcutMap[cron] };
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return defaults;
const [min, hr, dom, , dow] = parts;
const h = parseInt(hr ?? "9", 10);
const m = parseInt(min ?? "0", 10);
if (dom === "*" && dow === "*") return { ...defaults, freq: "daily", hour: h, minute: m };
if (dom === "*" && dow === "1-5") return { ...defaults, freq: "weekdays", hour: h, minute: m };
if (dom === "*" && dow !== "*") {
const days = (dow ?? "1").split(",").map((d) => parseInt(d, 10));
return { ...defaults, freq: "weekly", hour: h, minute: m, days };
}
if (dow === "*" && dom !== "*") {
return { ...defaults, freq: "monthly", hour: h, minute: m, dayOfMonth: parseInt(dom ?? "1", 10) };
}
return { ...defaults, hour: h, minute: m };
}

View file

@ -0,0 +1,271 @@
import { Notice, setIcon } from "obsidian";
import { ConfirmModal } from "../../modals/confirmModal";
import type { SkillConfig } from "../../types";
import { slugify } from "../../utils/markdown";
import { createIcon } from "../../utils/icons";
import type { DashboardFormDeps } from "./shared";
/** View helpers the skill form borrows from the dashboard. */
export interface SkillFormDeps extends DashboardFormDeps {
/** Navigate the dashboard to another page (post-save / cancel / delete). */
navigate: (page: "skills") => void;
}
/**
* Shared skill form used by both the Create Skill and Edit Skill pages.
* Pass `skill` to render in edit mode (pre-filled fields, read-only name,
* delete button, "Save Changes" submit); omit it for create mode (which
* additionally offers a template picker).
*/
export function renderSkillForm(
page: HTMLElement,
deps: SkillFormDeps,
skill?: SkillConfig,
): void {
const { plugin } = deps;
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
setIcon(avatar, skill ? "edit" : "plus");
const headerInfo = headerLeft.createDiv();
if (skill) {
headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Skill: ${skill.name}` });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Modify skill definition" });
} else {
headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Skill" });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Define a reusable skill for your agents" });
}
header.createDiv({ cls: "af-detail-header-actions" });
// Form state (pre-filled in edit mode)
const state = {
name: "",
description: skill ? skill.description ?? "" : "",
tags: skill ? skill.tags.join(", ") : "",
body: skill ? skill.body : "",
toolsBody: skill ? skill.toolsBody : "",
referencesBody: skill ? skill.referencesBody : "",
examplesBody: skill ? skill.examplesBody : "",
};
const SKILL_TEMPLATES: Record<string, { label: string; prompt: string }> = {
none: { label: "None", prompt: "" },
cli: { label: "CLI Tool Wrapper", prompt: "You are using the {{tool}} CLI. All operations go through the wrapper script.\n\nRequirements:\n- Ensure required environment variables are set\n- Parse JSON responses for human-readable output\n- Confirm destructive operations before executing\n\nKey behaviors:\n- List existing items before making changes\n- Use --dry-run flags when available\n- Report errors clearly with suggested fixes" },
api: { label: "API Integration", prompt: "You are integrating with the {{service}} API.\n\nBase URL: https://api.example.com/v1\nAuth: Bearer token via environment variable\n\nKey behaviors:\n- Always check rate limits before bulk operations\n- Handle pagination for list endpoints\n- Validate inputs before making requests\n- Parse and format JSON responses for readability" },
review: { label: "Code Review", prompt: "You are a code review skill. Analyze code changes and provide structured feedback.\n\nReview checklist:\n- Correctness: Does the code do what it claims?\n- Security: Any injection, auth, or data exposure risks?\n- Performance: Unnecessary allocations, N+1 queries, missing indexes?\n- Maintainability: Clear naming, reasonable complexity, adequate tests?\n\nOutput format:\n- Start with a 1-line summary\n- Group findings by severity (critical, warning, suggestion)\n- Reference specific file paths and line numbers" },
data: { label: "Data Analysis", prompt: "You are a data analysis skill. Query, transform, and report on data.\n\nKey behaviors:\n- Summarize datasets before diving into details\n- Use tables and charts where appropriate\n- Always state the time range and filters applied\n- Flag anomalies and outliers explicitly\n- End with actionable insights, not just observations" },
};
const form = page.createDiv({ cls: "af-create-form" });
// ─── Identity Section ───
const identitySection = form.createDiv({ cls: "af-create-section" });
const identityHeader = identitySection.createDiv({ cls: "af-create-section-header" });
const identityIcon = identityHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(identityIcon, "puzzle");
identityHeader.createSpan({ text: "Identity" });
if (skill) {
// Name (read-only)
const nameRow = identitySection.createDiv({ cls: "af-form-row" });
nameRow.createDiv({ cls: "af-form-label", text: "Name" });
const nameInput = nameRow.createEl("input", {
cls: "af-form-input",
attr: { type: "text", value: skill.name, disabled: "true" },
});
nameInput.setCssStyles({ opacity: "0.6" });
} else {
deps.createFormField(identitySection, "Name", "todoist", "Unique identifier (will be slugified)", (v) => { state.name = v; });
}
deps.createFormField(identitySection, "Description", "Manage tasks and projects via CLI", "", (v) => { state.description = v; }, skill ? skill.description ?? "" : undefined);
deps.createFormField(identitySection, "Tags", "productivity, tasks", "Comma-separated", (v) => { state.tags = v; }, skill ? skill.tags.join(", ") : undefined);
// ─── Core Instructions Section ───
const instructionsSection = form.createDiv({ cls: "af-create-section" });
const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" });
const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(instructionsIcon, "file-text");
instructionsHeader.createSpan({ text: "Core Instructions" });
// Template picker (create mode only)
let templateSelect: HTMLSelectElement | undefined;
if (!skill) {
const templateRow = instructionsSection.createDiv({ cls: "af-form-row" });
templateRow.createDiv({ cls: "af-form-label", text: "Template" });
templateSelect = templateRow.createEl("select", { cls: "af-form-select" });
for (const [key, { label }] of Object.entries(SKILL_TEMPLATES)) {
templateSelect.createEl("option", { text: label, attr: { value: key } });
}
}
const bodyTextarea = instructionsSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: {
placeholder: skill
? "Skill instructions..."
: "Skill instructions — what does this skill do and how should agents use it?",
rows: "10",
},
});
if (skill) bodyTextarea.value = skill.body;
bodyTextarea.addEventListener("input", () => { state.body = bodyTextarea.value; });
if (templateSelect) {
const select = templateSelect;
select.addEventListener("change", () => {
const preset = SKILL_TEMPLATES[select.value];
if (preset && select.value !== "none") {
state.body = preset.prompt;
bodyTextarea.value = preset.prompt;
}
});
}
// ─── Tools Section ───
const toolsSection = form.createDiv({ cls: "af-create-section" });
const toolsHeader = toolsSection.createDiv({ cls: "af-create-section-header" });
const toolsIcon = toolsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(toolsIcon, "wrench");
const toolsHeaderLabel = toolsHeader.createSpan({ text: "Tools" });
deps.addTooltip(toolsHeaderLabel, "CLI commands, API endpoints, and tool definitions available to agents using this skill");
const toolsTextarea = toolsSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: {
placeholder: skill
? "## Commands\n\n### list\n..."
: "## Commands\n\n### list\nUsage: tool list [--filter <query>]\n...",
rows: "8",
},
});
if (skill) toolsTextarea.value = skill.toolsBody;
toolsTextarea.addEventListener("input", () => { state.toolsBody = toolsTextarea.value; });
// ─── References Section ───
const refsSection = form.createDiv({ cls: "af-create-section" });
const refsHeader = refsSection.createDiv({ cls: "af-create-section-header" });
const refsIcon = refsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(refsIcon, "book-open");
const refsHeaderLabel = refsHeader.createSpan({ text: "References" });
deps.addTooltip(refsHeaderLabel, "Background docs, conventions, cheat sheets");
const refsTextarea = refsSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "API docs, filter syntax, conventions...", rows: "6" },
});
if (skill) refsTextarea.value = skill.referencesBody;
refsTextarea.addEventListener("input", () => { state.referencesBody = refsTextarea.value; });
// ─── Examples Section ───
const examplesSection = form.createDiv({ cls: "af-create-section" });
const examplesHeader = examplesSection.createDiv({ cls: "af-create-section-header" });
const examplesIcon = examplesHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(examplesIcon, "message-circle");
const examplesHeaderLabel = examplesHeader.createSpan({ text: "Examples" });
deps.addTooltip(examplesHeaderLabel, "Example prompts and ideal outputs showing how to use this skill");
const examplesTextarea = examplesSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: {
placeholder: skill
? "## Example: List all tasks\n..."
: "## Example: List all tasks\n\nUser: Show me my tasks for today\n\nAgent: ...",
rows: "6",
},
});
if (skill) examplesTextarea.value = skill.examplesBody;
examplesTextarea.addEventListener("input", () => { state.examplesBody = examplesTextarea.value; });
// ─── Footer ───
const footer = page.createDiv({ cls: "af-create-footer" });
if (skill) {
const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" });
createIcon(deleteBtn, "trash-2", "af-btn-icon");
deleteBtn.appendText(" Delete");
deleteBtn.onclick = () => {
new ConfirmModal(plugin.app, {
title: `Delete skill "${skill.name}"?`,
body: "The skill file will be moved to your system trash and can be recovered.",
confirmText: "Delete",
danger: true,
onConfirm: async () => {
await plugin.repository.deleteSkill(skill.name);
new Notice(`Skill "${skill.name}" deleted.`);
// Small delay for Obsidian vault cache to process the trash
await new Promise((r) => window.setTimeout(r, 200));
await plugin.refreshFromVault();
deps.navigate("skills");
},
}).open();
};
footer.createDiv({ cls: "af-toolbar-spacer" });
}
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
cancelBtn.onclick = () => deps.navigate("skills");
const submitBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
createIcon(submitBtn, skill ? "check" : "plus", "af-btn-icon");
submitBtn.appendText(skill ? " Save Changes" : " Create Skill");
if (skill) {
// Edit mode: update the existing skill file(s) in place.
submitBtn.onclick = async () => {
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
const restoreSubmit = deps.markSubmitBusy(submitBtn, "Saving...", "check", "Save Changes");
try {
await plugin.repository.updateSkill(skill.name, {
description: state.description.trim(),
tags: parseTags(state.tags),
body: state.body.trim(),
toolsBody: state.toolsBody.trim(),
referencesBody: state.referencesBody.trim(),
examplesBody: state.examplesBody.trim(),
});
new Notice(`Skill "${skill.name}" updated.`);
await plugin.refreshFromVault();
deps.navigate("skills");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to update skill: ${msg}`);
restoreSubmit();
}
};
} else {
// Create mode: write a new skill folder.
submitBtn.onclick = async () => {
const name = state.name.trim();
if (!name) {
new Notice("Skill name is required.");
return;
}
const slug = slugify(name);
if (plugin.repository.getSkillByName(slug)) {
new Notice(`Skill "${slug}" already exists.`);
return;
}
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
const restoreSubmit = deps.markSubmitBusy(submitBtn, "Creating...", "plus", "Create Skill");
try {
await plugin.repository.createSkillFolder({
name: slug,
description: state.description.trim(),
tags: parseTags(state.tags),
body: state.body.trim(),
toolsBody: state.toolsBody.trim(),
referencesBody: state.referencesBody.trim(),
examplesBody: state.examplesBody.trim(),
});
new Notice(`Skill "${slug}" created.`);
await plugin.refreshFromVault();
deps.navigate("skills");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to create skill: ${msg}`);
restoreSubmit();
}
};
}
}

786
src/views/forms/taskForm.ts Normal file
View file

@ -0,0 +1,786 @@
import { Notice, setIcon } from "obsidian";
import { ConfirmModal } from "../../modals/confirmModal";
import type { ChannelConfig, TaskConfig } from "../../types";
import { slugify, stringifyMarkdownWithFrontmatter } from "../../utils/markdown";
import { createIcon } from "../../utils/icons";
import { renderModelPicker } from "../../components/modelPicker";
import type { DashboardFormDeps } from "./shared";
import { parseCronComponents } from "./shared";
/** View helpers the task forms borrow from the dashboard. */
export interface TaskFormDeps extends DashboardFormDeps {
/** Navigate the dashboard to another page (post-save / cancel / delete). */
navigate: (page: "kanban" | "task-detail", context?: string) => void;
}
/** Inline cron schedule picker shared by the create and edit task forms.
* Writes the built cron expression into `state.schedule`. */
function renderInlineSchedule(
container: HTMLElement,
state: { schedule: string; type: string },
): void {
// Parse current cron into components
const parsed = parseCronComponents(state.schedule);
// Frequency dropdown
const freqRow = container.createDiv({ cls: "af-form-row" });
freqRow.createDiv({ cls: "af-form-label", text: "Frequency" });
const freqSelect = freqRow.createEl("select", { cls: "af-form-select" });
const freqOptions: Array<[string, string]> = [
["every_5m", "Every 5 minutes"],
["every_15m", "Every 15 minutes"],
["every_30m", "Every 30 minutes"],
["every_hour", "Every hour"],
["every_2h", "Every 2 hours"],
["daily", "Daily"],
["weekdays", "Weekdays"],
["weekly", "Weekly"],
["monthly", "Monthly"],
];
for (const [val, lbl] of freqOptions) {
const opt = freqSelect.createEl("option", { text: lbl, attr: { value: val } });
if (val === parsed.freq) opt.selected = true;
}
// Time row (shown for daily/weekdays/weekly/monthly)
const timeRow = container.createDiv({ cls: "af-form-row af-schedule-time-row" });
timeRow.createDiv({ cls: "af-form-label", text: "Time" });
const timeWrap = timeRow.createDiv({ cls: "af-schedule-time-selects" });
const hourSelect = timeWrap.createEl("select", { cls: "af-form-select af-form-select-sm" });
for (let h = 0; h < 24; h++) {
const ampm = h >= 12 ? "PM" : "AM";
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
const opt = hourSelect.createEl("option", { text: `${h12} ${ampm}`, attr: { value: String(h) } });
if (h === parsed.hour) opt.selected = true;
}
timeWrap.createSpan({ cls: "af-schedule-colon", text: ":" });
const minSelect = timeWrap.createEl("select", { cls: "af-form-select af-form-select-sm" });
for (let m = 0; m < 60; m += 5) {
const opt = minSelect.createEl("option", {
text: String(m).padStart(2, "0"),
attr: { value: String(m) },
});
if (m === parsed.minute) opt.selected = true;
}
// Day row (shown for weekly)
const dayRow = container.createDiv({ cls: "af-form-row af-schedule-day-row" });
dayRow.createDiv({ cls: "af-form-label", text: "Day" });
const dayWrap = dayRow.createDiv({ cls: "af-schedule-day-buttons" });
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const selectedDays = new Set(parsed.days);
for (let d = 0; d < 7; d++) {
const btn = dayWrap.createEl("button", {
cls: `af-schedule-day-btn${selectedDays.has(d) ? " active" : ""}`,
text: dayNames[d]!,
});
btn.onclick = () => {
if (selectedDays.has(d)) selectedDays.delete(d);
else selectedDays.add(d);
btn.toggleClass("active", selectedDays.has(d));
buildCron();
};
}
// Day-of-month row (shown for monthly)
const domRow = container.createDiv({ cls: "af-form-row af-schedule-dom-row" });
domRow.createDiv({ cls: "af-form-label", text: "Day of month" });
const domSelect = domRow.createEl("select", { cls: "af-form-select af-form-select-sm" });
for (let d = 1; d <= 28; d++) {
const opt = domSelect.createEl("option", { text: String(d), attr: { value: String(d) } });
if (d === parsed.dayOfMonth) opt.selected = true;
}
// Visibility logic
const showHideFields = () => {
const freq = freqSelect.value;
const needsTime = ["daily", "weekdays", "weekly", "monthly"].includes(freq);
const needsDay = freq === "weekly";
const needsDom = freq === "monthly";
timeRow.setCssStyles({ display: needsTime ? "" : "none" });
dayRow.setCssStyles({ display: needsDay ? "" : "none" });
domRow.setCssStyles({ display: needsDom ? "" : "none" });
};
// Build cron from selections
const buildCron = () => {
const freq = freqSelect.value;
const h = hourSelect.value;
const m = minSelect.value;
let cron = "";
switch (freq) {
case "every_5m": cron = "*/5 * * * *"; break;
case "every_15m": cron = "*/15 * * * *"; break;
case "every_30m": cron = "*/30 * * * *"; break;
case "every_hour": cron = "0 * * * *"; break;
case "every_2h": cron = "0 */2 * * *"; break;
case "daily": cron = `${m} ${h} * * *`; break;
case "weekdays": cron = `${m} ${h} * * 1-5`; break;
case "weekly": {
const days = Array.from(selectedDays).sort().join(",") || "1";
cron = `${m} ${h} * * ${days}`;
break;
}
case "monthly": cron = `${m} ${h} ${domSelect.value} * *`; break;
}
state.schedule = cron;
state.type = "recurring";
};
freqSelect.addEventListener("change", () => { showHideFields(); buildCron(); });
hourSelect.addEventListener("change", buildCron);
minSelect.addEventListener("change", buildCron);
domSelect.addEventListener("change", buildCron);
showHideFields();
}
/**
* 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.
*/
function renderTaskChannelDelivery(
section: HTMLElement,
deps: TaskFormDeps,
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;
}
deps.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(); });
deps.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();
});
}
function toLocalISO(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
/** Format a Date as the value string accepted by `<input type="datetime-local">`:
* `YYYY-MM-DDTHH:mm`, local timezone, no seconds. */
function toDatetimeLocal(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
type ScheduleChoice = "immediate" | "recurring" | "once";
/**
* Segmented [ Immediate | Recurring | One-time ] control that drives the
* schedule sections. Maps 1:1 onto the previous enable-toggle + mode-dropdown
* state (scheduleEnabled + scheduleMode), so the saved frontmatter is
* unchanged only the input UI differs.
*/
function renderScheduleTypeSegments(
container: HTMLElement,
initial: ScheduleChoice,
onSelect: (choice: ScheduleChoice) => void,
): void {
const row = container.createDiv({ cls: "af-form-row" });
row.createDiv({ cls: "af-form-label", text: "Run" });
const seg = row.createDiv({ cls: "af-segmented" });
const options: Array<[ScheduleChoice, string]> = [
["immediate", "Immediate"],
["recurring", "Recurring"],
["once", "One-time"],
];
const buttons: Array<[ScheduleChoice, HTMLButtonElement]> = [];
for (const [val, lbl] of options) {
const btn = seg.createEl("button", {
cls: `af-segmented-btn${val === initial ? " active" : ""}`,
text: lbl,
});
buttons.push([val, btn]);
btn.onclick = () => {
for (const [v, b] of buttons) b.toggleClass("active", v === val);
onSelect(val);
};
}
}
// ═══════════════════════════════════════════════════════
// Create Task Page
// ═══════════════════════════════════════════════════════
export function renderCreateTaskForm(page: HTMLElement, deps: TaskFormDeps): void {
const { plugin } = deps;
const snapshot = plugin.runtime.getSnapshot();
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const avatar = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
setIcon(avatar, "plus");
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: "Create New Task" });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Configure a new task for your fleet" });
header.createDiv({ cls: "af-detail-header-actions" });
// Form state
const state = {
title: "",
agent: snapshot.agents[0]?.name ?? "",
priority: "medium",
tags: "",
body: "",
scheduleEnabled: false,
/** "recurring" (cron) or "once" (run_at). Only consulted when
* scheduleEnabled is true; otherwise the task is "immediate". */
scheduleMode: "recurring" as "recurring" | "once",
schedule: "0 9 * * *",
runAt: "",
type: "immediate" as string,
enabled: true,
catchUp: true,
effort: "",
model: "",
channel: "",
channelTarget: "",
};
const form = page.createDiv({ cls: "af-create-form" });
// ─── Task Details Section ───
const detailsSection = form.createDiv({ cls: "af-create-section" });
const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" });
const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(detailsIcon, "file-text");
detailsHeader.createSpan({ text: "Task Details" });
deps.createFormField(detailsSection, "Title", "Daily status report", "Used as the task identifier", (v) => { state.title = v; });
// Agent dropdown
const agentRow = detailsSection.createDiv({ cls: "af-form-row" });
agentRow.createDiv({ cls: "af-form-label", text: "Agent" });
const agentSelect = agentRow.createEl("select", { cls: "af-form-select" });
for (const a of snapshot.agents) {
agentSelect.createEl("option", { text: a.name, attr: { value: a.name } });
}
agentSelect.addEventListener("change", () => { state.agent = agentSelect.value; });
// Priority dropdown
const priorityRow = detailsSection.createDiv({ cls: "af-form-row" });
priorityRow.createDiv({ cls: "af-form-label", text: "Priority" });
const prioritySelect = priorityRow.createEl("select", { cls: "af-form-select" });
const priorityOptions: Array<[string, string]> = [
["low", "Low"], ["medium", "Medium"], ["high", "High"], ["critical", "Critical"],
];
for (const [val, lbl] of priorityOptions) {
const opt = prioritySelect.createEl("option", { text: lbl, attr: { value: val } });
if (val === "medium") opt.selected = true;
}
prioritySelect.addEventListener("change", () => { state.priority = prioritySelect.value; });
// Tags
deps.createFormField(detailsSection, "Tags", "monitoring, devops", "Comma-separated", (v) => { state.tags = v; });
// ─── Instructions Section ───
const instructionsSection = form.createDiv({ cls: "af-create-section" });
const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" });
const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(instructionsIcon, "message-square");
instructionsHeader.createSpan({ text: "Instructions" });
const instructionsTextarea = instructionsSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "Describe what the agent should do...", rows: "10" },
});
instructionsTextarea.addEventListener("input", () => { state.body = instructionsTextarea.value; });
// ─── Schedule Section ───
const scheduleSection = form.createDiv({ cls: "af-create-section" });
const scheduleHeader = scheduleSection.createDiv({ cls: "af-create-section-header" });
const scheduleIcon = scheduleHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(scheduleIcon, "clock");
scheduleHeader.createSpan({ text: "Schedule" });
// Schedule type — one segmented control replaces the old enable-toggle +
// mode dropdown. Drives the same state fields (scheduleEnabled/scheduleMode)
// so the saved frontmatter is identical.
renderScheduleTypeSegments(scheduleSection, "immediate", (choice) => {
state.scheduleEnabled = choice !== "immediate";
if (choice !== "immediate") state.scheduleMode = choice;
state.type = choice;
scheduleBody.setCssStyles({ display: choice === "immediate" ? "none" : "" });
cronHost.setCssStyles({ display: choice === "recurring" ? "" : "none" });
onceHost.setCssStyles({ display: choice === "once" ? "" : "none" });
});
const scheduleBody = scheduleSection.createDiv({ cls: "af-schedule-body" });
scheduleBody.setCssStyles({ display: "none" });
const cronHost = scheduleBody.createDiv();
const onceHost = scheduleBody.createDiv();
onceHost.setCssStyles({ display: "none" });
renderInlineSchedule(cronHost, state);
const onceRow = onceHost.createDiv({ cls: "af-form-row" });
onceRow.createDiv({ cls: "af-form-label", text: "Run at" });
const onceInput = onceRow.createEl("input", {
cls: "af-form-input",
attr: { type: "datetime-local", value: toDatetimeLocal(new Date(Date.now() + 3600_000)) },
});
// Seed the initial value — the task won't persist blank runAt
state.runAt = new Date(onceInput.value).toISOString();
onceInput.addEventListener("input", () => {
state.runAt = onceInput.value ? new Date(onceInput.value).toISOString() : "";
});
// Enabled toggle (only when schedule is on)
const enabledRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" });
enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" });
const enabledToggle = enabledRow.createDiv({ cls: "af-agent-card-toggle on" });
enabledToggle.onclick = () => {
const isOn = enabledToggle.hasClass("on");
enabledToggle.toggleClass("on", !isOn);
state.enabled = !isOn;
};
// Catch up missed runs toggle
const catchUpRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" });
const catchUpLabel = catchUpRow.createDiv({ cls: "af-form-label" });
catchUpLabel.setText("Catch up if missed");
const catchUpToggle = catchUpRow.createDiv({ cls: `af-agent-card-toggle${state.catchUp ? " on" : ""}` });
catchUpToggle.onclick = () => {
const isOn = catchUpToggle.hasClass("on");
catchUpToggle.toggleClass("on", !isOn);
state.catchUp = !isOn;
};
// ─── Execution Section ───
// Model & effort are per-run controls, not scheduling knobs — they apply
// whether or not the task has a schedule, so they live in their own section.
const execSection = form.createDiv({ cls: "af-create-section" });
const execHeader = execSection.createDiv({ cls: "af-create-section-header" });
const execIcon = execHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(execIcon, "gauge");
execHeader.createSpan({ text: "Execution" });
// Model override (per-task)
const taskModelRow = execSection.createDiv({ cls: "af-form-row" });
const taskModelLabel = taskModelRow.createDiv({ cls: "af-form-label", text: "Model" });
const taskModelFieldWrap = taskModelRow.createDiv({ cls: "af-form-field-wrap" });
const renderCreateTaskModelPicker = (agentName: string) => {
taskModelFieldWrap.empty();
const selAgent = snapshot.agents.find((a) => a.name === agentName);
renderModelPicker(taskModelFieldWrap, {
value: state.model,
adapter: selAgent?.adapter,
onChange: (value) => { state.model = value; },
allowInherit: true,
inheritPlaceholder: selAgent
? `Inherit from ${selAgent.name}${selAgent.model ? ` (${selAgent.model})` : ""}`
: "Inherit from agent",
});
};
renderCreateTaskModelPicker(state.agent);
agentSelect.addEventListener("change", () => renderCreateTaskModelPicker(agentSelect.value));
deps.addTooltip(
taskModelLabel,
"Override the agents model for this task only. Useful for routing simple runs to haiku while the agent stays on opus for heavier work.",
);
// Effort level
const taskEffortRow = execSection.createDiv({ cls: "af-form-row" });
const taskEffortLabel = taskEffortRow.createDiv({ cls: "af-form-label", text: "Effort" });
const taskEffortSelect = taskEffortRow.createEl("select", { cls: "af-form-select" });
for (const [val, lbl] of [["", "Agent Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["max", "Max"]] as const) {
const opt = taskEffortSelect.createEl("option", { text: lbl, attr: { value: val } });
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.
renderTaskChannelDelivery(execSection, deps, snapshot.channels, state);
deps.addTooltip(
taskEffortLabel,
"Overrides the agents effort level for this task. Higher effort = more thinking tokens spent.",
);
// ─── Footer ───
const footer = page.createDiv({ cls: "af-create-footer" });
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
cancelBtn.onclick = () => deps.navigate("kanban");
const createBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
createIcon(createBtn, "plus", "af-btn-icon");
createBtn.appendText(" Create Task");
createBtn.onclick = async () => {
const title = state.title.trim();
if (!title) {
new Notice("Task title is required.");
return;
}
const taskId = slugify(title);
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
const effectiveType = state.scheduleEnabled
? state.scheduleMode === "once"
? "once"
: "recurring"
: "immediate";
const frontmatter: Record<string, unknown> = {
task_id: taskId,
agent: state.agent,
type: effectiveType,
priority: state.priority,
enabled: state.enabled,
created: toLocalISO(new Date()),
run_count: 0,
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),
};
if (effectiveType === "recurring") {
frontmatter.schedule = state.schedule.trim() || "0 9 * * *";
} else if (effectiveType === "once") {
if (!state.runAt) {
new Notice("Pick a date/time for the one-time run.");
return;
}
frontmatter.run_at = state.runAt;
}
const restoreSubmit = deps.markSubmitBusy(createBtn, "Creating...", "plus", "Create Task");
try {
const path = await plugin.repository.getAvailablePath(
plugin.repository.getSubfolder("tasks"),
taskId,
);
await plugin.app.vault.create(
path,
stringifyMarkdownWithFrontmatter(frontmatter, state.body.trim() || "Describe the task here."),
);
new Notice(`Task "${taskId}" created.`);
await plugin.refreshFromVault();
deps.navigate("task-detail", taskId);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to create task: ${msg}`);
restoreSubmit();
}
};
}
// ═══════════════════════════════════════════════════════
// Edit Task Page
// ═══════════════════════════════════════════════════════
export function renderEditTaskForm(page: HTMLElement, deps: TaskFormDeps, task: TaskConfig): void {
const { plugin } = deps;
const snapshot = plugin.runtime.getSnapshot();
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const taskIcon = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
setIcon(taskIcon, "edit");
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: `Edit Task: ${task.taskId}` });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: "Modify task configuration" });
header.createDiv({ cls: "af-detail-header-actions" });
const hasSchedule = !!(task.schedule || task.runAt);
// Form state pre-filled
const state = {
agent: task.agent,
type: task.type as string,
priority: task.priority,
schedule: task.schedule ?? "0 9 * * *",
runAt: task.runAt ?? "",
scheduleEnabled: hasSchedule,
scheduleMode: (task.type === "once" ? "once" : "recurring"),
enabled: task.enabled,
catchUp: task.catchUp,
effort: task.effort ?? "",
model: task.model ?? "",
channel: task.channel ?? "",
channelTarget: task.channelTarget ?? "",
tags: task.tags.join(", "),
body: task.body,
};
const form = page.createDiv({ cls: "af-create-form" });
// ─── Task Details Section ───
const detailsSection = form.createDiv({ cls: "af-create-section" });
const detailsHeader = detailsSection.createDiv({ cls: "af-create-section-header" });
const detailsIcon = detailsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(detailsIcon, "file-text");
detailsHeader.createSpan({ text: "Task Details" });
// Title (read-only)
const nameRow = detailsSection.createDiv({ cls: "af-form-row" });
nameRow.createDiv({ cls: "af-form-label", text: "Title" });
const nameInput = nameRow.createEl("input", {
cls: "af-form-input",
attr: { type: "text", value: task.taskId, disabled: "true" },
});
nameInput.setCssStyles({ opacity: "0.6" });
// Agent dropdown
const agentRow = detailsSection.createDiv({ cls: "af-form-row" });
agentRow.createDiv({ cls: "af-form-label", text: "Agent" });
const agentSelect = agentRow.createEl("select", { cls: "af-form-select" });
for (const a of snapshot.agents) {
const opt = agentSelect.createEl("option", { text: a.name, attr: { value: a.name } });
if (a.name === task.agent) opt.selected = true;
}
agentSelect.addEventListener("change", () => { state.agent = agentSelect.value; });
// Priority dropdown
const priorityRow = detailsSection.createDiv({ cls: "af-form-row" });
priorityRow.createDiv({ cls: "af-form-label", text: "Priority" });
const prioritySelect = priorityRow.createEl("select", { cls: "af-form-select" });
const priorityOptions: Array<[string, string]> = [
["low", "Low"], ["medium", "Medium"], ["high", "High"], ["critical", "Critical"],
];
for (const [val, lbl] of priorityOptions) {
const opt = prioritySelect.createEl("option", { text: lbl, attr: { value: val } });
if (val === task.priority) opt.selected = true;
}
prioritySelect.addEventListener("change", () => { state.priority = prioritySelect.value as typeof state.priority; });
// Tags
deps.createFormField(detailsSection, "Tags", "monitoring, critical", "Comma-separated", (v) => { state.tags = v; }, task.tags.join(", "));
// ─── Instructions Section ───
const instructionsSection = form.createDiv({ cls: "af-create-section" });
const instructionsHeader = instructionsSection.createDiv({ cls: "af-create-section-header" });
const instructionsIcon = instructionsHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(instructionsIcon, "message-square");
instructionsHeader.createSpan({ text: "Instructions" });
const instructionsTextarea = instructionsSection.createEl("textarea", {
cls: "af-create-prompt-textarea",
attr: { placeholder: "Describe what the agent should do...", rows: "10" },
});
instructionsTextarea.value = task.body;
instructionsTextarea.addEventListener("input", () => { state.body = instructionsTextarea.value; });
// ─── Schedule Section ───
const scheduleSection = form.createDiv({ cls: "af-create-section" });
const scheduleHeader = scheduleSection.createDiv({ cls: "af-create-section-header" });
const scheduleIcon = scheduleHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(scheduleIcon, "clock");
scheduleHeader.createSpan({ text: "Schedule" });
// Schedule type — one segmented control replaces the old enable-toggle +
// mode dropdown. Drives the same state fields (scheduleEnabled/scheduleMode)
// so the saved frontmatter is identical.
const initialChoice: ScheduleChoice = !hasSchedule
? "immediate"
: state.scheduleMode === "once"
? "once"
: "recurring";
renderScheduleTypeSegments(scheduleSection, initialChoice, (choice) => {
state.scheduleEnabled = choice !== "immediate";
if (choice !== "immediate") state.scheduleMode = choice;
state.type = choice;
scheduleBody.setCssStyles({ display: choice === "immediate" ? "none" : "" });
editCronHost.setCssStyles({ display: choice === "recurring" ? "" : "none" });
editOnceHost.setCssStyles({ display: choice === "once" ? "" : "none" });
});
const scheduleBody = scheduleSection.createDiv({ cls: "af-schedule-body" });
scheduleBody.setCssStyles({ display: hasSchedule ? "" : "none" });
const editCronHost = scheduleBody.createDiv();
const editOnceHost = scheduleBody.createDiv();
editCronHost.setCssStyles({ display: initialChoice === "recurring" ? "" : "none" });
editOnceHost.setCssStyles({ display: initialChoice === "once" ? "" : "none" });
renderInlineSchedule(editCronHost, state);
const editOnceRow = editOnceHost.createDiv({ cls: "af-form-row" });
editOnceRow.createDiv({ cls: "af-form-label", text: "Run at" });
const prefillOnce = state.runAt
? toDatetimeLocal(new Date(state.runAt))
: toDatetimeLocal(new Date(Date.now() + 3600_000));
const editOnceInput = editOnceRow.createEl("input", {
cls: "af-form-input",
attr: { type: "datetime-local", value: prefillOnce },
});
if (!state.runAt) state.runAt = new Date(editOnceInput.value).toISOString();
editOnceInput.addEventListener("input", () => {
state.runAt = editOnceInput.value ? new Date(editOnceInput.value).toISOString() : "";
});
// Enabled toggle (whether the schedule is active)
const enabledRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" });
enabledRow.createDiv({ cls: "af-form-label", text: "Enabled" });
const enabledToggle = enabledRow.createDiv({ cls: `af-agent-card-toggle${task.enabled ? " on" : ""}` });
enabledToggle.onclick = () => {
const isOn = enabledToggle.hasClass("on");
enabledToggle.toggleClass("on", !isOn);
state.enabled = !isOn;
};
// Catch up missed runs toggle
const catchUpRow = scheduleBody.createDiv({ cls: "af-form-row af-form-row-toggle" });
const catchUpLabel = catchUpRow.createDiv({ cls: "af-form-label" });
catchUpLabel.setText("Catch up if missed");
const catchUpToggle = catchUpRow.createDiv({ cls: `af-agent-card-toggle${state.catchUp ? " on" : ""}` });
catchUpToggle.onclick = () => {
const isOn = catchUpToggle.hasClass("on");
catchUpToggle.toggleClass("on", !isOn);
state.catchUp = !isOn;
};
// ─── Execution Section ───
const execSection = form.createDiv({ cls: "af-create-section" });
const execHeader = execSection.createDiv({ cls: "af-create-section-header" });
const execIcon = execHeader.createSpan({ cls: "af-create-section-icon" });
setIcon(execIcon, "gauge");
execHeader.createSpan({ text: "Execution" });
// Effort level
const effortRow = execSection.createDiv({ cls: "af-form-row" });
const effortLabel = effortRow.createDiv({ cls: "af-form-label", text: "Effort" });
const effortSelect = effortRow.createEl("select", { cls: "af-form-select" });
const effortOptions: Array<[string, string]> = [
["", "Agent Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], ["max", "Max"],
];
for (const [val, lbl] of effortOptions) {
const opt = effortSelect.createEl("option", { text: lbl, attr: { value: val } });
if (val === state.effort) opt.selected = true;
}
effortSelect.addEventListener("change", () => { state.effort = effortSelect.value; });
deps.addTooltip(
effortLabel,
"Overrides the agents effort level for this task. Higher effort = more thinking tokens spent.",
);
// Model override
const modelRowEdit = execSection.createDiv({ cls: "af-form-row" });
const modelLabelEdit = modelRowEdit.createDiv({ cls: "af-form-label", text: "Model" });
const modelFieldWrapEdit = modelRowEdit.createDiv({ cls: "af-form-field-wrap" });
const renderTaskModelPicker = (agentName: string) => {
modelFieldWrapEdit.empty();
const selAgent = snapshot.agents.find((a) => a.name === agentName);
renderModelPicker(modelFieldWrapEdit, {
value: state.model,
adapter: selAgent?.adapter,
onChange: (value) => { state.model = value; },
allowInherit: true,
inheritPlaceholder: selAgent
? `Inherit from ${selAgent.name}${selAgent.model ? ` (${selAgent.model})` : ""}`
: "Inherit from agent",
});
};
renderTaskModelPicker(state.agent);
agentSelect.addEventListener("change", () => renderTaskModelPicker(agentSelect.value));
// Channel delivery (optional) — post this task's output to a channel on completion.
renderTaskChannelDelivery(execSection, deps, snapshot.channels, state);
deps.addTooltip(
modelLabelEdit,
"Override the agents model for this task only. Useful for routing simple runs to haiku while the agent stays on opus for heavier work.",
);
// ─── Footer ───
const footer = page.createDiv({ cls: "af-create-footer" });
const deleteBtn = footer.createEl("button", { cls: "af-btn-sm danger" });
createIcon(deleteBtn, "trash-2", "af-btn-icon");
deleteBtn.appendText(" Delete");
deleteBtn.onclick = () => {
new ConfirmModal(plugin.app, {
title: `Delete task "${task.taskId}"?`,
body: "The task file will be moved to your system trash and can be recovered.",
confirmText: "Delete",
danger: true,
onConfirm: async () => {
await plugin.repository.deleteTask(task.taskId);
new Notice(`Task "${task.taskId}" deleted.`);
await new Promise((r) => window.setTimeout(r, 200));
await plugin.refreshFromVault();
deps.navigate("kanban");
},
}).open();
};
footer.createDiv({ cls: "af-toolbar-spacer" });
const cancelBtn = footer.createEl("button", { cls: "af-btn-sm", text: "Cancel" });
cancelBtn.onclick = () => deps.navigate("task-detail", task.taskId);
const saveBtn = footer.createEl("button", { cls: "af-btn-sm primary af-create-submit" });
createIcon(saveBtn, "check", "af-btn-icon");
saveBtn.appendText(" Save Changes");
saveBtn.onclick = async () => {
const parseTags = (s: string) => s.split(",").map((t) => t.trim()).filter(Boolean);
const effectiveType = state.scheduleEnabled
? state.scheduleMode === "once"
? "once"
: "recurring"
: "immediate";
if (effectiveType === "once" && !state.runAt) {
new Notice("Pick a date/time for the one-time run.");
return;
}
const restoreSubmit = deps.markSubmitBusy(saveBtn, "Saving...", "check", "Save Changes");
try {
await plugin.repository.updateTask(task.taskId, {
agent: state.agent,
type: effectiveType,
priority: state.priority,
schedule: effectiveType === "recurring" ? state.schedule.trim() : "",
runAt: effectiveType === "once" ? state.runAt : "",
enabled: state.enabled,
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(),
});
new Notice(`Task "${task.taskId}" updated.`);
await plugin.refreshFromVault();
deps.navigate("task-detail", task.taskId);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to update task: ${msg}`);
restoreSubmit();
}
};
}

View file

@ -0,0 +1,396 @@
import { Notice, setIcon } from "obsidian";
import type { AgentConfig, AgentHealth, RunLogData, UsageRecord } from "../../types";
import { truncate } from "../../utils/markdown";
import { createIcon } from "../../utils/icons";
import { renderSections } from "../../utils/memoryFormat";
import type { DashboardPageDeps } from "./shared";
/** View helpers the agent detail page borrows from the dashboard so the
* extracted markup stays byte-identical to what the view used to render
* inline. The active tab is view state the page reads and writes it
* through the get/set delegates so it survives re-renders. */
export interface AgentDetailPageDeps extends DashboardPageDeps {
/** Navigate the dashboard to another page. */
navigate: (page: "edit-agent", context?: string) => void;
healthToClass: (status: AgentHealth) => string;
/** Lucide icon / emoji / initials avatar renderer (owned by the view). */
renderAgentAvatar: (el: HTMLElement, agent: AgentConfig) => void;
/** Dashboard stat card (owned by the view, shared with the overview page). */
renderStatCard: (
container: HTMLElement,
label: string,
value: string,
valueSuffix: string,
iconName: string,
sub: string,
) => void;
/** Comprehensive token + cost totals across run logs and usage records. */
combinedTotals: (runs: RunLogData[], usage: UsageRecord[]) => { tokens: number; cost: number };
/** Run timeline entry shared with the overview page (owned by the view). */
renderTimelineItem: (container: HTMLElement, run: RunLogData) => void;
/** Label + monospace value config row (owned by the view). */
renderConfigRow: (container: HTMLElement, label: string, value: string) => void;
statusToTimelineClass: (status: string) => string;
statusToIconName: (status: string) => string;
statusToBadgeClass: (status: string) => string;
statusToBadgeText: (status: string) => string;
formatStarted: (iso: string) => string;
formatDuration: (seconds: number) => string;
formatTokenCount: (tokens: number) => string;
cronToHuman: (cron: string) => string;
timeUntil: (date: Date) => string;
/** Open the run-details slideover (owned by the view). */
openSlideover: (run: RunLogData) => void;
/** Open the chat panel for this agent (owned by the view). */
openChatSlideover: (agent: AgentConfig) => void;
/** Active detail tab — view state so it survives re-renders. */
getDetailTab: () => string;
setDetailTab: (tab: string) => void;
/** Re-render the whole dashboard view (tab switches). */
rerender: () => Promise<void>;
}
export function renderAgentDetailPage(container: HTMLElement, deps: AgentDetailPageDeps, agentName: string | undefined): void {
const page = container.createDiv({ cls: "af-agent-detail-page" });
if (!agentName) {
deps.renderEmptyState(page, "bot", "No agent selected", "Select an agent from the list");
return;
}
const agent = deps.plugin.runtime.getSnapshot().agents.find((a) => a.name === agentName);
if (!agent) {
deps.renderEmptyState(page, "bot", "Agent not found", `Agent "${agentName}" was not found`);
return;
}
const state = deps.plugin.runtime.getAgentState(agent.name);
const agentRuns = deps.plugin.runtime.getRecentRuns().filter((r) => r.agent === agent.name);
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const avatar = headerLeft.createDiv({
cls: `af-agent-card-avatar ${deps.healthToClass(state.status)}`,
});
deps.renderAgentAvatar(avatar, agent);
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: agent.name });
headerInfo.createDiv({ cls: "af-detail-header-desc", text: agent.description ?? "No description" });
const headerActions = header.createDiv({ cls: "af-detail-header-actions" });
// Chat — primary action
const chatBtn = headerActions.createEl("button", { cls: "af-btn-sm primary" });
createIcon(chatBtn, "message-circle", "af-btn-icon");
chatBtn.appendText(" Chat");
chatBtn.onclick = () => deps.openChatSlideover(agent);
// Run Now / Enable — secondary
if (agent.enabled) {
const runBtn = headerActions.createEl("button", { cls: "af-btn-sm" });
createIcon(runBtn, "play", "af-btn-icon");
runBtn.appendText(" Run Now");
runBtn.onclick = () => void deps.plugin.runAgentPrompt(agent.name);
const pauseBtn = headerActions.createEl("button", { cls: "af-btn-sm" });
createIcon(pauseBtn, "pause", "af-btn-icon");
pauseBtn.appendText(" Disable");
pauseBtn.onclick = () => void deps.plugin.toggleAgent(agent.name, false);
} else {
const enableBtn = headerActions.createEl("button", { cls: "af-btn-sm" });
createIcon(enableBtn, "play", "af-btn-icon");
enableBtn.appendText(" Enable");
enableBtn.onclick = () => void deps.plugin.toggleAgent(agent.name, true);
}
const detailEditBtn = headerActions.createEl("button", { cls: "af-btn-sm" });
createIcon(detailEditBtn, "edit", "af-btn-icon");
detailEditBtn.appendText(" Edit");
detailEditBtn.onclick = () => deps.navigate("edit-agent", agent.name);
const deleteBtn = headerActions.createEl("button", { cls: "af-btn-sm danger" });
createIcon(deleteBtn, "trash-2", "af-btn-icon");
deleteBtn.appendText(" Delete");
deleteBtn.onclick = () => void deps.plugin.deleteAgent(agent.name);
// Tabs
const tabs = page.createDiv({ cls: "af-detail-tabs" });
const tabDefs = [
{ id: "overview", label: "Overview", icon: "layout-dashboard" },
{ id: "config", label: "Config", icon: "settings" },
{ id: "runs", label: "Runs", icon: "scroll-text" },
{ id: "memory", label: "Memory", icon: "file-text" },
];
for (const t of tabDefs) {
const tabBtn = tabs.createEl("button", {
cls: `af-detail-tab${deps.getDetailTab() === t.id ? " active" : ""}`,
});
createIcon(tabBtn, t.icon, "af-tab-icon");
tabBtn.appendText(` ${t.label}`);
tabBtn.onclick = () => {
deps.setDetailTab(t.id);
void deps.rerender();
};
}
const tabContent = page.createDiv({ cls: "af-detail-tab-content" });
switch (deps.getDetailTab()) {
case "overview":
renderAgentOverviewTab(tabContent, deps, agent, agentRuns);
break;
case "config":
renderAgentConfigTab(tabContent, deps, agent);
break;
case "runs":
renderAgentRunsTab(tabContent, deps, agentRuns);
break;
case "memory":
void renderAgentMemoryTab(tabContent, deps, agent);
break;
}
}
function renderAgentOverviewTab(container: HTMLElement, deps: AgentDetailPageDeps, agent: AgentConfig, runs: RunLogData[]): void {
// Stats row
const statsRow = container.createDiv({ cls: "af-dash-grid" });
const totalRuns = runs.length;
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;
// Comprehensive: this agent's run logs + its chat/channel usage over the window.
const agentUsage = deps.plugin.runtime.getUsageRecords().filter((u) => u.agent === agent.name);
const { tokens: totalTokens, cost: totalCostAgent } = deps.combinedTotals(runs, agentUsage);
const costSuffixAgent = totalCostAgent > 0 ? ` \u00B7 $${totalCostAgent.toFixed(2)}` : "";
deps.renderStatCard(statsRow, "Total Runs", String(totalRuns), "", "activity", "all time");
deps.renderStatCard(statsRow, "Success Rate", `${successRate}%`, "", "check-circle-2", `${successRuns}/${totalRuns}`);
deps.renderStatCard(statsRow, "Avg Time", `${avgTime}s`, "", "clock", "per run");
deps.renderStatCard(statsRow, "Total Tokens", deps.formatTokenCount(totalTokens), "", "zap", `all time${costSuffixAgent}`);
// Heartbeat — compact status only (instruction shown in Config tab)
if (agent.isFolder && (agent.heartbeatBody.trim() || agent.heartbeatEnabled)) {
const hbSection = container.createDiv({ cls: "af-section-card" });
const hbHeader = hbSection.createDiv({ cls: "af-section-header" });
const hbTitle = hbHeader.createDiv({ cls: "af-section-title" });
createIcon(hbTitle, "heart-pulse");
hbTitle.appendText(" Heartbeat");
const hbActions = hbHeader.createDiv({ cls: "af-detail-header-actions" });
const hbToggle = hbActions.createDiv({ cls: `af-agent-card-toggle${agent.heartbeatEnabled ? " on" : ""}` });
hbToggle.onclick = async () => {
const isOn = hbToggle.hasClass("on");
await deps.plugin.repository.updateHeartbeat(agent.name, { enabled: !isOn });
await deps.plugin.refreshFromVault();
new Notice(`Heartbeat ${!isOn ? "enabled" : "paused"} for ${agent.name}`);
};
const hbBody = hbSection.createDiv({ cls: "af-config-form" });
deps.renderConfigRow(hbBody, "Schedule", deps.cronToHuman(agent.heartbeatSchedule));
const nextRun = deps.plugin.runtime.getNextHeartbeat(agent.name);
if (nextRun && agent.heartbeatEnabled) {
deps.renderConfigRow(hbBody, "Next run", deps.timeUntil(nextRun));
}
if (agent.heartbeatChannel) {
deps.renderConfigRow(hbBody, "Channel", agent.heartbeatChannel);
}
}
// Skills
if (agent.skills.length > 0) {
const skillsSection = container.createDiv({ cls: "af-section-card" });
const skillsHeader = skillsSection.createDiv({ cls: "af-section-header" });
const skillsTitle = skillsHeader.createDiv({ cls: "af-section-title" });
createIcon(skillsTitle, "puzzle");
skillsTitle.appendText(" Skills");
const skillsBody = skillsSection.createDiv({ cls: "af-detail-skills-list" });
for (const skill of agent.skills) {
skillsBody.createSpan({ cls: "af-skill-tag", text: skill });
}
}
// MCP Servers
const agentMcpServers = agent.mcpServers ?? [];
if (agentMcpServers.length > 0) {
const mcpSection = container.createDiv({ cls: "af-section-card" });
const mcpHeader = mcpSection.createDiv({ cls: "af-section-header" });
const mcpTitle = mcpHeader.createDiv({ cls: "af-section-title" });
createIcon(mcpTitle, "plug");
mcpTitle.appendText(" MCP Servers");
const mcpBody = mcpSection.createDiv({ cls: "af-mcp-overview-list" });
const cachedServers = deps.plugin.repository.getMcpServers();
for (const serverName of agentMcpServers) {
const serverInfo = cachedServers.find((s) => s.name === serverName);
const row = mcpBody.createDiv({ cls: "af-mcp-overview-row" });
const dot = row.createSpan({
cls: `af-mcp-status-dot ${serverInfo ? (serverInfo.enabled ? serverInfo.status : "disabled") : "disconnected"}`,
});
dot.title = serverInfo ? (serverInfo.enabled ? serverInfo.status : "disabled") : "unknown";
row.createSpan({ cls: "af-mcp-overview-name", text: serverName });
const toolCount = serverInfo?.toolDetails.length ?? serverInfo?.tools.length ?? 0;
if (toolCount > 0) {
row.createSpan({ cls: "af-mcp-overview-tools", text: `${toolCount} tools` });
} else if (serverInfo && !serverInfo.enabled) {
row.createSpan({ cls: "af-mcp-overview-tools af-muted", text: "disabled" });
} else if (serverInfo?.status === "needs-auth") {
row.createSpan({ cls: "af-mcp-overview-tools af-muted", text: "needs auth" });
}
}
}
// Permissions
const hasPermRules = agent.permissionRules.allow.length > 0 || agent.permissionRules.deny.length > 0;
if (hasPermRules || (agent.permissionMode && agent.permissionMode !== "default")) {
const permSection = container.createDiv({ cls: "af-section-card" });
const permHeader = permSection.createDiv({ cls: "af-section-header" });
const permTitle = permHeader.createDiv({ cls: "af-section-title" });
createIcon(permTitle, "shield-check");
permTitle.appendText(" Permissions");
const permBody = permSection.createDiv({ cls: "af-config-form" });
deps.renderConfigRow(permBody, "Mode", agent.permissionMode || "default");
if (agent.permissionRules.allow.length > 0) {
deps.renderConfigRow(permBody, "Allowed", agent.permissionRules.allow.join(", "));
}
if (agent.permissionRules.deny.length > 0) {
deps.renderConfigRow(permBody, "Denied", agent.permissionRules.deny.join(", "));
}
}
// Recent runs
const runsSection = container.createDiv({ cls: "af-section-card" });
const runsHeader = runsSection.createDiv({ cls: "af-section-header" });
const runsTitle = runsHeader.createDiv({ cls: "af-section-title" });
createIcon(runsTitle, "scroll-text");
runsTitle.appendText(" Recent Runs");
const runsBody = runsSection.createDiv({ cls: "af-timeline" });
if (runs.length === 0) {
deps.renderEmptyState(runsBody, "scroll-text", "No runs yet", "");
} else {
for (const run of runs.slice(0, 5)) {
deps.renderTimelineItem(runsBody, run);
}
}
}
function renderAgentConfigTab(container: HTMLElement, deps: AgentDetailPageDeps, agent: AgentConfig): void {
const form = container.createDiv({ cls: "af-config-form" });
deps.renderConfigRow(form, "Name", agent.name);
deps.renderConfigRow(form, "Description", agent.description ?? "");
deps.renderConfigRow(form, "Model", agent.model);
deps.renderConfigRow(form, "Timeout", `${agent.timeout}s`);
deps.renderConfigRow(form, "Working Directory", agent.cwd ?? "(vault root)");
deps.renderConfigRow(form, "Permission Mode", agent.permissionMode || "default");
deps.renderConfigRow(form, "Approval Required", agent.approvalRequired.join(", ") || "none");
if (agent.permissionRules.allow.length > 0) {
deps.renderConfigRow(form, "Allowed Commands", agent.permissionRules.allow.join(", "));
}
if (agent.permissionRules.deny.length > 0) {
deps.renderConfigRow(form, "Blocked Commands", agent.permissionRules.deny.join(", "));
}
deps.renderConfigRow(form, "Memory", agent.memory ? "Enabled" : "Disabled");
deps.renderConfigRow(
form,
"Auto-compact",
agent.autoCompactThreshold && agent.autoCompactThreshold > 0
? `at ${agent.autoCompactThreshold}% context`
: "disabled",
);
if (agent.wikiReferences && agent.wikiReferences.length > 0) {
deps.renderConfigRow(
form,
"Wiki access",
agent.wikiReferences.map((r) => r.agent).join(", "),
);
}
deps.renderConfigRow(form, "Tags", agent.tags.join(", ") || "none");
const promptSection = form.createDiv({ cls: "af-config-prompt-section" });
promptSection.createDiv({ cls: "af-slideover-section-title", text: "SYSTEM PROMPT" });
promptSection.createDiv({ cls: "af-output-block", text: agent.body || "(empty)" });
if (agent.heartbeatBody.trim()) {
const hbPromptSection = form.createDiv({ cls: "af-config-prompt-section" });
hbPromptSection.createDiv({ cls: "af-slideover-section-title", text: "HEARTBEAT INSTRUCTION" });
hbPromptSection.createDiv({ cls: "af-output-block", text: agent.heartbeatBody });
}
const actions = form.createDiv({ cls: "af-slideover-actions" });
const editBtn = actions.createEl("button", { cls: "af-btn-sm primary" });
createIcon(editBtn, "edit", "af-btn-icon");
editBtn.appendText(" Edit Agent");
editBtn.onclick = () => deps.navigate("edit-agent", agent.name);
}
function renderAgentRunsTab(container: HTMLElement, deps: AgentDetailPageDeps, runs: RunLogData[]): void {
if (runs.length === 0) {
deps.renderEmptyState(container, "scroll-text", "No runs yet", "Run this agent to see history");
return;
}
for (const run of runs) {
const item = container.createDiv({ cls: "af-run-list-item" });
const statusIcon = item.createDiv({ cls: `af-tl-icon ${deps.statusToTimelineClass(run.status)}` });
setIcon(statusIcon, deps.statusToIconName(run.status));
const body = item.createDiv({ cls: "af-tl-body" });
const titleRow = body.createDiv({ cls: "af-tl-title" });
titleRow.createSpan({ text: run.task });
titleRow.createSpan({ cls: `af-status-badge ${deps.statusToBadgeClass(run.status)}`, text: deps.statusToBadgeText(run.status) });
const meta = body.createDiv({ cls: "af-tl-meta" });
meta.createSpan({ text: `${deps.formatStarted(run.started)} \u00B7 ${deps.formatDuration(run.durationSeconds)}` });
if (run.tokensUsed) {
meta.createSpan({ text: `${run.tokensUsed.toLocaleString()} tokens` });
}
body.createDiv({ cls: "af-tl-desc", text: truncate(run.output, 120) });
item.onclick = () => deps.openSlideover(run);
}
}
async function renderAgentMemoryTab(container: HTMLElement, deps: AgentDetailPageDeps, agent: AgentConfig): Promise<void> {
if (!agent.memory) {
deps.renderEmptyState(container, "file-text", "Memory disabled", "Enable memory in agent config");
return;
}
const wm = await deps.plugin.repository.readWorkingMemory(agent.name);
// Header: token usage vs budget + reflection status.
const meta = container.createDiv({ cls: "af-form-help" });
const used = wm?.tokenEstimate ?? 0;
const reflectBits = agent.reflection.enabled
? `reflection on (${agent.reflection.schedule})`
: "reflection off";
meta.setText(`~${used} / ${agent.memoryTokenBudget} tokens · ${reflectBits}`);
if (!wm || wm.sections.length === 0) {
deps.renderEmptyState(container, "file-text", "No memories yet", "Agent will learn from runs");
} else {
const block = container.createDiv({ cls: "af-output-block" });
block.setText(renderSections(wm.sections));
}
const actions = container.createDiv({ cls: "af-slideover-actions" });
const reflectBtn = actions.createEl("button", { cls: "af-btn-sm" });
createIcon(reflectBtn, "moon", "af-btn-icon");
reflectBtn.appendText(" Reflect now");
reflectBtn.onclick = async () => {
reflectBtn.disabled = true;
reflectBtn.setText(" Reflecting…");
const res = await deps.plugin.runtime.runReflectionNow(agent.name);
new Notice(`Agent Fleet: ${res.message}`);
await renderAgentMemoryTab((container.empty(), container), deps, agent);
};
const editBtn = actions.createEl("button", { cls: "af-btn-sm" });
createIcon(editBtn, "external-link", "af-btn-icon");
editBtn.appendText(" Open in Editor");
editBtn.onclick = () =>
void deps.plugin.openPath(deps.plugin.repository.getWorkingMemoryPath(agent.name));
}

View file

@ -0,0 +1,191 @@
import { Notice, setIcon } from "obsidian";
import type { AgentConfig, AgentHealth, RunLogData, SkillConfig, TaskConfig } from "../../types";
import { createIcon } from "../../utils/icons";
import type { DashboardPageDeps } from "./shared";
/** View helpers the agents page borrows from the dashboard so the
* extracted markup stays byte-identical to what the view used to render
* inline. */
export interface AgentsPageDeps extends DashboardPageDeps {
/** Navigate the dashboard to another page. */
navigate: (page: "agent-detail" | "edit-agent", context?: string) => void;
healthToClass: (status: AgentHealth) => string;
/** Lucide icon / emoji / initials avatar renderer (owned by the view). */
renderAgentAvatar: (el: HTMLElement, agent: AgentConfig) => void;
/** Value + label stat cell shared with the channel cards (owned by the view). */
renderAgentStat: (container: HTMLElement, value: string, label: string) => void;
formatTokenCount: (tokens: number) => string;
cronToHuman: (cron: string) => string;
timeUntil: (date: Date) => string;
}
export function renderAgentsPage(container: HTMLElement, deps: AgentsPageDeps): void {
const page = container.createDiv({ cls: "af-agents-page" });
const snapshot = deps.plugin.runtime.getSnapshot();
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
toolbar.createDiv({ cls: "af-page-title", text: "Agents" });
toolbar.createDiv({ cls: "af-toolbar-spacer" });
const newBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" });
createIcon(newBtn, "plus", "af-btn-icon");
newBtn.appendText(" New Agent");
newBtn.onclick = () => void deps.plugin.createAgentTemplate();
const grid = page.createDiv({ cls: "af-agents-grid" });
if (snapshot.agents.length === 0) {
deps.renderEmptyState(grid, "bot", "No agents configured", "Create your first agent to get started", {
label: "New Agent",
onClick: () => void deps.plugin.createAgentTemplate(),
});
return;
}
// Fetch runs once and group by agent — avoids re-filtering the full run
// list for every card (O(agents × runs)).
const runsByAgent = new Map<string, RunLogData[]>();
for (const run of deps.plugin.runtime.getRecentRuns()) {
const list = runsByAgent.get(run.agent);
if (list) {
list.push(run);
} else {
runsByAgent.set(run.agent, [run]);
}
}
for (const agent of snapshot.agents) {
renderAgentCard(grid, deps, agent, snapshot, runsByAgent.get(agent.name) ?? []);
}
}
function renderAgentCard(
container: HTMLElement,
deps: AgentsPageDeps,
agent: AgentConfig,
snapshot: { tasks: TaskConfig[]; skills: SkillConfig[] },
agentRuns: RunLogData[],
): void {
const state = deps.plugin.runtime.getAgentState(agent.name);
const card = container.createDiv({ cls: `af-agent-card${agent.enabled ? "" : " disabled"}` });
// Header
const header = card.createDiv({ cls: "af-agent-card-header" });
const avatarCls = agent.enabled ? deps.healthToClass(state.status) : "disabled";
const avatar = header.createDiv({ cls: `af-agent-card-avatar ${avatarCls}` });
deps.renderAgentAvatar(avatar, agent);
const titleBlock = header.createDiv({ cls: "af-agent-card-titleblock" });
const nameRow = titleBlock.createDiv({ cls: "af-agent-card-name" });
nameRow.appendText(agent.name);
if (agent.heartbeatEnabled && agent.heartbeatSchedule) {
const hbIcon = nameRow.createSpan({ cls: "af-heartbeat-indicator" });
setIcon(hbIcon, "heart-pulse");
hbIcon.title = `Heartbeat: ${agent.heartbeatSchedule}`;
}
titleBlock.createDiv({ cls: "af-agent-card-desc", text: agent.description ?? "No description" });
const toggle = header.createDiv({ cls: `af-agent-card-toggle${agent.enabled ? " on" : ""}` });
toggle.onclick = (e) => {
e.stopPropagation();
void deps.plugin.toggleAgent(agent.name, !agent.enabled);
};
// Stats
const stats = card.createDiv({ cls: "af-agent-card-stats" });
const totalRuns = agentRuns.length;
const successRuns = agentRuns.filter((r) => r.status === "success").length;
const successRate = totalRuns > 0 ? Math.round((successRuns / totalRuns) * 100) : 0;
const avgTime =
totalRuns > 0
? Math.round(agentRuns.reduce((s, r) => s + r.durationSeconds, 0) / totalRuns)
: 0;
const totalTokens = agentRuns.reduce((s, r) => s + (r.tokensUsed ?? 0), 0);
deps.renderAgentStat(stats, String(totalRuns), "Runs");
deps.renderAgentStat(stats, `${successRate}%`, "Success");
deps.renderAgentStat(stats, `${avgTime}s`, "Avg Time");
deps.renderAgentStat(stats, deps.formatTokenCount(totalTokens), "Tokens");
// Skills
if (agent.skills.length > 0) {
const skillsRow = card.createDiv({ cls: "af-agent-card-skills" });
for (const skill of agent.skills) {
skillsRow.createSpan({ cls: "af-skill-tag", text: skill });
}
}
// Heartbeat status — same gate + next-run source as the agent detail
// Overview tab, with the same quick toggle (repository.updateHeartbeat).
if (agent.isFolder && (agent.heartbeatBody.trim() || agent.heartbeatEnabled)) {
const hbRow = card.createDiv({ cls: "af-agent-card-heartbeat" });
const hbRowIcon = hbRow.createSpan({ cls: "af-agent-card-hb-icon" });
setIcon(hbRowIcon, "heart-pulse");
const parts: string[] = [deps.cronToHuman(agent.heartbeatSchedule)];
if (agent.heartbeatEnabled) {
const nextHb = deps.plugin.runtime.getNextHeartbeat(agent.name);
if (nextHb) parts.push(`next ${deps.timeUntil(nextHb)}`);
} else {
parts.push("paused");
}
hbRow.createSpan({
cls: "af-agent-card-hb-text",
text: `Heartbeat · ${parts.join(" · ")}`,
});
const hbToggle = hbRow.createDiv({
cls: `af-agent-card-toggle af-agent-card-toggle-sm${agent.heartbeatEnabled ? " on" : ""}`,
});
hbToggle.title = agent.heartbeatEnabled ? "Pause heartbeat" : "Enable heartbeat";
hbToggle.onclick = (e) => {
e.stopPropagation();
void (async () => {
await deps.plugin.repository.updateHeartbeat(agent.name, { enabled: !agent.heartbeatEnabled });
await deps.plugin.refreshFromVault();
new Notice(`Heartbeat ${!agent.heartbeatEnabled ? "enabled" : "paused"} for ${agent.name}`);
})();
};
}
// Footer
const footer = card.createDiv({ cls: "af-agent-card-footer" });
const metaParts: string[] = [`Model: ${agent.model}`];
if (agent.approvalRequired.length > 0) {
metaParts.push(`Approval: ${agent.approvalRequired.join(", ")}`);
}
if (agent.memory) metaParts.push("Memory: on");
if (!agent.enabled) metaParts.unshift("Disabled");
footer.createSpan({ cls: "af-agent-card-meta", text: metaParts.join(" \u00B7 ") });
const actions = footer.createDiv({ cls: "af-agent-card-actions" });
if (!agent.enabled) {
const enableBtn = actions.createEl("button", { cls: "af-btn-sm", text: "Enable" });
enableBtn.onclick = (e) => {
e.stopPropagation();
void deps.plugin.toggleAgent(agent.name, true);
};
}
const editBtn = actions.createEl("button", { cls: "af-btn-sm" });
createIcon(editBtn, "edit", "af-btn-icon");
editBtn.appendText(" Edit");
editBtn.onclick = (e) => {
e.stopPropagation();
deps.navigate("edit-agent", agent.name);
};
if (agent.enabled) {
const runBtn = actions.createEl("button", { cls: "af-btn-sm primary" });
createIcon(runBtn, "play", "af-btn-icon");
runBtn.appendText(" Run");
runBtn.onclick = (e) => {
e.stopPropagation();
void deps.plugin.runAgentPrompt(agent.name);
};
}
card.onclick = () => deps.navigate("agent-detail", agent.name);
}

View file

@ -0,0 +1,114 @@
import { setIcon } from "obsidian";
import type { RunLogData } from "../../types";
import { createIcon } from "../../utils/icons";
import type { DashboardPageDeps } from "./shared";
/** View helpers the approvals page borrows from the dashboard so the
* extracted markup stays byte-identical to what the view used to render
* inline. */
export interface ApprovalsPageDeps extends DashboardPageDeps {
formatStarted: (iso: string) => string;
/** Re-render the whole dashboard view (after an approval is resolved). */
rerender: () => Promise<void>;
}
export function renderApprovalsPage(container: HTMLElement, deps: ApprovalsPageDeps): void {
const page = container.createDiv({ cls: "af-approvals-page" });
const runs = deps.plugin.runtime.getRecentRuns();
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
toolbar.createDiv({ cls: "af-page-title", text: "Approvals" });
toolbar.createDiv({ cls: "af-toolbar-spacer" });
// Pending approvals
const pendingRuns = runs.filter((r) =>
(r.approvals ?? []).some((a) => a.status === "pending"),
);
if (pendingRuns.length > 0) {
const pendingSection = page.createDiv({ cls: "af-section-card" });
const pendingHeader = pendingSection.createDiv({ cls: "af-section-header" });
const pendingTitle = pendingHeader.createDiv({ cls: "af-section-title" });
createIcon(pendingTitle, "alert-triangle");
pendingTitle.appendText(` Pending (${pendingRuns.length})`);
const pendingBody = pendingSection.createDiv({ cls: "af-approvals-list" });
for (const run of pendingRuns) {
renderApprovalItem(pendingBody, deps, run, true);
}
} else {
const emptySection = page.createDiv({ cls: "af-section-card" });
deps.renderEmptyState(emptySection, "shield-check", "No pending approvals", "All clear!");
}
// Resolved approvals history
const resolvedRuns = runs.filter((r) =>
(r.approvals ?? []).some((a) => a.status !== "pending"),
);
if (resolvedRuns.length > 0) {
const resolvedSection = page.createDiv({ cls: "af-section-card" });
const resolvedHeader = resolvedSection.createDiv({ cls: "af-section-header" });
const resolvedTitle = resolvedHeader.createDiv({ cls: "af-section-title" });
createIcon(resolvedTitle, "check-circle-2");
resolvedTitle.appendText(" History");
const resolvedBody = resolvedSection.createDiv({ cls: "af-approvals-list" });
for (const run of resolvedRuns.slice(0, 20)) {
renderApprovalItem(resolvedBody, deps, run, false);
}
}
}
function renderApprovalItem(container: HTMLElement, deps: ApprovalsPageDeps, run: RunLogData, showActions: boolean): void {
for (const approval of run.approvals ?? []) {
if (showActions && approval.status !== "pending") continue;
if (!showActions && approval.status === "pending") continue;
const item = container.createDiv({ cls: "af-approval-item" });
const iconEl = item.createDiv({ cls: "af-approval-item-icon" });
if (approval.status === "pending") {
setIcon(iconEl, "shield-check");
iconEl.addClass("pending");
} else if (approval.status === "approved") {
setIcon(iconEl, "check-circle-2");
iconEl.addClass("approved");
} else {
setIcon(iconEl, "x-circle");
iconEl.addClass("rejected");
}
const body = item.createDiv({ cls: "af-approval-item-body" });
body.createDiv({
cls: "af-approval-item-title",
text: `${run.agent} \u2192 ${approval.tool}`,
});
body.createDiv({
cls: "af-approval-item-meta",
text: `Task: ${run.task} \u00B7 ${approval.command ?? "no command"} \u00B7 ${deps.formatStarted(run.started)}`,
});
if (approval.reason) {
body.createDiv({ cls: "af-approval-item-reason", text: `Reason: ${approval.reason}` });
}
if (showActions && approval.status === "pending") {
const actions = item.createDiv({ cls: "af-approval-item-actions" });
const approveBtn = actions.createEl("button", { cls: "af-btn-approve" });
createIcon(approveBtn, "check-circle-2", "af-btn-icon");
approveBtn.appendText(" Approve");
approveBtn.onclick = () =>
void deps.plugin.runtime
.resolveApproval(run, approval.tool, "approved")
.then(() => deps.rerender());
const rejectBtn = actions.createEl("button", { cls: "af-btn-reject" });
createIcon(rejectBtn, "x-circle", "af-btn-icon");
rejectBtn.appendText(" Reject");
rejectBtn.onclick = () =>
void deps.plugin.runtime
.resolveApproval(run, approval.tool, "rejected")
.then(() => deps.rerender());
}
}
}

View file

@ -0,0 +1,146 @@
import { setIcon } from "obsidian";
import type { ChannelConfig } from "../../types";
import { createIcon } from "../../utils/icons";
import { channelStatusToAvatarClass } from "../forms/channelForm";
import type { DashboardPageDeps } from "./shared";
/** View helpers the channels page borrows from the dashboard so the
* extracted markup stays byte-identical to what the view used to render
* inline. */
export interface ChannelsPageDeps extends DashboardPageDeps {
/** Navigate the dashboard to another page. */
navigate: (page: "create-channel" | "edit-channel", context?: string) => void;
/** Value + label stat cell shared with the agent cards (owned by the view). */
renderAgentStat: (container: HTMLElement, value: string, label: string) => void;
}
export function renderChannelsPage(container: HTMLElement, deps: ChannelsPageDeps): void {
const page = container.createDiv({ cls: "af-agents-page" });
const snapshot = deps.plugin.runtime.getSnapshot();
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
toolbar.createDiv({ cls: "af-page-title", text: "Channels" });
toolbar.createDiv({ cls: "af-toolbar-spacer" });
const newBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" });
createIcon(newBtn, "plus", "af-btn-icon");
newBtn.appendText(" New Channel");
newBtn.onclick = () => deps.navigate("create-channel");
const grid = page.createDiv({ cls: "af-agents-grid" });
if (snapshot.channels.length === 0) {
deps.renderEmptyState(
grid,
"radio",
"No channels configured",
"Connect an agent to Slack or another chat platform",
{ label: "New Channel", onClick: () => deps.navigate("create-channel") },
);
return;
}
for (const channel of snapshot.channels) {
renderChannelCard(grid, deps, channel, snapshot.validationIssues);
}
}
function renderChannelCard(
container: HTMLElement,
deps: ChannelsPageDeps,
channel: ChannelConfig,
validationIssues: Array<{ path: string; message: string }>,
): void {
const status = deps.plugin.channelManager?.getChannelStatus(channel.name) ?? "disabled";
const avatarCls = channelStatusToAvatarClass(status);
const cardCls = channel.enabled && status !== "disabled" ? "af-agent-card" : "af-agent-card disabled";
const card = container.createDiv({ cls: cardCls });
card.setCssStyles({ cursor: "default" }); // No card-level click — channels have no detail page
// Header — avatar (icon + status color) + name/agent + type pill
const header = card.createDiv({ cls: "af-agent-card-header" });
const avatar = header.createDiv({ cls: `af-agent-card-avatar ${avatarCls}` });
setIcon(avatar, "radio");
const titleBlock = header.createDiv({ cls: "af-agent-card-titleblock" });
titleBlock.createDiv({ cls: "af-agent-card-name", text: channel.name });
titleBlock.createDiv({
cls: "af-agent-card-desc",
text: `Default: ${channel.defaultAgent}`,
});
const statusPill = header.createSpan({ cls: `af-pill ${channelStatusPillColor(status)}` });
statusPill.createSpan({ cls: "af-dot" });
statusPill.appendText(` ${status}`);
// Agents — show all allowed agents as tags (same style as skill tags on agent cards)
if (channel.allowedAgents.length > 0) {
const agentsRow = card.createDiv({ cls: "af-agent-card-skills" });
for (const name of channel.allowedAgents) {
const tag = agentsRow.createSpan({ cls: "af-skill-tag", text: name });
if (name === channel.defaultAgent) {
tag.setCssStyles({ fontWeight: "700" });
}
}
}
// Stats — sessions, messages in/out, allowlist
const stats = card.createDiv({ cls: "af-agent-card-stats" });
const sessionCount = deps.plugin.channelManager?.getSessionCount(channel.name) ?? 0;
const metrics = deps.plugin.channelManager?.getMetrics(channel.name);
const agentCount = channel.allowedAgents.length > 0
? String(channel.allowedAgents.length)
: "all";
deps.renderAgentStat(stats, agentCount, "Agents");
deps.renderAgentStat(stats, String(sessionCount), "Sessions");
deps.renderAgentStat(stats, String(metrics?.messagesReceived ?? 0), "In");
deps.renderAgentStat(stats, String(metrics?.messagesSent ?? 0), "Out");
// Footer — meta text + edit action
const footer = card.createDiv({ cls: "af-agent-card-footer" });
const metaParts: string[] = [channel.type];
if (!channel.enabled) metaParts.push("disabled");
if (channel.allowedUsers.length > 0) metaParts.push(`${channel.allowedUsers.length} user(s)`);
else metaParts.push("allowlist empty");
footer.createSpan({ cls: "af-agent-card-meta", text: metaParts.join(" \u00B7 ") });
const actions = footer.createDiv({ cls: "af-agent-card-actions" });
const editBtn = actions.createEl("button", { cls: "af-btn-sm" });
createIcon(editBtn, "edit", "af-btn-icon");
editBtn.appendText(" Edit");
editBtn.onclick = (e) => {
e.stopPropagation();
deps.navigate("edit-channel", channel.name);
};
// Validation issues — red-tinted block below the footer
const issues = validationIssues.filter((i) => i.path === channel.filePath);
if (issues.length > 0) {
const issuesBox = card.createDiv({ cls: "af-channel-issues" });
for (const issue of issues) {
issuesBox.createDiv({ cls: "af-channel-issue-row", text: issue.message });
}
}
// No card-level click — channels have no detail page. Editing is via the edit button.
}
/** Map a channel status to an `af-pill` color variant for the header badge. */
function channelStatusPillColor(status: string): string {
switch (status) {
case "connected":
return "green";
case "connecting":
case "reconnecting":
return "blue";
case "needs-auth":
case "error":
return "red";
case "stopped":
case "disabled":
default:
return "";
}
}

358
src/views/pages/mcpPage.ts Normal file
View file

@ -0,0 +1,358 @@
import { Notice, setIcon } from "obsidian";
import type { McpServer, McpTool } from "../../types";
import { truncate } from "../../utils/markdown";
import { splitLines } from "../../utils/platform";
import { createIcon } from "../../utils/icons";
import type { DashboardPageDeps } from "./shared";
/** View helpers the MCP servers page borrows from the dashboard so the
* extracted markup stays byte-identical to what the view used to render
* inline. The probe cache and in-flight auth set are view state the page
* receives the live references so results survive re-renders. */
export interface McpPageDeps extends DashboardPageDeps {
/** Navigate the dashboard to another page. */
navigate: (page: "add-mcp-server") => void;
/** Label + monospace value row used by the detail slideover (owned by the view). */
renderDetailRow: (container: HTMLElement, label: string, value: string) => void;
/** Re-render the whole dashboard view. */
rerender: () => Promise<void>;
/** View content element the detail slideover overlay attaches to. */
contentEl: HTMLElement;
/** Transient per-server tool probe results (name → tools), owned by the view. */
mcpProbeCache: Map<string, McpTool[]>;
/** Names of servers with an OAuth flow in flight, owned by the view. */
authenticatingServers: Set<string>;
}
export function renderMcpPage(container: HTMLElement, deps: McpPageDeps): void {
const page = container.createDiv({ cls: "af-agents-page" });
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
toolbar.createDiv({ cls: "af-page-title", text: "MCP Servers" });
toolbar.createDiv({ cls: "af-toolbar-spacer" });
const addBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" });
createIcon(addBtn, "plus", "af-btn-icon");
addBtn.appendText(" Add Server");
addBtn.onclick = () => deps.navigate("add-mcp-server");
const servers = deps.plugin.repository.getMcpServers();
if (servers.length === 0) {
deps.renderEmptyState(page, "plug", "No MCP servers registered", "Click 'Add Server' above to register one.");
return;
}
const grid = page.createDiv({ cls: "af-agents-grid" });
for (const server of servers) {
renderMcpCard(grid, deps, server);
}
}
/** Whether the server has a stored auth token (OAuth/static bearer). */
function mcpHasToken(deps: McpPageDeps, server: McpServer): boolean {
return deps.plugin.mcpAuth.hasToken(server.name);
}
/** Whether an http/sse server still needs the user to authenticate (auth is
* oauth/bearer but no token is stored yet). */
function mcpNeedsAuth(deps: McpPageDeps, server: McpServer): boolean {
return (
server.type !== "stdio" &&
(server.auth === "oauth" || server.auth === "bearer") &&
!mcpHasToken(deps, server)
);
}
function renderMcpCard(container: HTMLElement, deps: McpPageDeps, server: McpServer): void {
const card = container.createDiv({ cls: `af-mcp-card${server.enabled ? "" : " af-mcp-card-disabled"}` });
const needsAuth = mcpNeedsAuth(deps, server);
const header = card.createDiv({ cls: "af-agent-card-header" });
const statusClass = !server.enabled ? "disabled" : needsAuth ? "pending" : "idle";
const avatarEl = header.createDiv({ cls: `af-agent-card-avatar ${statusClass}` });
setIcon(avatarEl, "plug");
const titleBlock = header.createDiv({ cls: "af-agent-card-titleblock" });
titleBlock.createDiv({ cls: "af-agent-card-name", text: server.name });
const metaRow = titleBlock.createDiv({ cls: "af-agent-card-desc af-mcp-meta" });
metaRow.createSpan({ cls: "af-mcp-type-badge", text: server.type });
if (server.source === "imported") {
metaRow.createSpan({ cls: "af-badge", text: "imported" });
}
// Enable/disable toggle — writes the registry file frontmatter.
const toggle = header.createDiv({ cls: `af-agent-card-toggle${server.enabled ? " on" : ""}` });
toggle.onclick = (e) => {
e.stopPropagation();
void deps.plugin.repository.setMcpServerEnabled(server.name, !server.enabled).then(async () => {
await deps.plugin.refreshFromVault();
void deps.rerender();
});
};
// Status badge
const statusBadge = card.createDiv({ cls: `af-mcp-status-badge ${!server.enabled ? "disabled" : needsAuth ? "needs-auth" : "connected"}` });
const statusIcon = statusBadge.createSpan();
if (!server.enabled) {
setIcon(statusIcon, "pause");
statusBadge.createSpan({ text: " Disabled" });
} else if (needsAuth) {
setIcon(statusIcon, "alert-circle");
statusBadge.createSpan({ text: " Needs auth" });
} else {
setIcon(statusIcon, "check-circle");
statusBadge.createSpan({ text: server.type === "stdio" ? " Enabled" : " Authenticated" });
}
if (server.description) {
const desc = truncateDescription(server.description, 120);
card.createDiv({ cls: "af-mcp-description", text: desc });
}
const urlOrCmd = server.url ?? server.command ?? "";
if (urlOrCmd) {
card.createDiv({ cls: "af-mcp-command", text: truncate(urlOrCmd, 60) });
}
// Tool count from the on-demand probe cache (if probed this session).
const probed = deps.mcpProbeCache.get(server.name);
if (probed && probed.length > 0) {
const toolFooter = card.createDiv({ cls: "af-mcp-tool-footer" });
const toolCount = toolFooter.createDiv({ cls: "af-mcp-tool-count" });
const toolIcon = toolCount.createSpan();
setIcon(toolIcon, "wrench");
toolCount.createSpan({ text: ` ${probed.length} tools` });
const chips = toolFooter.createDiv({ cls: "af-mcp-tool-chips" });
for (const t of probed.slice(0, 4)) {
chips.createSpan({ cls: "af-mcp-tool-chip", text: t.name });
}
if (probed.length > 4) {
chips.createSpan({ cls: "af-mcp-tool-chip af-mcp-tool-chip-more", text: `+${probed.length - 4}` });
}
}
// Auth / authenticating indicator for http/sse servers.
if (deps.authenticatingServers.has(server.name)) {
const authRow = card.createDiv({ cls: "af-mcp-auth-row" });
const authBtn = authRow.createEl("button", { cls: "af-btn-sm primary", attr: { disabled: "true" } });
const spinIcon = authBtn.createSpan({ cls: "af-spin" });
setIcon(spinIcon, "loader-2");
authBtn.appendText(" Authenticating…");
} else if (server.enabled && needsAuth && server.auth === "oauth") {
const authRow = card.createDiv({ cls: "af-mcp-auth-row" });
const authBtn = authRow.createEl("button", { cls: "af-btn-sm primary" });
const authIcon = authBtn.createSpan();
setIcon(authIcon, "key");
authBtn.appendText(" Authenticate");
authBtn.onclick = (e) => {
e.stopPropagation();
void authenticateMcpServer(deps, server);
};
}
card.onclick = () => openMcpDetailSlideover(deps, server);
}
async function authenticateMcpServer(deps: McpPageDeps, server: McpServer): Promise<void> {
if (!server.url) {
new Notice("No URL found for this server — can't authenticate.");
return;
}
deps.authenticatingServers.add(server.name);
void deps.rerender();
new Notice(`Authenticating ${server.name}… Complete authorization in your browser.`, 10000);
try {
const transport = server.type === "sse" ? "sse" : "http";
await deps.plugin.mcpManager.authenticateServer(server.name, server.url, transport);
new Notice(`${server.name} authenticated successfully!`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Authentication failed: ${msg}`, 8000);
} finally {
deps.authenticatingServers.delete(server.name);
void deps.rerender();
}
}
/** On-demand tool probe for the MCP detail view. Stores results in the
* transient cache and re-renders. */
async function probeMcpServer(deps: McpPageDeps, server: McpServer): Promise<void> {
try {
const tools = await deps.plugin.mcpManager.probeServer(server);
deps.mcpProbeCache.set(server.name, tools);
if (tools.length === 0) new Notice(`No tools discovered for ${server.name}.`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Probe failed: ${truncate(msg, 150)}`);
}
}
function truncateDescription(text: string, maxLen: number): string {
// Take first sentence or first line, whichever is shorter
const firstLine = splitLines(text)[0] ?? text;
const firstSentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine;
const candidate = firstSentence.length < firstLine.length ? firstSentence : firstLine;
if (candidate.length <= maxLen) return candidate;
return candidate.slice(0, maxLen - 1) + "…";
}
function openMcpDetailSlideover(deps: McpPageDeps, server: McpServer): void {
deps.contentEl.querySelector(".af-slideover-overlay")?.remove();
const overlay = deps.contentEl.createDiv({ cls: "af-slideover-overlay" });
const panel = overlay.createDiv({ cls: "af-slideover" });
const header = panel.createDiv({ cls: "af-slideover-header" });
header.createDiv({ cls: "af-slideover-title", text: server.name });
const closeBtn = header.createEl("button", { cls: "clickable-icon" });
setIcon(closeBtn, "cross");
closeBtn.onclick = () => overlay.remove();
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
const body = panel.createDiv({ cls: "af-slideover-body" });
// Description
if (server.description) {
const descSection = body.createDiv({ cls: "af-slideover-section" });
descSection.createDiv({ cls: "af-slideover-section-title", text: "DESCRIPTION" });
descSection.createDiv({ cls: "af-mcp-detail-description", text: server.description });
}
// Server info
const infoSection = body.createDiv({ cls: "af-slideover-section" });
infoSection.createDiv({ cls: "af-slideover-section-title", text: "SERVER INFO" });
deps.renderDetailRow(infoSection, "Name", server.name);
deps.renderDetailRow(infoSection, "Transport", server.type);
deps.renderDetailRow(infoSection, "Enabled", server.enabled ? "yes" : "no");
if (server.type !== "stdio") {
deps.renderDetailRow(infoSection, "Auth", server.auth ?? "none");
deps.renderDetailRow(infoSection, "Authenticated", mcpHasToken(deps, server) ? "yes" : "no");
}
if (server.source) deps.renderDetailRow(infoSection, "Source", server.source);
if (server.url) deps.renderDetailRow(infoSection, "URL", server.url);
if (server.command) deps.renderDetailRow(infoSection, "Command", server.command);
if (server.args && server.args.length > 0) deps.renderDetailRow(infoSection, "Args", server.args.join(" "));
// Tools — populated by the on-demand probe (registry definitions carry no
// probed tools until the user clicks "Probe tools").
const probedTools = deps.mcpProbeCache.get(server.name) ?? [];
const toolsSection = body.createDiv({ cls: "af-slideover-section" });
const toolsTitleRow = toolsSection.createDiv({ cls: "af-slideover-section-title" });
toolsTitleRow.setText(`TOOLS (${probedTools.length})`);
const probeBtn = toolsSection.createEl("button", { cls: "af-btn-sm" });
const probeIcon = probeBtn.createSpan();
setIcon(probeIcon, "wrench");
probeBtn.appendText(" Probe tools");
probeBtn.onclick = async () => {
probeBtn.disabled = true;
probeBtn.setText(" Probing…");
await probeMcpServer(deps, server);
overlay.remove();
openMcpDetailSlideover(deps, server);
};
if (probedTools.length > 0) {
for (const tool of probedTools) {
const toolItem = toolsSection.createDiv({ cls: "af-mcp-tool-detail" });
const toolHeader = toolItem.createDiv({ cls: "af-mcp-tool-detail-header" });
const toolNameEl = toolHeader.createSpan({ cls: "af-mcp-tool-detail-name" });
const toolNameIcon = toolNameEl.createSpan();
setIcon(toolNameIcon, "wrench");
toolNameEl.createSpan({ text: ` ${tool.name}` });
if (tool.inputSchema) {
const params = (tool.inputSchema as { required?: string[] }).required ?? [];
if (params.length > 0) {
toolHeader.createSpan({
cls: "af-mcp-tool-param-count",
text: `${params.length} param${params.length !== 1 ? "s" : ""}`,
});
}
}
if (tool.description) {
// Show first 2 lines of description, rest in collapsible
const descLines = splitLines(tool.description).filter((l) => l.trim());
const shortDesc = descLines.slice(0, 2).join(" ").trim();
const hasMore = descLines.length > 2;
if (hasMore) {
const details = toolItem.createEl("details", { cls: "af-mcp-tool-detail-desc" });
details.createEl("summary", { text: truncateDescription(shortDesc, 200) });
details.createDiv({ cls: "af-mcp-tool-detail-full", text: tool.description });
} else {
toolItem.createDiv({ cls: "af-mcp-tool-detail-desc", text: shortDesc });
}
}
// Input schema params
if (tool.inputSchema) {
const props = (tool.inputSchema as { properties?: Record<string, { type?: string; description?: string }> }).properties;
const required = new Set((tool.inputSchema as { required?: string[] }).required ?? []);
if (props && Object.keys(props).length > 0) {
const paramsEl = toolItem.createDiv({ cls: "af-mcp-tool-params" });
for (const [paramName, paramDef] of Object.entries(props)) {
const paramEl = paramsEl.createDiv({ cls: "af-mcp-tool-param" });
paramEl.createSpan({ cls: "af-mcp-tool-param-name", text: paramName });
if (paramDef.type) {
paramEl.createSpan({ cls: "af-mcp-tool-param-type", text: paramDef.type });
}
if (required.has(paramName)) {
paramEl.createSpan({ cls: "af-mcp-tool-param-required", text: "required" });
}
if (paramDef.description) {
paramEl.createSpan({
cls: "af-mcp-tool-param-desc",
text: truncate(paramDef.description, 80),
});
}
}
}
}
}
} else {
toolsSection.createDiv({
cls: "af-form-hint",
text: "Click \"Probe tools\" to discover the tools this server exposes.",
});
}
// Actions section
const actionsSection = body.createDiv({ cls: "af-slideover-section" });
actionsSection.createDiv({ cls: "af-slideover-section-title", text: "ACTIONS" });
if (server.enabled && server.url && server.auth === "oauth" && !mcpHasToken(deps, server)) {
const authBtn = actionsSection.createEl("button", { cls: "af-btn-sm primary" });
const authIcon = authBtn.createSpan();
setIcon(authIcon, "key");
authBtn.appendText(" Authenticate");
authBtn.onclick = () => {
overlay.remove();
void authenticateMcpServer(deps, server);
};
}
const removeBtn = actionsSection.createEl("button", { cls: "af-btn-sm danger" });
const removeIcon = removeBtn.createSpan();
setIcon(removeIcon, "trash-2");
removeBtn.appendText(" Remove Server");
removeBtn.onclick = async () => {
try {
await deps.plugin.repository.deleteMcpServer(server.name);
deps.plugin.mcpAuth.removeToken(server.name);
deps.mcpProbeCache.delete(server.name);
new Notice(`Server "${server.name}" removed.`);
overlay.remove();
await deps.plugin.refreshFromVault();
void deps.rerender();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Failed to remove server: ${msg}`);
}
};
}

View file

@ -0,0 +1,77 @@
import { setIcon } from "obsidian";
import type { RunLogData } from "../../types";
import type { DashboardPageDeps } from "./shared";
/** View helpers the run-history page borrows from the dashboard so the
* extracted markup stays byte-identical to what the view used to render
* inline. */
export interface RunsPageDeps extends DashboardPageDeps {
/** Navigate the dashboard to another page. */
navigate: (page: "agent-detail", context?: string) => void;
/** Open the run-details slideover (owned by the view). */
openSlideover: (run: RunLogData) => void;
formatStarted: (iso: string) => string;
formatDuration: (seconds: number) => string;
statusToBadgeClass: (status: string) => string;
statusToIconName: (status: string) => string;
statusToBadgeText: (status: string) => string;
}
export function renderRunsPage(container: HTMLElement, deps: RunsPageDeps): void {
const page = container.createDiv({ cls: "af-runs-page" });
const runs = deps.plugin.runtime.getRecentRuns();
const toolbar = page.createDiv({ cls: "af-runs-toolbar" });
toolbar.createDiv({ cls: "af-page-title", text: "Run History" });
toolbar.createDiv({ cls: "af-toolbar-spacer" });
const tableWrap = page.createDiv({ cls: "af-runs-table" });
if (runs.length === 0) {
deps.renderEmptyState(tableWrap, "scroll-text", "No runs yet", "Run an agent to see history here");
return;
}
const table = tableWrap.createEl("table");
const thead = table.createEl("thead");
const headerRow = thead.createEl("tr");
for (const col of ["Status", "Agent", "Task", "Started", "Duration", "Tokens", "Model"]) {
headerRow.createEl("th", { text: col });
}
const tbody = table.createEl("tbody");
for (const run of runs.slice(0, 50)) {
renderRunRow(tbody, deps, run);
}
}
function renderRunRow(tbody: HTMLElement, deps: RunsPageDeps, run: RunLogData): void {
const row = tbody.createEl("tr");
const statusTd = row.createEl("td");
const badge = statusTd.createSpan({
cls: `af-status-badge ${deps.statusToBadgeClass(run.status)}`,
});
const badgeIcon = badge.createSpan();
setIcon(badgeIcon, deps.statusToIconName(run.status));
badge.appendText(` ${deps.statusToBadgeText(run.status)}`);
const agentTd = row.createEl("td", { cls: "af-agent-link" });
agentTd.setText(run.agent);
agentTd.onclick = (e) => {
e.stopPropagation();
deps.navigate("agent-detail", run.agent);
};
row.createEl("td", { text: run.task });
row.createEl("td", { cls: "af-mono", text: deps.formatStarted(run.started) });
row.createEl("td", { cls: "af-mono", text: deps.formatDuration(run.durationSeconds) });
row.createEl("td", {
cls: "af-mono",
text: run.tokensUsed ? run.tokensUsed.toLocaleString() : "\u2014",
});
row.createEl("td", { cls: "af-mono", text: run.model });
row.setCssStyles({ cursor: "pointer" });
row.onclick = () => deps.openSlideover(run);
}

21
src/views/pages/shared.ts Normal file
View file

@ -0,0 +1,21 @@
import type AgentFleetPlugin from "../../main";
/**
* View helpers every extracted page borrows from the dashboard so the
* extracted markup stays byte-identical to what the view used to render
* inline. Each page module extends this with its own (narrowly typed)
* `navigate` delegate and any page-specific extras. Shared state (ticker
* registries, detail context, probe caches) stays on the view and is handed
* to pages through these deps or as parameters.
*/
export interface DashboardPageDeps {
plugin: AgentFleetPlugin;
/** Standard icon + label + optional CTA empty-state block (owned by the view). */
renderEmptyState: (
container: HTMLElement,
iconName: string,
label: string,
sublabel: string,
action?: { label: string; onClick: () => void },
) => void;
}

View file

@ -0,0 +1,76 @@
import { setIcon } from "obsidian";
import type { AgentConfig, SkillConfig } from "../../types";
import { createIcon } from "../../utils/icons";
import type { DashboardPageDeps } from "./shared";
/** View helpers the skills-library page borrows from the dashboard so the
* extracted markup stays byte-identical to what the view used to render
* inline. */
export interface SkillsPageDeps extends DashboardPageDeps {
/** Navigate the dashboard to another page. */
navigate: (page: "edit-skill", context?: string) => void;
}
export function renderSkillsPage(container: HTMLElement, deps: SkillsPageDeps): void {
const page = container.createDiv({ cls: "af-skills-page" });
const snapshot = deps.plugin.runtime.getSnapshot();
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
toolbar.createDiv({ cls: "af-page-title", text: "Skills Library" });
toolbar.createDiv({ cls: "af-toolbar-spacer" });
const newBtn = toolbar.createEl("button", { cls: "af-btn-sm primary" });
createIcon(newBtn, "plus", "af-btn-icon");
newBtn.appendText(" New Skill");
newBtn.onclick = () => void deps.plugin.createSkillTemplate();
const grid = page.createDiv({ cls: "af-skills-grid" });
if (snapshot.skills.length === 0) {
deps.renderEmptyState(grid, "puzzle", "No skills yet", "Create skills to give agents specialized abilities", {
label: "New Skill",
onClick: () => void deps.plugin.createSkillTemplate(),
});
return;
}
for (const skill of snapshot.skills) {
renderSkillCard(grid, deps, skill, snapshot.agents);
}
}
function renderSkillCard(container: HTMLElement, deps: SkillsPageDeps, skill: SkillConfig, agents: AgentConfig[]): void {
const card = container.createDiv({ cls: "af-skill-card" });
const cardHeader = card.createDiv({ cls: "af-skill-card-header" });
const iconEl = cardHeader.createDiv({ cls: "af-skill-card-icon" });
setIcon(iconEl, getSkillIcon(skill.name));
const skillEditBtn = cardHeader.createEl("button", { cls: "af-btn-sm af-btn-xs" });
createIcon(skillEditBtn, "edit", "af-btn-icon");
skillEditBtn.onclick = (e) => {
e.stopPropagation();
deps.navigate("edit-skill", skill.name);
};
card.createDiv({ cls: "af-skill-card-name", text: skill.name });
card.createDiv({ cls: "af-skill-card-desc", text: skill.description ?? "No description" });
const usedBy = agents.filter((a) => a.skills.includes(skill.name));
if (usedBy.length > 0) {
const agentsRow = card.createDiv({ cls: "af-skill-card-agents" });
for (const agent of usedBy) {
agentsRow.createSpan({ cls: "af-skill-card-agent-tag", text: agent.name });
}
}
// No card-level click — skills have no detail page. Editing is via the edit button.
}
function getSkillIcon(name: string): string {
if (name.includes("git")) return "settings";
if (name.includes("summarize") || name.includes("log")) return "activity";
if (name.includes("review") || name.includes("check")) return "check-circle-2";
if (name.includes("vault") || name.includes("note")) return "file-text";
return "puzzle";
}

View file

@ -0,0 +1,111 @@
import { setIcon } from "obsidian";
import type { RunLogData } from "../../types";
import { createIcon } from "../../utils/icons";
import type { DashboardPageDeps } from "./shared";
/** View helpers the task detail page borrows from the dashboard so the
* extracted markup stays byte-identical to what the view used to render
* inline. */
export interface TaskDetailPageDeps extends DashboardPageDeps {
/** Navigate the dashboard to another page. */
navigate: (page: "edit-task", context?: string) => void;
/** Label + monospace value config row (owned by the view). */
renderConfigRow: (container: HTMLElement, label: string, value: string) => void;
/** Run timeline entry shared with the overview page (owned by the view). */
renderTimelineItem: (container: HTMLElement, run: RunLogData) => void;
humanizeCron: (cron: string) => string;
formatStarted: (iso: string) => string;
}
export function renderTaskDetailPage(container: HTMLElement, deps: TaskDetailPageDeps, taskId: string | undefined): void {
const page = container.createDiv({ cls: "af-task-detail-page" });
if (!taskId) {
deps.renderEmptyState(page, "circle-dot", "No task selected", "");
return;
}
const task = deps.plugin.runtime.getSnapshot().tasks.find((t) => t.taskId === taskId);
if (!task) {
deps.renderEmptyState(page, "circle-dot", "Task not found", `Task "${taskId}" was not found`);
return;
}
const snapshot = deps.plugin.runtime.getSnapshot();
const runs = deps.plugin.runtime.getRecentRuns().filter((r) => r.task === taskId);
const agent = snapshot.agents.find((a) => a.name === task.agent);
// Header
const header = page.createDiv({ cls: "af-detail-header" });
const headerLeft = header.createDiv({ cls: "af-detail-header-left" });
const taskIcon = headerLeft.createDiv({ cls: "af-agent-card-avatar idle" });
setIcon(taskIcon, "circle-dot");
const headerInfo = headerLeft.createDiv();
headerInfo.createDiv({ cls: "af-detail-header-name", text: task.taskId });
headerInfo.createDiv({
cls: "af-detail-header-desc",
text: `Agent: ${task.agent}`,
});
const headerActions = header.createDiv({ cls: "af-detail-header-actions" });
const editBtn = headerActions.createEl("button", { cls: "af-btn-sm" });
createIcon(editBtn, "edit", "af-btn-icon");
editBtn.appendText(" Edit");
editBtn.onclick = () => deps.navigate("edit-task", task.taskId);
const runBtn = headerActions.createEl("button", { cls: "af-btn-sm primary" });
createIcon(runBtn, "play", "af-btn-icon");
runBtn.appendText(" Run Now");
runBtn.onclick = () => void deps.plugin.runtime.runTaskNow(task);
// Details section
const details = page.createDiv({ cls: "af-section-card" });
const detailsHeader = details.createDiv({ cls: "af-section-header" });
const detailsTitle = detailsHeader.createDiv({ cls: "af-section-title" });
createIcon(detailsTitle, "file-text");
detailsTitle.appendText(" Details");
const detailsBody = details.createDiv({ cls: "af-config-form" });
deps.renderConfigRow(detailsBody, "Agent", task.agent);
deps.renderConfigRow(detailsBody, "Priority", task.priority.charAt(0).toUpperCase() + task.priority.slice(1));
deps.renderConfigRow(detailsBody, "Status", task.enabled ? "Enabled" : "Disabled");
// Schedule — human-readable, no raw cron
const scheduleText = task.schedule
? deps.humanizeCron(task.schedule)
: task.runAt ?? "Manual (run on demand)";
deps.renderConfigRow(detailsBody, "Schedule", scheduleText);
if (task.schedule) {
deps.renderConfigRow(detailsBody, "Catch up if missed", task.catchUp ? "Yes" : "No");
}
deps.renderConfigRow(detailsBody, "Created", task.created);
deps.renderConfigRow(detailsBody, "Runs", String(task.runCount));
if (task.lastRun) {
deps.renderConfigRow(detailsBody, "Last Run", deps.formatStarted(task.lastRun));
}
// Instructions section
const promptSection = page.createDiv({ cls: "af-section-card" });
const promptHeader = promptSection.createDiv({ cls: "af-section-header" });
const promptTitle = promptHeader.createDiv({ cls: "af-section-title" });
createIcon(promptTitle, "message-square");
promptTitle.appendText(" Instructions");
promptSection.createDiv({ cls: "af-output-block", text: task.body || "(empty)" });
// Recent runs
const runsSection = page.createDiv({ cls: "af-section-card" });
const runsHeader = runsSection.createDiv({ cls: "af-section-header" });
const runsTitle = runsHeader.createDiv({ cls: "af-section-title" });
createIcon(runsTitle, "scroll-text");
runsTitle.appendText(" Recent Runs");
const runsBody = runsSection.createDiv({ cls: "af-timeline" });
if (runs.length === 0) {
deps.renderEmptyState(runsBody, "scroll-text", "No runs yet", "");
} else {
for (const run of runs.slice(0, 10)) {
deps.renderTimelineItem(runsBody, run);
}
}
}

View file

@ -0,0 +1,193 @@
import { Notice, TFile } from "obsidian";
import type { AgentConfig } from "../../types";
import { parseLatestLintReport } from "../../utils/wikiLintReport";
import type { DashboardPageDeps } from "./shared";
/** View helpers the Wiki Keepers page borrows from the dashboard so the
* extracted markup stays byte-identical to what the view used to render
* inline. */
export type WikiKeepersPageDeps = DashboardPageDeps;
/**
* Wiki Keepers page: lists every Wiki Keeper instance with the latest
* lint report parsed from its scope's `log.md`. "Needs review" items
* render as cards with a Dismiss button (in-memory only re-parse on
* navigation gives the user a fresh view of the most recent lint pass).
*/
export async function renderWikiKeepersPage(container: HTMLElement, deps: WikiKeepersPageDeps): Promise<void> {
const page = container.createDiv({ cls: "af-agents-page" });
const snapshot = deps.plugin.runtime.getSnapshot();
const toolbar = page.createDiv({ cls: "af-agents-toolbar" });
toolbar.createDiv({ cls: "af-page-title", text: "Wiki Keepers" });
toolbar.createDiv({ cls: "af-toolbar-spacer" });
const keepers = snapshot.agents.filter(
(a): a is AgentConfig & { wikiKeeper: NonNullable<AgentConfig["wikiKeeper"]> } =>
a.wikiKeeper !== undefined,
);
if (keepers.length === 0) {
deps.renderEmptyState(
page,
"library",
"No Wiki Keepers yet",
"Open Settings → Agent Fleet → Wiki Keepers → + Add to create one.",
);
return;
}
const list = page.createDiv({ cls: "af-wk-list" });
list.setCssStyles({ display: "flex" });
list.setCssStyles({ flexDirection: "column" });
list.setCssStyles({ gap: "16px" });
for (const keeper of keepers) {
await renderWikiKeeperCard(list, deps, keeper);
}
}
async function renderWikiKeeperCard(
container: HTMLElement,
deps: WikiKeepersPageDeps,
agent: AgentConfig & { wikiKeeper: NonNullable<AgentConfig["wikiKeeper"]> },
): Promise<void> {
const wk = agent.wikiKeeper;
const card = container.createDiv({ cls: "af-card" });
card.setCssStyles({ padding: "16px" });
card.setCssStyles({ border: "1px solid var(--background-modifier-border)" });
card.setCssStyles({ borderRadius: "8px" });
const header = card.createDiv();
header.setCssStyles({ display: "flex" });
header.setCssStyles({ alignItems: "center" });
header.setCssStyles({ gap: "12px" });
header.setCssStyles({ marginBottom: "12px" });
const titleWrap = header.createDiv();
titleWrap.setCssStyles({ flex: "1" });
titleWrap.createEl("strong", { text: agent.name });
const scopeLabel = wk.scopeRoot || "(whole vault)";
titleWrap.createEl("div", {
text: `Scope: ${scopeLabel} · topics: ${wk.topicsRoot}/ · log: ${wk.logPath}`,
cls: "af-form-hint",
});
const openBtn = header.createEl("button", { cls: "af-btn-sm" });
openBtn.appendText("Open log");
openBtn.onclick = () => {
const logFullPath = wk.scopeRoot ? `${wk.scopeRoot}/${wk.logPath}` : wk.logPath;
const file = deps.plugin.app.vault.getAbstractFileByPath(logFullPath);
if (file instanceof TFile) {
void deps.plugin.app.workspace.getLeaf().openFile(file);
} else {
new Notice(`Log file not found: ${logFullPath}`);
}
};
// Read and parse log.md
const logFullPath = wk.scopeRoot ? `${wk.scopeRoot}/${wk.logPath}` : wk.logPath;
const logFile = deps.plugin.app.vault.getAbstractFileByPath(logFullPath);
let report: ReturnType<typeof parseLatestLintReport> = null;
if (logFile instanceof TFile) {
try {
const content = await deps.plugin.app.vault.cachedRead(logFile);
report = parseLatestLintReport(content);
} catch {
report = null;
}
}
if (!report) {
const empty = card.createDiv({ cls: "af-form-hint" });
empty.setText(
"No lint report yet. Run wiki-lint manually or wait for the weekly task to fire.",
);
return;
}
// Latest lint header
const reportHeader = card.createDiv();
reportHeader.setCssStyles({ display: "flex" });
reportHeader.setCssStyles({ alignItems: "baseline" });
reportHeader.setCssStyles({ gap: "12px" });
reportHeader.setCssStyles({ marginBottom: "8px" });
reportHeader.createEl("strong", { text: `Lint ${report.date}` });
reportHeader.createSpan({
cls: "af-form-hint",
text: `${report.summary.length} summary lines · ${report.autoApplied.length} auto-applied · ${report.needsReview.length} needs review`,
});
// Summary bullets (read-only)
if (report.summary.length > 0) {
const sumDetails = card.createEl("details");
sumDetails.createEl("summary", { text: "Summary" });
const sumList = sumDetails.createEl("ul");
sumList.setCssStyles({ marginTop: "4px" });
for (const item of report.summary) {
sumList.createEl("li", { text: item });
}
}
if (report.autoApplied.length > 0) {
const autoDetails = card.createEl("details");
autoDetails.createEl("summary", { text: `Auto-applied (${report.autoApplied.length})` });
const autoList = autoDetails.createEl("ul");
autoList.setCssStyles({ marginTop: "4px" });
for (const item of report.autoApplied) {
autoList.createEl("li", { text: item });
}
}
if (report.refreshChained.length > 0) {
const refDetails = card.createEl("details");
refDetails.createEl("summary", {
text: `Refresh chained (${report.refreshChained.length})`,
});
const refList = refDetails.createEl("ul");
refList.setCssStyles({ marginTop: "4px" });
for (const item of report.refreshChained) {
refList.createEl("li", { text: item });
}
}
// Needs review queue — the actionable bit
const reviewWrap = card.createDiv();
reviewWrap.setCssStyles({ marginTop: "12px" });
reviewWrap.createEl("strong", { text: `Needs review (${report.needsReview.length})` });
if (report.needsReview.length === 0) {
reviewWrap.createDiv({
cls: "af-form-hint",
text: "All clear. Nothing requires manual review from this lint pass.",
});
return;
}
const reviewList = reviewWrap.createDiv();
reviewList.setCssStyles({ display: "flex" });
reviewList.setCssStyles({ flexDirection: "column" });
reviewList.setCssStyles({ gap: "6px" });
reviewList.setCssStyles({ marginTop: "8px" });
for (const item of report.needsReview) {
const row = reviewList.createDiv();
row.setCssStyles({ display: "flex" });
row.setCssStyles({ alignItems: "flex-start" });
row.setCssStyles({ gap: "8px" });
row.setCssStyles({ padding: "8px 10px" });
row.setCssStyles({ background: "var(--background-secondary)" });
row.setCssStyles({ borderRadius: "4px" });
row.setCssStyles({ fontSize: "13px" });
const text = row.createDiv();
text.setCssStyles({ flex: "1" });
text.setText(item);
const dismissBtn = row.createEl("button", { cls: "af-btn-sm", text: "Dismiss" });
dismissBtn.title =
"Hide this item from the dashboard until the next lint pass (does not modify log.md).";
dismissBtn.onclick = () => {
row.remove();
};
}
}

View file

@ -334,6 +334,19 @@
height: 14px;
}
.af-search-result-empty {
padding: 8px 12px;
font-size: 12px;
color: var(--af-text-muted);
}
.af-search-result-footer {
padding: 6px 12px;
font-size: 11px;
color: var(--af-text-muted);
border-top: 1px solid var(--af-border);
}
/* ─── Status Pills ─── */
.af-status-pills {
display: flex;
@ -649,10 +662,6 @@
height: 12px;
}
.af-icon-back {
transform: rotate(180deg);
}
/* ─── Buttons ─── */
/* ─── Outlined Buttons ─── */
.af-btn-sm {
@ -1446,12 +1455,6 @@
margin-top: 16px;
}
.af-detail-value-row {
display: flex;
align-items: center;
gap: 8px;
}
.af-run-list-item {
display: flex;
gap: 12px;
@ -1573,7 +1576,6 @@
}
.af-kanban-running { border-color: rgba(249, 226, 175, 0.3); }
.af-kanban-review { border-color: rgba(137, 180, 250, 0.3); }
.af-kanban-failed { border-color: rgba(243, 139, 168, 0.15); }
/* Drag and drop states */
@ -1801,23 +1803,6 @@
gap: 4px;
}
.af-kanban-card-tags {
display: flex;
gap: 3px;
}
.af-kanban-card-tag {
padding: 1px 6px;
border-radius: 3px;
font-size: 9px;
font-weight: 500;
}
.af-kanban-card-tag.monitoring { background: var(--af-blue-bg); color: var(--af-blue); }
.af-kanban-card-tag.devops { background: var(--af-orange-bg); color: var(--af-orange); }
.af-kanban-card-tag.sample { background: var(--af-purple-bg); color: var(--af-purple); }
.af-kanban-card-tag.default { background: var(--af-bg-surface); color: var(--af-text-muted); }
.af-kanban-card-error {
padding: 6px 0;
font-size: 11px;
@ -1907,21 +1892,6 @@
background: var(--af-accent-bg);
}
/* ─── Priority Badge (detail page) ─── */
.af-priority-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--af-radius-sm);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.af-priority-badge.critical { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
.af-priority-badge.high { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
.af-priority-badge.medium { background: rgba(59, 130, 246, 0.15); color: #3b82f6; }
.af-priority-badge.low { background: var(--af-bg-surface); color: var(--af-text-secondary); }
/* ─── Schedule body toggle ─── */
.af-schedule-body {
padding-left: 4px;
@ -2166,10 +2136,6 @@
word-break: break-word;
}
.af-detail-value.af-accent {
color: var(--af-accent);
}
.af-output-block {
background: var(--af-bg-primary);
border: 1px solid var(--af-border);
@ -2281,6 +2247,10 @@
font-size: 12px;
}
.af-empty-action {
margin-top: 12px;
}
/* ─── Responsive ─── */
@media (max-width: 1000px) {
.af-dash-grid {
@ -2333,11 +2303,6 @@
margin: 8px 0 0 16px;
}
.agent-fleet-textarea {
width: 100%;
margin: 8px 0 16px;
}
/* ─── Create Agent Modal ─── */
.af-create-agent-modal {
max-width: 600px;
@ -2584,13 +2549,6 @@
line-height: 1;
}
.af-form-desc {
font-size: 10px;
color: var(--af-text-muted);
font-weight: 400;
margin-top: 2px;
}
/* Info-icon tooltip — replaces verbose subtitles on form labels */
.af-form-tooltip {
display: inline-flex;
@ -2970,34 +2928,6 @@
border-radius: 8px;
}
/* ─── Chat Slideover ─── */
.af-slideover.af-chat-slideover {
width: 540px;
}
.af-chat-header-title {
display: flex;
align-items: center;
gap: 8px;
}
.af-chat-header-actions {
display: flex;
align-items: center;
gap: 6px;
}
.af-chat-header-icon {
display: flex;
align-items: center;
color: var(--af-accent);
}
.af-chat-header-icon svg {
width: 16px;
height: 16px;
}
.af-chat-messages {
flex: 1;
overflow-y: auto;
@ -3298,13 +3228,6 @@
}
}
.af-chat-working-indicator {
font-size: 11px;
color: var(--af-text-secondary);
padding: 0 0 6px 0;
opacity: 0.8;
}
.af-chat-input-area {
border-top: 1px solid var(--af-border);
padding: 12px 16px 16px 16px;
@ -3802,105 +3725,6 @@
font-size: 10px;
}
/* ─── MCP Tool Item (simple list fallback) ─── */
.af-mcp-tool-item {
font-family: var(--font-monospace);
font-size: 12px;
padding: 4px 8px;
background: var(--af-bg-surface);
border-radius: 4px;
margin-bottom: 4px;
color: var(--af-text-secondary);
}
/* ── MCP Discovery Progress ── */
.af-mcp-progress {
margin: 0 0 20px 0;
padding: 16px 20px;
background: var(--af-card-bg);
border: 1px solid var(--af-border);
border-radius: 10px;
}
.af-mcp-progress-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.af-mcp-spinner {
display: flex;
gap: 4px;
align-items: center;
}
.af-mcp-spinner span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--af-blue);
animation: af-mcp-bounce 1.2s infinite ease-in-out;
}
.af-mcp-spinner span:nth-child(2) {
animation-delay: 0.15s;
}
.af-mcp-spinner span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes af-mcp-bounce {
0%, 60%, 100% { opacity: 0.3; transform: scale(0.8); }
30% { opacity: 1; transform: scale(1.1); }
}
.af-mcp-progress-label {
font-size: 13px;
font-weight: 500;
color: var(--af-text-primary);
}
.af-mcp-progress-bar {
height: 4px;
border-radius: 2px;
background: var(--af-border);
overflow: hidden;
margin-bottom: 8px;
}
.af-mcp-progress-fill {
height: 100%;
border-radius: 2px;
background: var(--af-blue);
transition: width 0.4s ease;
}
.af-mcp-progress-fill-slow {
/* When tool discovery is running (the slow phase), add a shimmer */
background: linear-gradient(
90deg,
var(--af-blue) 0%,
color-mix(in srgb, var(--af-blue) 60%, white) 50%,
var(--af-blue) 100%
);
background-size: 200% 100%;
animation: af-mcp-shimmer 1.5s infinite linear;
}
@keyframes af-mcp-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.af-mcp-progress-detail {
font-size: 11px;
color: var(--af-text-secondary);
letter-spacing: 0.01em;
}
/* ── MCP Auth Button ── */
.af-mcp-auth-row {
@ -3932,24 +3756,6 @@
to { transform: rotate(360deg); }
}
.af-mcp-hint-row {
font-size: 11px;
color: var(--af-text-secondary);
padding: 8px 0 0;
border-top: 1px solid var(--af-border);
margin-top: 10px;
}
.af-mcp-hint-row .af-link {
cursor: pointer;
text-decoration: underline;
opacity: 0.8;
}
.af-mcp-hint-row .af-link:hover {
opacity: 1;
}
/* ── MCP Slideover Actions ── */
.af-slideover-section .af-btn-sm {
@ -4467,10 +4273,6 @@
border-top: 1px solid var(--af-border);
}
.af-wk-schedule-block {
margin-bottom: 12px;
}
/* ── Run-detail transcript disclosure ── */
.af-run-transcript {
@ -4520,12 +4322,6 @@
opacity: 0.92;
}
.af-wk-schedule-block .af-form-hint {
font-size: 12px;
color: var(--af-text-secondary);
margin-bottom: 6px;
}
/* ─── Chat: conversations side rail ───────────────────────────────────── */
/* Adjacent to the messages column inside the chat view, this rail lists all
* parallel conversations for the currently-selected agent. Styled to match
@ -4732,3 +4528,176 @@
.af-chat-convo-collapse-btn.is-collapsed .af-convo-toggle-bar {
width: 8.33%;
}
/* Muted recovery hint below the message inside an error bubble */
.af-chat-error-hint {
margin-top: 4px;
font-size: 11px;
color: var(--af-text-secondary);
}
/* ─── Agent card: heartbeat status line ─────────────────────────────────── */
/* Compact line above the card footer: heart-pulse icon + schedule/next-run
* text + a small quick-toggle (same update path as the Overview tab). */
.af-agent-card-heartbeat {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
font-size: 11px;
color: var(--af-text-muted);
}
.af-agent-card-hb-icon {
display: inline-flex;
align-items: center;
color: var(--af-green);
opacity: 0.7;
}
.af-agent-card-hb-icon svg {
width: 13px;
height: 13px;
}
.af-agent-card-hb-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Smaller variant of the standard pill toggle for inline card rows */
.af-agent-card-toggle-sm {
width: 28px;
height: 16px;
border-radius: 8px;
}
.af-agent-card-toggle-sm::after {
width: 12px;
height: 12px;
}
.af-agent-card-toggle-sm.on::after {
transform: translateX(12px);
}
/* ─── Agent form: adapter-switch permission mapping hint ────────────────── */
/* Muted one-liner under the permission-mode field, shown only after the user
* switches the adapter, explaining how the mode vocabulary was translated. */
.af-adapter-map-hint {
font-size: 11px;
color: var(--af-text-muted);
line-height: 1.5;
}
/* ─── Task form: schedule type segmented control ────────────────────────── */
.af-segmented {
display: inline-flex;
border: 1px solid var(--af-border);
border-radius: var(--af-radius-md);
overflow: hidden;
}
.af-segmented-btn {
padding: 5px 14px;
font-size: 12px;
border: none;
border-radius: 0;
background: transparent;
color: var(--af-text-secondary);
cursor: pointer;
box-shadow: none;
}
.af-segmented-btn + .af-segmented-btn {
border-left: 1px solid var(--af-border);
}
.af-segmented-btn:hover {
color: var(--af-text-normal, var(--text-normal));
}
.af-segmented-btn.active {
background: var(--af-accent);
color: white;
}
/* ─── Dashboard: first-run welcome card ─────────────────────────────────── */
.af-welcome-card {
padding: 16px;
margin-bottom: 16px;
border: 1px solid var(--af-border);
border-radius: var(--af-radius-md);
background: var(--af-bg-card, var(--background-secondary));
}
.af-welcome-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.af-welcome-dismiss {
flex-shrink: 0;
}
.af-welcome-sub {
margin-top: 4px;
font-size: 12px;
color: var(--af-text-secondary);
}
.af-welcome-steps {
display: flex;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
.af-welcome-step {
flex: 1 1 180px;
display: flex;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--af-border);
border-radius: var(--af-radius-md);
cursor: pointer;
transition: border-color var(--af-transition), background var(--af-transition);
}
.af-welcome-step:hover {
border-color: var(--af-accent);
background: var(--background-modifier-hover);
}
.af-welcome-step-num {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--af-accent);
color: white;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.af-welcome-step-title {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
}
.af-welcome-step-desc {
margin-top: 2px;
font-size: 11px;
color: var(--af-text-muted);
}

View file

@ -30,5 +30,6 @@
"0.13.5": "1.11.4",
"0.13.6": "1.11.4",
"0.14.0": "1.11.4",
"0.15.0": "1.11.4"
}
"0.15.0": "1.11.4",
"0.16.0": "1.11.4"
}