* feat: add before_compaction and before_reset plugin hooks with session context - Pass session messages to before_compaction hook - Add before_reset plugin hook for /new and /reset commands - Add sessionId to plugin hook agent context * feat: extraBootstrapFiles config with glob pattern support Add extraBootstrapFiles to agent defaults config, allowing glob patterns (e.g. "projects/*/TOOLS.md") to auto-load project-level bootstrap files into agent context every turn. Missing files silently skipped. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(status): show custom memory plugins as enabled, not unavailable The status command probes memory availability using the built-in memory-core manager. Custom memory plugins (e.g. via plugin slot) can't be probed this way, so they incorrectly showed "unavailable". Now they show "enabled (plugin X)" without the misleading label. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use async fs.glob and capture pre-compaction messages - Replace globSync (node:fs) with fs.glob (node:fs/promises) to match codebase conventions for async file operations - Capture session.messages BEFORE replaceMessages(limited) so before_compaction hook receives the full conversation history, not the already-truncated list * fix: resolve lint errors from CI (oxlint strict mode) - Add void to fire-and-forget IIFE (no-floating-promises) - Use String() for unknown catch params in template literals - Add curly braces to single-statement if (curly rule) * fix: resolve remaining CI lint errors in workspace.ts - Remove `| string` from WorkspaceBootstrapFileName union (made all typeof members redundant per no-redundant-type-constituents) - Use type assertion for extra bootstrap file names - Drop redundant await on fs.glob() AsyncIterable (await-thenable) * fix: address Greptile review — path traversal guard + fs/promises import - workspace.ts: use path.resolve() + traversal check in loadExtraBootstrapFiles() - commands-core.ts: import fs from node:fs/promises, drop fs.promises prefix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve symlinks before workspace boundary check Greptile correctly identified that symlinks inside the workspace could point to files outside it, bypassing the path prefix check. Now uses fs.realpath() to resolve symlinks before verifying the real path stays within the workspace boundary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address Greptile review — hook reliability and type safety 1. before_compaction: add compactingCount field so plugins know both the full pre-compaction message count and the truncated count being fed to the compaction LLM. Clarify semantics in comment. 2. loadExtraBootstrapFiles: use path.basename() for the name field so "projects/quaid/TOOLS.md" maps to the known "TOOLS.md" type instead of an invalid WorkspaceBootstrapFileName cast. 3. before_reset: fire the hook even when no session file exists. Previously, short sessions without a persisted file would silently skip the hook. Now fires with empty messages array so plugins always know a reset occurred. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: validate bootstrap filenames and add compaction hook timeout - Only load extra bootstrap files whose basename matches a recognized workspace filename (AGENTS.md, TOOLS.md, etc.), preventing arbitrary files from being injected into agent context. - Wrap before_compaction hook in a 30-second Promise.race timeout so misbehaving plugins cannot stall the compaction pipeline. - Clarify hook comments: before_compaction is intentionally awaited (plugins need messages before they're discarded) but bounded. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: make before_compaction non-blocking, add sessionFile to after_compaction - before_compaction is now true fire-and-forget — no await, no timeout. Plugins that need full conversation data should persist it themselves and return quickly, or use after_compaction for async processing. - after_compaction now includes sessionFile path so plugins can read the full JSONL transcript asynchronously. All pre-compaction messages are preserved on disk, eliminating the need to block compaction. - Removes Promise.race timeout pattern that didn't actually cancel slow hooks (just raced past them while they continued running). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add sessionFile to before_compaction for parallel processing The session JSONL already has all messages on disk before compaction starts. By providing sessionFile in before_compaction, plugins can read and extract data in parallel with the compaction LLM call rather than waiting for after_compaction. This is the optimal path for memory plugins that need the full conversation history. sessionFile is also kept on after_compaction for plugins that only need to act after compaction completes (analytics, cleanup, etc.). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: move bootstrap extras into bundled hook --------- Co-authored-by: Solomon Steadman <solstead@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Clawdbot <clawdbot@alfie.local> Co-authored-by: Peter Steinberger <steipete@gmail.com>
180 lines
6.3 KiB
TypeScript
180 lines
6.3 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import type {
|
|
CommandHandler,
|
|
CommandHandlerResult,
|
|
HandleCommandsParams,
|
|
} from "./commands-types.js";
|
|
import { logVerbose } from "../../globals.js";
|
|
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
|
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
|
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
|
import { shouldHandleTextCommands } from "../commands-registry.js";
|
|
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
|
import { handleApproveCommand } from "./commands-approve.js";
|
|
import { handleBashCommand } from "./commands-bash.js";
|
|
import { handleCompactCommand } from "./commands-compact.js";
|
|
import { handleConfigCommand, handleDebugCommand } from "./commands-config.js";
|
|
import {
|
|
handleCommandsListCommand,
|
|
handleContextCommand,
|
|
handleHelpCommand,
|
|
handleStatusCommand,
|
|
handleWhoamiCommand,
|
|
} from "./commands-info.js";
|
|
import { handleModelsCommand } from "./commands-models.js";
|
|
import { handlePluginCommand } from "./commands-plugin.js";
|
|
import {
|
|
handleAbortTrigger,
|
|
handleActivationCommand,
|
|
handleRestartCommand,
|
|
handleSendPolicyCommand,
|
|
handleStopCommand,
|
|
handleUsageCommand,
|
|
} from "./commands-session.js";
|
|
import { handleSubagentsCommand } from "./commands-subagents.js";
|
|
import { handleTtsCommands } from "./commands-tts.js";
|
|
import { routeReply } from "./route-reply.js";
|
|
|
|
let HANDLERS: CommandHandler[] | null = null;
|
|
|
|
export async function handleCommands(params: HandleCommandsParams): Promise<CommandHandlerResult> {
|
|
if (HANDLERS === null) {
|
|
HANDLERS = [
|
|
// Plugin commands are processed first, before built-in commands
|
|
handlePluginCommand,
|
|
handleBashCommand,
|
|
handleActivationCommand,
|
|
handleSendPolicyCommand,
|
|
handleUsageCommand,
|
|
handleRestartCommand,
|
|
handleTtsCommands,
|
|
handleHelpCommand,
|
|
handleCommandsListCommand,
|
|
handleStatusCommand,
|
|
handleAllowlistCommand,
|
|
handleApproveCommand,
|
|
handleContextCommand,
|
|
handleWhoamiCommand,
|
|
handleSubagentsCommand,
|
|
handleConfigCommand,
|
|
handleDebugCommand,
|
|
handleModelsCommand,
|
|
handleStopCommand,
|
|
handleCompactCommand,
|
|
handleAbortTrigger,
|
|
];
|
|
}
|
|
const resetMatch = params.command.commandBodyNormalized.match(/^\/(new|reset)(?:\s|$)/);
|
|
const resetRequested = Boolean(resetMatch);
|
|
if (resetRequested && !params.command.isAuthorizedSender) {
|
|
logVerbose(
|
|
`Ignoring /reset from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
|
);
|
|
return { shouldContinue: false };
|
|
}
|
|
|
|
// Trigger internal hook for reset/new commands
|
|
if (resetRequested && params.command.isAuthorizedSender) {
|
|
const commandAction = resetMatch?.[1] ?? "new";
|
|
const hookEvent = createInternalHookEvent("command", commandAction, params.sessionKey ?? "", {
|
|
sessionEntry: params.sessionEntry,
|
|
previousSessionEntry: params.previousSessionEntry,
|
|
commandSource: params.command.surface,
|
|
senderId: params.command.senderId,
|
|
cfg: params.cfg, // Pass config for LLM slug generation
|
|
});
|
|
await triggerInternalHook(hookEvent);
|
|
|
|
// Send hook messages immediately if present
|
|
if (hookEvent.messages.length > 0) {
|
|
// Use OriginatingChannel/To if available, otherwise fall back to command channel/from
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
const channel = params.ctx.OriginatingChannel || (params.command.channel as any);
|
|
// For replies, use 'from' (the sender) not 'to' (which might be the bot itself)
|
|
const to = params.ctx.OriginatingTo || params.command.from || params.command.to;
|
|
|
|
if (channel && to) {
|
|
const hookReply = { text: hookEvent.messages.join("\n\n") };
|
|
await routeReply({
|
|
payload: hookReply,
|
|
channel: channel,
|
|
to: to,
|
|
sessionKey: params.sessionKey,
|
|
accountId: params.ctx.AccountId,
|
|
threadId: params.ctx.MessageThreadId,
|
|
cfg: params.cfg,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Fire before_reset plugin hook — extract memories before session history is lost
|
|
const hookRunner = getGlobalHookRunner();
|
|
if (hookRunner?.hasHooks("before_reset")) {
|
|
const prevEntry = params.previousSessionEntry;
|
|
const sessionFile = prevEntry?.sessionFile;
|
|
// Fire-and-forget: read old session messages and run hook
|
|
void (async () => {
|
|
try {
|
|
const messages: unknown[] = [];
|
|
if (sessionFile) {
|
|
const content = await fs.readFile(sessionFile, "utf-8");
|
|
for (const line of content.split("\n")) {
|
|
if (!line.trim()) {
|
|
continue;
|
|
}
|
|
try {
|
|
const entry = JSON.parse(line);
|
|
if (entry.type === "message" && entry.message) {
|
|
messages.push(entry.message);
|
|
}
|
|
} catch {
|
|
// skip malformed lines
|
|
}
|
|
}
|
|
} else {
|
|
logVerbose("before_reset: no session file available, firing hook with empty messages");
|
|
}
|
|
await hookRunner.runBeforeReset(
|
|
{ sessionFile, messages, reason: commandAction },
|
|
{
|
|
agentId: params.sessionKey?.split(":")[0] ?? "main",
|
|
sessionKey: params.sessionKey,
|
|
sessionId: prevEntry?.sessionId,
|
|
workspaceDir: params.workspaceDir,
|
|
},
|
|
);
|
|
} catch (err: unknown) {
|
|
logVerbose(`before_reset hook failed: ${String(err)}`);
|
|
}
|
|
})();
|
|
}
|
|
}
|
|
|
|
const allowTextCommands = shouldHandleTextCommands({
|
|
cfg: params.cfg,
|
|
surface: params.command.surface,
|
|
commandSource: params.ctx.CommandSource,
|
|
});
|
|
|
|
for (const handler of HANDLERS) {
|
|
const result = await handler(params, allowTextCommands);
|
|
if (result) {
|
|
return result;
|
|
}
|
|
}
|
|
|
|
const sendPolicy = resolveSendPolicy({
|
|
cfg: params.cfg,
|
|
entry: params.sessionEntry,
|
|
sessionKey: params.sessionKey,
|
|
channel: params.sessionEntry?.channel ?? params.command.channel,
|
|
chatType: params.sessionEntry?.chatType,
|
|
});
|
|
if (sendPolicy === "deny") {
|
|
logVerbose(`Send blocked by policy for session ${params.sessionKey ?? "unknown"}`);
|
|
return { shouldContinue: false };
|
|
}
|
|
|
|
return { shouldContinue: true };
|
|
}
|