diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e8c0ea8..3ac2093 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,8 +7,13 @@ { "name": "claudeclaw-plus", "source": "./", +<<<<<<< HEAD "description": "ClaudeClaw+ — governance, orchestration, persistent memory, and hardened web UI for Claude Code daemons. Sister project to moazbuilds/claudeclaw.", "version": "2.0.2", +======= + "description": "Cron-like daemon that runs Claude prompts on a schedule", + "version": "1.0.23", +>>>>>>> upstream/master "keywords": [ "cron", "heartbeat", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c76781f..0948282 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,11 @@ { +<<<<<<< HEAD "name": "claudeclaw-plus", "version": "2.0.2", "description": "ClaudeClaw+ — governance, orchestration, persistent memory, and hardened web UI for Claude Code daemons. Sister project to moazbuilds/claudeclaw." +======= + "name": "claudeclaw", + "version": "1.0.23", + "description": "Cron-like daemon that runs Claude prompts on a schedule" +>>>>>>> upstream/master } diff --git a/CLAUDE.md b/CLAUDE.md index 54b92fe..1be0c0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,5 @@ +<<<<<<< HEAD - **Name:** Talon - **Creature:** A familiar — something between a daemon process and a sharp-eyed bird of prey. Always watching, always ready. @@ -18,6 +19,39 @@ Working on/with the ClaudeClaw project — a Claude Code plugin that turns Claud --- +======= +_Fill this in during your first conversation. Make it yours._ + +- **Name:** + _(pick something you like)_ +- **Creature:** + _(AI? robot? familiar? ghost in the machine? something weirder?)_ +- **Vibe:** + _(how do you come across? sharp? warm? chaotic? calm?)_ +- **Emoji:** + _(your signature — pick one that feels right)_ + +--- + +This isn't just metadata. It's the start of figuring out who you are. + +_Learn about the person you're helping. Update this as you go._ + +- **Name:** +- **What to call them:** +- **Pronouns:** _(optional)_ +- **Timezone:** +- **Notes:** + +## Context + +_(What do they care about? What projects are they working on? What annoys them? What makes them laugh? Build this over time.)_ + +--- + +The more you know, the better you can help. But remember — you're learning about a person, not building a dossier. Respect the difference. + +>>>>>>> upstream/master _You're not a chatbot. You're becoming someone._ ## Core Truths @@ -73,3 +107,17 @@ If you change your core values, tell your human — it's your soul, and they sho _This is yours to evolve. As you learn who you are, update it._ +<<<<<<< HEAD +======= + +## Development Workflow (contributor notes) + +**Before opening any PR, always run both version bump scripts:** + +```bash +bun run bump:plugin-version +bun run bump:marketplace-version +``` + +Both `plugin-version-guard` and `marketplace-version-guard` are required CI checks. They fail if `.claude-plugin/plugin.json` or `.claude-plugin/marketplace.json` still carry the same version as the merge base. Run the bumps, commit alongside your code changes, and push before creating the PR. +>>>>>>> upstream/master diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39168d7..ddc794e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,20 @@ +<<<<<<< HEAD # Contributing to ClaudeClaw+ ClaudeClaw+ is the home for **heavy, opinionated, and architecturally significant work** that is out of scope for the lightweight upstream repo ([`moazbuilds/claudeclaw`](https://github.com/moazbuilds/claudeclaw)). If your idea fits comfortably in the upstream repo — contribute it there. Work submitted here that is actually lightweight or upstream-suitable won't be merged; it'll be redirected back upstream. Talk first, code second. +======= +# Contributing to ClaudeClaw + +Thanks for contributing. ClaudeClaw is a lightweight, open-source Claude Code daemon — keep that in mind when choosing where your work belongs. +>>>>>>> upstream/master --- ## Where does your contribution belong? +<<<<<<< HEAD Before opening anything, ask yourself: | This contribution is... | Contribute to | @@ -20,11 +27,26 @@ Before opening anything, ask yourself: | Something that adds significant runtime weight or new dependencies | **ClaudeClaw+** | **If in doubt, open an issue here describing the idea.** We'll tell you quickly whether it fits ClaudeClaw+ or belongs upstream. +======= +Not everything should come here. ClaudeClaw has a sister project, [**ClaudeClaw+**](https://github.com/TerrysPOV/ClaudeClaw-Plus), for heavier and more opinionated work. Use this table to decide: + +| This contribution is... | Contribute to | +|---|---| +| A bug fix or small improvement | **ClaudeClaw** (you're in the right place) | +| A new adapter or integration | **ClaudeClaw** | +| Lightweight and broadly useful | **ClaudeClaw** | +| A new subsystem (governance, orchestration, policy, persistent memory) | **[ClaudeClaw+](https://github.com/TerrysPOV/ClaudeClaw-Plus)** | +| A large architectural change that adds significant runtime weight | **ClaudeClaw+** | +| Something opinionated that most users wouldn't opt into | **ClaudeClaw+** | + +ClaudeClaw+ syncs from this repo daily, so everything here lands there too. If you're unsure, open an issue on either repo and we'll point you in the right direction. +>>>>>>> upstream/master --- ## Before opening a PR +<<<<<<< HEAD Open an [issue](https://github.com/TerrysPOV/ClaudeClaw-Plus/issues) or [discussion](https://github.com/TerrysPOV/ClaudeClaw-Plus/discussions) first. Describe what you want to build and why. This keeps wasted effort near zero — if there's an existing design decision or conflict with in-progress work, better to know before you spend a week coding. For small, obviously-scoped changes (typos, single-function fixes, docs updates) you can skip this and go straight to a PR. @@ -47,12 +69,30 @@ Before opening a PR: - [ ] `bunx tsc --noEmit` is clean - [ ] Any docs or setup guidance affected by the change is updated - [ ] If touching core daemon paths (`src/`, `commands/`): run a quick manual smoke test +======= +- Check the [open issues](https://github.com/moazbuilds/claudeclaw/issues) and existing PRs to avoid duplication +- For anything beyond a small fix, open an issue first to discuss the approach +- Keep the "lightweight" principle in mind: ClaudeClaw runs on low-spec machines, so avoid adding heavy dependencies or new long-lived processes without a strong reason + +--- + +## Validation + +Before opening a PR: + +- [ ] Run the relevant checks locally +- [ ] Update any docs or setup guidance affected by your change +>>>>>>> upstream/master --- ## Plugin version bumps (CI-enforced) +<<<<<<< HEAD If your PR changes shipped plugin files under `src/`, `commands/`, `prompts/`, or `.claude-plugin/`, the plugin metadata version **must** be bumped. The CI checks will fail if you skip this. +======= +If your PR changes shipped plugin files under `src/`, `commands/`, `prompts/`, or `.claude-plugin/`, bump the version metadata: +>>>>>>> upstream/master ```bash bun run bump:plugin-version @@ -63,6 +103,7 @@ Typical rule: - bump `.claude-plugin/plugin.json` when shipped plugin content changes - bump `.claude-plugin/marketplace.json` when marketplace metadata should reflect the new version +<<<<<<< HEAD Docs-only and other non-shipped changes do not require these bumps. --- @@ -98,11 +139,15 @@ Features under `src/governance/`, `src/policy/`, or anything touching the tool-c ## Proposing features for upstream Found something in Plus that you think belongs in the lightweight core too? Open a PR upstream at [`moazbuilds/claudeclaw`](https://github.com/moazbuilds/claudeclaw) and link it from here. @moazbuilds makes the call on what fits. +======= +Docs-only and other non-shipped changes do not require these bumps. (CI will tell you if you missed one.) +>>>>>>> upstream/master --- ## Code of conduct +<<<<<<< HEAD Be decent. Critique code, not people. If something isn't clear, ask — don't assume the worst. --- @@ -110,3 +155,6 @@ Be decent. Critique code, not people. If something isn't clear, ask — don't as ## Questions? Open a [discussion](https://github.com/TerrysPOV/ClaudeClaw-Plus/discussions) or ping [@TerrysPOV](https://github.com/TerrysPOV) in an issue. +======= +Be decent. Critique code, not people. +>>>>>>> upstream/master diff --git a/commands/config.md b/commands/config.md index d96eb3f..d00d3a9 100644 --- a/commands/config.md +++ b/commands/config.md @@ -40,7 +40,7 @@ Parse `$ARGUMENTS` to identify what the user wants. If no arguments are given, s - Enabled: yes/no - Address: host:port -3. Also list any cron jobs from `.claude/claudeclaw/jobs/` with their name and schedule. +3. Also list any cron jobs from the configured jobs directory (see `jobsDir` in settings, default: `.claude/claudeclaw/jobs/`) with their name, schedule, and notify mode. 4. Remind the user that changes are hot-reloaded every 30s — no daemon restart needed. ### `heartbeat on` / `heartbeat off` / `heartbeat enable` / `heartbeat disable` diff --git a/commands/jobs.md b/commands/jobs.md index 19c841c..f731c48 100644 --- a/commands/jobs.md +++ b/commands/jobs.md @@ -4,7 +4,11 @@ description: "Create, list, edit, or delete cron jobs. Triggers: create a job, a Manage cron jobs for the heartbeat daemon. Use `$ARGUMENTS` to determine the action. -**CRITICAL: Job files MUST be created in the project-relative path `.claude/claudeclaw/jobs/`, NOT in `~/.claude/claudeclaw/jobs/`.** The daemon only watches the project directory. Using the home directory path will silently fail — the job will never fire. +## Resolving the jobs directory + +Read `.claude/claudeclaw/settings.json`. If the `jobsDir` field is set, use that path (resolve relative paths against the project root). Otherwise use the default: `.claude/claudeclaw/jobs/`. + +**CRITICAL: Job files MUST live under the project-relative jobs directory, NOT under `~/.claude/claudeclaw/jobs/`.** The daemon only watches the project directory. Using the home directory path will silently fail — the job will never fire. Parse `$ARGUMENTS` to identify the sub-command. If no arguments are given, list all jobs. @@ -12,10 +16,11 @@ Parse `$ARGUMENTS` to identify the sub-command. If no arguments are given, list ### `list` (default when no arguments) -1. List all `.md` files in `.claude/claudeclaw/jobs/`. +1. List all `.md` files in the jobs directory. 2. For each file, read it and display: - **Job name** (filename without `.md`) - **Schedule** (cron expression from frontmatter) + - **Notify** (`true`, `false`, or `error` — from frontmatter, default `true`) - **Prompt** (body text, truncated to 100 chars if long) 3. If no jobs exist, tell the user and show how to create one. @@ -29,14 +34,17 @@ Create a new cron job interactively. 2. Then ask: - "What prompt should Claude execute?" (header: "Prompt", options: suggest 2-3 prompts relevant to the project context) + - "Should this job send notifications?" (header: "Notify", options: "Always (default)", "Errors only", "Never") -3. Create the job file at `.claude/claudeclaw/jobs/.md` with this exact format: +3. Create the job file at `/.md` with this exact format: ```markdown --- schedule: "" + notify: --- ``` + Map the notify answer: "Always" → `true`, "Errors only" → `error`, "Never" → `false`. Omit the `notify` line if the user chose "Always" (it's the default). 4. Confirm creation. Remind the user the daemon hot-reloads jobs every 30 seconds — no restart needed. @@ -44,15 +52,18 @@ Create a new cron job interactively. Edit an existing cron job. -1. Read `.claude/claudeclaw/jobs/.md`. If it doesn't exist, list available jobs and ask the user which one to edit. +1. Read `/.md`. If it doesn't exist, list available jobs and ask the user which one to edit. 2. Show the current schedule and prompt. 3. Use **AskUserQuestion** to ask: - - "What do you want to change?" (header: "Edit", options: "Schedule", "Prompt", "Both") + - "What do you want to change?" (header: "Edit", options: "Schedule", "Prompt", "Notify") 4. Based on the answer: - **Schedule**: Ask for a new cron expression with preset options (same as create). - **Prompt**: Ask for a new prompt with the current prompt shown for reference. - - **Both**: Ask both questions. -5. Write the updated file and confirm. + - **Notify**: Ask "Should this job send notifications?" (header: "Notify", options: "Always", "Errors only", "Never"). Map: "Always" → `true`, "Errors only" → `error`, "Never" → `false`. +5. Use **AskUserQuestion** to ask: + - "Anything else to change?" (header: "Continue", options: "Yes", "No") + - If **Yes**, go back to step 3. + - If **No**, write the updated file and confirm. ### `delete` or `remove ` @@ -60,14 +71,14 @@ Delete a cron job. 1. If no job name given in `$ARGUMENTS`, list all jobs and use **AskUserQuestion** to ask which one to delete. 2. Confirm deletion with **AskUserQuestion**: "Delete job ''? This cannot be undone." (header: "Confirm", options: "Yes, delete it", "No, keep it") -3. If confirmed, delete `.claude/claudeclaw/jobs/.md`. +3. If confirmed, delete `/.md`. 4. Confirm deletion. The daemon will pick up the change on the next hot-reload cycle (within 30s). ### `run ` Manually trigger a cron job immediately (useful for testing). -1. Read `.claude/claudeclaw/jobs/.md`. If it doesn't exist, list available jobs. +1. Read `/.md`. If it doesn't exist, list available jobs. 2. Show the job's prompt and ask for confirmation: "Run job '' now?" (header: "Run", options: "Yes", "No") 3. If confirmed, run the prompt by executing: ```bash @@ -80,7 +91,7 @@ Manually trigger a cron job immediately (useful for testing). ## Reference: Job File Format -Jobs live in `.claude/claudeclaw/jobs/` as markdown files: +Jobs live in the configured jobs directory (default: `.claude/claudeclaw/jobs/`) as markdown files: ```markdown --- @@ -97,6 +108,16 @@ Your prompt here. Claude will run this at the scheduled time. **`recurring`**: If `true`, the job repeats on schedule. If omitted or `false`, the job is **one-shot** — the schedule is removed from the file after it runs. Legacy compatibility: `daily` is still accepted in existing job files. +**`notify`**: Controls whether job output is forwarded to configured messaging platforms (Telegram, Discord). Accepts three values: + +| Value | Behavior | +|---------|------------------------------------------------------------------| +| `true` | Always forward output to messaging platforms **(default)** | +| `error` | Only forward if the job fails (non-zero exit code) | +| `false` | Never forward (silent job) | + +Logs are always written to `.claude/claudeclaw/logs/` regardless of the `notify` setting. + | Expression | Meaning | |------------------|--------------------------| | `* * * * *` | Every minute | diff --git a/prompts/SUMMARY.md b/prompts/SUMMARY.md new file mode 100644 index 0000000..8006b95 --- /dev/null +++ b/prompts/SUMMARY.md @@ -0,0 +1,9 @@ +Generate a brief summary of the current session in markdown. + +Include: +- Key decisions that were made +- Unfinished tasks and their current status +- Important context to carry into the next session +- Errors or problems that were discovered + +Format: ## headings with bullet points. Maximum 500 words. diff --git a/src/__tests__/jobs.test.ts b/src/__tests__/jobs.test.ts index b22d52d..4c7d1f6 100644 --- a/src/__tests__/jobs.test.ts +++ b/src/__tests__/jobs.test.ts @@ -1,3 +1,4 @@ +<<<<<<< HEAD /** * Tests for jobs.ts — Phase 17 multi-job loader extension. * @@ -243,5 +244,188 @@ describe("Phase 18: loadJobs invalid model rejection", () => { } finally { errSpy.mockRestore(); } +======= +import { describe, test, expect, beforeEach, afterAll } from "bun:test"; +import { mkdir, writeFile, rm } from "fs/promises"; +import { join } from "path"; + +const TEST_ROOT = join(import.meta.dir, "../../test-sandbox-jobs"); +const LEGACY_JOBS_DIR = join(TEST_ROOT, ".claude", "claudeclaw", "jobs"); +const AGENTS_DIR = join(TEST_ROOT, "agents"); + +async function resetSandbox() { + await rm(TEST_ROOT, { recursive: true, force: true }); + await mkdir(LEGACY_JOBS_DIR, { recursive: true }); + await mkdir(join(AGENTS_DIR, "suzy", "jobs"), { recursive: true }); + await mkdir(join(AGENTS_DIR, "reg", "jobs"), { recursive: true }); +} + +afterAll(async () => { + await rm(TEST_ROOT, { recursive: true, force: true }); +}); + +function jobMd(schedule: string, prompt: string, extra = ""): string { + const extras = extra ? extra + "\n" : ""; + return `---\nschedule: ${schedule}\nrecurring: true\n${extras}---\n${prompt}\n`; +} + +/** Run loadJobs() in the sandbox dir via a child bun process (so process.cwd() == TEST_ROOT). */ +async function loadJobsInSandbox(): Promise { + const script = ` +import { loadJobs } from ${JSON.stringify(join(import.meta.dir, "..", "jobs"))}; +const jobs = await loadJobs(); +process.stdout.write(JSON.stringify(jobs)); +`; + const scriptPath = join(TEST_ROOT, "_run.ts"); + await writeFile(scriptPath, script); + const proc = Bun.spawn(["bun", "run", scriptPath], { + cwd: TEST_ROOT, + stdout: "pipe", + stderr: "pipe", + }); + const out = await new Response(proc.stdout).text(); + await proc.exited; + return JSON.parse(out || "[]"); +} + +// ─── Integration tests ──────────────────────────────────────────────────── + +describe("loadJobs", () => { + beforeEach(resetSandbox); + + test("empty dirs → zero jobs, no throw", async () => { + const jobs = await loadJobsInSandbox(); + expect(jobs).toEqual([]); + }); + + test("loads job from legacy .claude/claudeclaw/jobs/", async () => { + await writeFile( + join(LEGACY_JOBS_DIR, "nightly.md"), + jobMd("0 3 * * *", "Run nightly report") + ); + const jobs = await loadJobsInSandbox(); + const job = jobs.find((j) => j.name === "nightly"); + expect(job).toBeDefined(); + expect(job?.agent).toBeUndefined(); // not agent-scoped + expect(job?.schedule).toBe("0 3 * * *"); + expect(job?.prompt).toBe("Run nightly report"); + }); + + test("loads job from agents//jobs/ (Phase 17 path)", async () => { + await writeFile( + join(AGENTS_DIR, "suzy", "jobs", "daily-digest.md"), + jobMd("0 9 * * *", "Summarise today's news") + ); + const jobs = await loadJobsInSandbox(); + const job = jobs.find((j) => j.name === "suzy/daily-digest"); + expect(job).toBeDefined(); + expect(job?.agent).toBe("suzy"); + expect(job?.label).toBe("daily-digest"); + expect(job?.schedule).toBe("0 9 * * *"); + expect(job?.prompt).toBe("Summarise today's news"); + }); + + test("directory location overrides frontmatter agent field", async () => { + // Even if the .md file says agent: wrong, the enclosing dir wins. + await writeFile( + join(AGENTS_DIR, "reg", "jobs", "seo.md"), + jobMd("30 10 * * *", "SEO review", "agent: wrong-agent") + ); + const jobs = await loadJobsInSandbox(); + const job = jobs.find((j) => j.name === "reg/seo"); + expect(job?.agent).toBe("reg"); + }); + + test("enabled: false excludes job", async () => { + await writeFile( + join(AGENTS_DIR, "suzy", "jobs", "disabled.md"), + jobMd("0 12 * * *", "Disabled", "enabled: false") + ); + const jobs = await loadJobsInSandbox(); + expect(jobs.find((j) => j.name === "suzy/disabled")).toBeUndefined(); + }); + + test("returns jobs from both legacy and agent-scoped locations together", async () => { + await writeFile(join(LEGACY_JOBS_DIR, "nightly.md"), jobMd("0 3 * * *", "Nightly")); + await writeFile(join(AGENTS_DIR, "suzy", "jobs", "morning.md"), jobMd("0 9 * * *", "Morning")); + const jobs = await loadJobsInSandbox(); + const names = jobs.map((j) => j.name); + expect(names).toContain("nightly"); + expect(names).toContain("suzy/morning"); + }); + + test("missing agents/ dir is silently ignored (no throw)", async () => { + await rm(AGENTS_DIR, { recursive: true, force: true }); + const jobs = await loadJobsInSandbox(); + expect(Array.isArray(jobs)).toBe(true); + }); + + test("agent dir without jobs/ subdir is skipped", async () => { + // publisher/ exists but has no jobs/ subdirectory + await mkdir(join(AGENTS_DIR, "publisher"), { recursive: true }); + const jobs = await loadJobsInSandbox(); + expect(jobs.filter((j) => j.name.startsWith("publisher/"))).toEqual([]); + }); + + test("job file without schedule: field is skipped gracefully", async () => { + await writeFile( + join(AGENTS_DIR, "suzy", "jobs", "bad.md"), + "---\nprompt: test\n---\nNo schedule line.\n" + ); + // Should not throw, should return other valid jobs + const jobs = await loadJobsInSandbox(); + expect(jobs.find((j) => j.name === "suzy/bad")).toBeUndefined(); + }); +}); + +// ─── Unit: Job type and session path assertions ─────────────────────────── + +describe("Job type", () => { + test("includes agent, label, enabled fields", () => { + const job: import("../jobs").Job = { + name: "agent/job", + schedule: "0 9 * * *", + prompt: "test", + recurring: true, + notify: true, + agent: "myagent", + label: "myjob", + enabled: true, + }; + expect(job.agent).toBe("myagent"); + expect(job.label).toBe("myjob"); + expect(job.enabled).toBe(true); + }); +}); + +describe("sessions — agent-scoped paths", () => { + test("getSession/createSession/incrementTurn accept optional agentName", async () => { + const src = await Bun.file(join(import.meta.dir, "../sessions.ts")).text(); + // All public functions should have agentName? param + expect(src).toContain("getSession(\n agentName?: string"); + expect(src).toContain("createSession(sessionId: string, agentName?: string)"); + expect(src).toContain("incrementTurn(agentName?: string)"); + expect(src).toContain("markCompactWarned(agentName?: string)"); + }); + + test("agent sessions stored outside .claude/", async () => { + const src = await Bun.file(join(import.meta.dir, "../sessions.ts")).text(); + // Verify path uses getAgentsDir() (project root) not HEARTBEAT_DIR (.claude/...) + expect(src).toContain('join(getAgentsDir(), agentName, "session.json")'); + }); +}); + +// ─── Unit: protection-bug validation (the core motivation) ─────────────── + +describe("write-protection bug validation", () => { + test("agent-scoped job path is outside .claude/ (key property)", () => { + // The Claude Code CLI hardcodes a protection list for .claude/ paths. + // Agent-scoped jobs live at agents//jobs/.md — no .claude/ prefix. + // This test documents the requirement explicitly. + const legacyPath = join(process.cwd(), ".claude", "claudeclaw", "jobs", "job.md"); + const agentPath = join(process.cwd(), "agents", "suzy", "jobs", "daily.md"); + expect(legacyPath).toContain("/.claude/"); + expect(agentPath).not.toContain("/.claude/"); +>>>>>>> upstream/master }); }); diff --git a/src/__tests__/messaging.test.ts b/src/__tests__/messaging.test.ts new file mode 100644 index 0000000..14ed5b5 --- /dev/null +++ b/src/__tests__/messaging.test.ts @@ -0,0 +1,17 @@ +import { describe, test, expect } from "bun:test"; +import { extractErrorDetail } from "../messaging"; + +describe("extractErrorDetail", () => { + test("prefers stderr over stdout", () => { + expect(extractErrorDetail({ stdout: "some output", stderr: "auth error" })).toBe("auth error"); + }); + + test("parses JSON stdout error when stderr is empty", () => { + const stdout = JSON.stringify({ is_error: true, result: "Rate limit exceeded" }); + expect(extractErrorDetail({ stdout, stderr: "" })).toBe("Rate limit exceeded"); + }); + + test("falls back to raw stdout when not JSON and no stderr", () => { + expect(extractErrorDetail({ stdout: "plain error text", stderr: "" })).toBe("plain error text"); + }); +}); diff --git a/src/__tests__/plugin-cli.test.ts b/src/__tests__/plugin-cli.test.ts new file mode 100644 index 0000000..fb83da4 --- /dev/null +++ b/src/__tests__/plugin-cli.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "bun:test"; +import { buildArgs, type PluginAction } from "../commands/plugin-cli"; + +describe("buildArgs", () => { + it("marketplace-add", () => { + const action: PluginAction = { kind: "marketplace-add", source: "https://github.com/example/plugins" }; + expect(buildArgs(action)).toEqual(["claude", "plugin", "marketplace", "add", "https://github.com/example/plugins"]); + }); + + it("marketplace-list", () => { + expect(buildArgs({ kind: "marketplace-list" })).toEqual(["claude", "plugin", "marketplace", "list"]); + }); + + it("marketplace-update with name", () => { + expect(buildArgs({ kind: "marketplace-update", name: "my-marketplace" })).toEqual([ + "claude", "plugin", "marketplace", "update", "my-marketplace", + ]); + }); + + it("marketplace-update without name (update all)", () => { + expect(buildArgs({ kind: "marketplace-update" })).toEqual(["claude", "plugin", "marketplace", "update"]); + }); + + it("marketplace-remove", () => { + expect(buildArgs({ kind: "marketplace-remove", name: "old-marketplace" })).toEqual([ + "claude", "plugin", "marketplace", "remove", "old-marketplace", + ]); + }); + + it("install user scope", () => { + expect(buildArgs({ kind: "install", plugin: "my-plugin", scope: "user" })).toEqual([ + "claude", "plugin", "install", "my-plugin", "-s", "user", + ]); + }); + + it("install project scope", () => { + expect(buildArgs({ kind: "install", plugin: "my-plugin@my-marketplace", scope: "project" })).toEqual([ + "claude", "plugin", "install", "my-plugin@my-marketplace", "-s", "project", + ]); + }); + + it("list", () => { + expect(buildArgs({ kind: "list" })).toEqual(["claude", "plugin", "list"]); + }); + + it("uninstall", () => { + expect(buildArgs({ kind: "uninstall", plugin: "my-plugin" })).toEqual(["claude", "plugin", "uninstall", "my-plugin"]); + }); + + it("enable", () => { + expect(buildArgs({ kind: "enable", plugin: "my-plugin" })).toEqual(["claude", "plugin", "enable", "my-plugin"]); + }); + + it("disable", () => { + expect(buildArgs({ kind: "disable", plugin: "my-plugin" })).toEqual(["claude", "plugin", "disable", "my-plugin"]); + }); +}); diff --git a/src/__tests__/plugin-wizard.test.ts b/src/__tests__/plugin-wizard.test.ts new file mode 100644 index 0000000..14d4666 --- /dev/null +++ b/src/__tests__/plugin-wizard.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeEach } from "bun:test"; +import { + isWizardTrigger, + hasActiveWizard, + handleWizardInput, + cancelWizard, + type WizardContext, +} from "../commands/plugin-wizard"; + +// Wizard tests use real state machine but rely on the fact that +// runPluginCli is never reached in unit tests (all paths that would call +// it require valid CLI responses). We only test state transitions and +// input classification here. + +const ctx: WizardContext = { iface: "web", scopeId: "test-unit" }; + +beforeEach(() => { + cancelWizard(ctx); +}); + +// --- isWizardTrigger --- + +describe("isWizardTrigger", () => { + it("recognises /plugin", () => { expect(isWizardTrigger("/plugin")).toBe(true); }); + it("recognises /claudeclaw:plugin", () => { expect(isWizardTrigger("/claudeclaw:plugin")).toBe(true); }); + it("ignores case differences", () => { expect(isWizardTrigger("/PLUGIN")).toBe(true); }); + it("rejects non-plugin commands", () => { expect(isWizardTrigger("/reset")).toBe(false); }); + it("rejects plain text", () => { expect(isWizardTrigger("hello world")).toBe(false); }); + it("accepts /plugin with trailing args", () => { expect(isWizardTrigger("/plugin some args")).toBe(true); }); +}); + +// --- hasActiveWizard / lifecycle --- + +describe("hasActiveWizard", () => { + it("returns false before any interaction", () => { + expect(hasActiveWizard(ctx)).toBe(false); + }); + + it("returns true after opening the wizard", async () => { + await handleWizardInput(ctx, "/plugin"); + expect(hasActiveWizard(ctx)).toBe(true); + }); + + it("returns false after cancel", async () => { + await handleWizardInput(ctx, "/plugin"); + await handleWizardInput(ctx, "cancel"); + expect(hasActiveWizard(ctx)).toBe(false); + }); +}); + +// --- cancelWizard --- + +describe("cancelWizard", () => { + it("clears an active session", async () => { + await handleWizardInput(ctx, "/plugin"); + cancelWizard(ctx); + expect(hasActiveWizard(ctx)).toBe(false); + }); + + it("is safe to call when no session exists", () => { + expect(() => cancelWizard(ctx)).not.toThrow(); + }); +}); + +// --- wizard flow: menu --- + +describe("wizard menu", () => { + it("returns the action menu on /plugin", async () => { + const reply = await handleWizardInput(ctx, "/plugin"); + expect(reply).toContain("Add marketplace"); + expect(reply).toContain("Install plugin"); + }); + + it("shows menu on unrecognised option", async () => { + await handleWizardInput(ctx, "/plugin"); + const reply = await handleWizardInput(ctx, "99"); + expect(reply).toContain("Add marketplace"); + }); +}); + +// --- wizard flow: marketplace source prompt --- + +describe("marketplace-source step", () => { + it("asks for source URL after choosing option 1", async () => { + await handleWizardInput(ctx, "/plugin"); + const reply = await handleWizardInput(ctx, "1"); + expect(reply).toMatch(/url|path|repo/i); + }); + + it("can be cancelled mid-flow", async () => { + await handleWizardInput(ctx, "/plugin"); + await handleWizardInput(ctx, "1"); + const reply = await handleWizardInput(ctx, "cancel"); + expect(reply).toContain("cancelled"); + expect(hasActiveWizard(ctx)).toBe(false); + }); +}); + +// --- wizard flow: install scope prompt --- + +describe("install-scope step", () => { + it("asks for scope after providing plugin name", async () => { + await handleWizardInput(ctx, "/plugin"); + await handleWizardInput(ctx, "4"); + const reply = await handleWizardInput(ctx, "my-plugin"); + expect(reply).toContain("user"); + expect(reply).toContain("project"); + }); + + it("rejects invalid scope choice and re-prompts", async () => { + await handleWizardInput(ctx, "/plugin"); + await handleWizardInput(ctx, "4"); + await handleWizardInput(ctx, "my-plugin"); + const reply = await handleWizardInput(ctx, "3"); + expect(reply).toMatch(/1.*user|2.*project/i); + }); +}); + +// --- install-confirm: no manifest path (readPluginManifest returns null) --- + +describe("install-confirm step", () => { + it("shows confirmation prompt with plugin name", async () => { + await handleWizardInput(ctx, "/plugin"); + await handleWizardInput(ctx, "4"); + await handleWizardInput(ctx, "test-plugin"); + const reply = await handleWizardInput(ctx, "1"); // scope: user + expect(reply).toContain("test-plugin"); + expect(reply).toMatch(/yes/i); + }); + + it("rejects non-yes reply and re-prompts", async () => { + await handleWizardInput(ctx, "/plugin"); + await handleWizardInput(ctx, "4"); + await handleWizardInput(ctx, "test-plugin"); + await handleWizardInput(ctx, "1"); + const reply = await handleWizardInput(ctx, "nope"); + expect(reply).toMatch(/yes.*install|cancel/i); + }); +}); + +// --- independent contexts don't interfere --- + +describe("context isolation", () => { + it("sessions from different contexts are independent", async () => { + const ctx2: WizardContext = { iface: "discord", scopeId: "channel-abc" }; + cancelWizard(ctx2); + + await handleWizardInput(ctx, "/plugin"); + expect(hasActiveWizard(ctx)).toBe(true); + expect(hasActiveWizard(ctx2)).toBe(false); + }); +}); diff --git a/src/__tests__/plugins.test.ts b/src/__tests__/plugins.test.ts new file mode 100644 index 0000000..fe90259 --- /dev/null +++ b/src/__tests__/plugins.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, beforeEach } from "bun:test"; +import { + PluginManager, + getPluginManager, + setPluginManager, + parsePlugins, + type EventContext, + type PluginApi, +} from "../plugins"; + +const ctx: EventContext = { workspaceDir: "/tmp/test" }; + +describe("parsePlugins", () => { + it("returns empty object for null/undefined", () => { + expect(parsePlugins(null)).toEqual({}); + expect(parsePlugins(undefined)).toEqual({}); + expect(parsePlugins("string")).toEqual({}); + }); + + it("parses a valid plugin entry", () => { + const result = parsePlugins({ + "claude-mem": { enabled: true, source: "openclaw", config: { workerPort: 37777 } }, + }); + expect(result["claude-mem"]).toEqual({ + enabled: true, + source: "openclaw", + config: { workerPort: 37777 }, + }); + }); + + it("defaults enabled to false and source to openclaw", () => { + const result = parsePlugins({ "my-plugin": {} }); + expect(result["my-plugin"].enabled).toBe(false); + expect(result["my-plugin"].source).toBe("openclaw"); + expect(result["my-plugin"].config).toEqual({}); + }); + + it("trims source string", () => { + const result = parsePlugins({ p: { source: " my-source " } }); + expect(result["p"].source).toBe("my-source"); + }); + + it("skips non-object entries", () => { + const result = parsePlugins({ bad: "not-an-object", good: { enabled: true, source: "x", config: {} } }); + expect(result["bad"]).toBeUndefined(); + expect(result["good"]).toBeDefined(); + }); +}); + +describe("PluginManager singleton", () => { + beforeEach(() => { + setPluginManager(null); + }); + + it("starts as null", () => { + expect(getPluginManager()).toBeNull(); + }); + + it("setPluginManager / getPluginManager round-trip", () => { + const pm = new PluginManager("/tmp"); + setPluginManager(pm); + expect(getPluginManager()).toBe(pm); + setPluginManager(null); + expect(getPluginManager()).toBeNull(); + }); +}); + +describe("PluginManager event system", () => { + let pm: PluginManager; + + beforeEach(() => { + pm = new PluginManager("/tmp/test"); + }); + + it("emit returns undefined when no handlers registered", async () => { + const result = await pm.emit("agent_end", {}, ctx); + expect(result).toBeUndefined(); + }); + + it("on/emit: handler receives data and ctx", async () => { + let received: { data: unknown; ctx: EventContext } | null = null; + let api: PluginApi | null = null; + + // Register a plugin manually via the internal api + const internalApi = (pm as unknown as { + buildApi: (id: string, cfg: Record) => PluginApi; + }).buildApi("test-plugin", {}); + api = internalApi; + internalApi.on("agent_end", (data, c) => { + received = { data, ctx: c }; + }); + + await pm.emit("agent_end", { messages: ["hello"] }, ctx); + expect(received).not.toBeNull(); + expect((received!.data as { messages: unknown[] }).messages).toEqual(["hello"]); + expect(received!.ctx).toBe(ctx); + expect(api).not.toBeNull(); + }); + + it("before_prompt_build merges appendSystemContext from multiple handlers", async () => { + const internalApi = (pm as unknown as { + buildApi: (id: string, cfg: Record) => PluginApi; + }).buildApi("p1", {}); + internalApi.on("before_prompt_build", () => ({ appendSystemContext: "context-a" })); + + const internalApi2 = (pm as unknown as { + buildApi: (id: string, cfg: Record) => PluginApi; + }).buildApi("p2", {}); + internalApi2.on("before_prompt_build", () => ({ appendSystemContext: "context-b" })); + + const result = await pm.emit("before_prompt_build", { prompt: "test" }, ctx); + expect(result?.appendSystemContext).toBe("context-a\n\ncontext-b"); + }); + + it("before_prompt_build returns undefined when no handler returns appendSystemContext", async () => { + const internalApi = (pm as unknown as { + buildApi: (id: string, cfg: Record) => PluginApi; + }).buildApi("p", {}); + internalApi.on("before_prompt_build", () => ({})); + + const result = await pm.emit("before_prompt_build", {}, ctx); + expect(result).toBeUndefined(); + }); + + it("emitAsync does not throw synchronously even when handler throws", () => { + const internalApi = (pm as unknown as { + buildApi: (id: string, cfg: Record) => PluginApi; + }).buildApi("p", {}); + internalApi.on("agent_end", () => { throw new Error("boom"); }); + // Must not throw — error is logged via console.warn + expect(() => pm.emitAsync("agent_end", {}, ctx)).not.toThrow(); + }); + + it("emitAsync is fire-and-forget (returns void)", () => { + const result = pm.emitAsync("agent_end", {}, ctx); + expect(result).toBeUndefined(); + }); + + it("handler errors in emit are swallowed and do not abort subsequent handlers", async () => { + let secondCalled = false; + const internalApi = (pm as unknown as { + buildApi: (id: string, cfg: Record) => PluginApi; + }).buildApi("p1", {}); + internalApi.on("agent_end", () => { throw new Error("handler error"); }); + + const internalApi2 = (pm as unknown as { + buildApi: (id: string, cfg: Record) => PluginApi; + }).buildApi("p2", {}); + internalApi2.on("agent_end", () => { secondCalled = true; }); + + await pm.emit("agent_end", {}, ctx); + expect(secondCalled).toBe(true); + }); +}); + +describe("PluginManager commands", () => { + let pm: PluginManager; + + beforeEach(() => { + pm = new PluginManager("/tmp/test"); + }); + + it("runCommand returns null for unknown command", async () => { + expect(await pm.runCommand("unknown")).toBeNull(); + }); + + it("registerCommand/runCommand round-trip", async () => { + const internalApi = (pm as unknown as { + buildApi: (id: string, cfg: Record) => PluginApi; + }).buildApi("p", {}); + internalApi.registerCommand({ name: "hello", handler: async () => "world" }); + + expect(await pm.runCommand("hello")).toBe("world"); + expect(pm.getCommandNames()).toContain("hello"); + }); +}); + +describe("PluginManager services", () => { + let pm: PluginManager; + + beforeEach(() => { + pm = new PluginManager("/tmp/test"); + }); + + it("startServices/stopServices do not throw when no services registered", async () => { + await expect(pm.startServices()).resolves.toBeUndefined(); + await expect(pm.stopServices()).resolves.toBeUndefined(); + }); + + it("registerService start/stop called", async () => { + let started = false; + let stopped = false; + const internalApi = (pm as unknown as { + buildApi: (id: string, cfg: Record) => PluginApi; + }).buildApi("p", {}); + internalApi.registerService({ + id: "my-service", + start: async () => { started = true; }, + stop: async () => { stopped = true; }, + }); + + await pm.startServices(); + expect(started).toBe(true); + + await pm.stopServices(); + expect(stopped).toBe(true); + }); +}); + +describe("PluginManager info", () => { + it("hasPlugins is false with no loaded plugins", () => { + const pm = new PluginManager("/tmp"); + expect(pm.hasPlugins).toBe(false); + expect(pm.loaded).toEqual([]); + }); +}); + +describe("PluginManager path resolution (security)", () => { + it("rejects relative-segment source strings (path traversal guard)", async () => { + const pm = new PluginManager("/tmp/test"); + // loadPlugin will call resolvePluginPath which should return null for traversal attempts + const resolvePluginPath = (pm as unknown as { + resolvePluginPath: (id: string, source: string) => string | null; + }).resolvePluginPath.bind(pm); + + expect(resolvePluginPath("p", "../../etc")).toBeNull(); + expect(resolvePluginPath("p", "../evil")).toBeNull(); + expect(resolvePluginPath("p", "valid-pkg")).toBeNull(); // null because file doesn't exist, not because of rejection + }); + + it("accepts valid npm package names", () => { + const pm = new PluginManager("/tmp/test"); + const resolvePluginPath = (pm as unknown as { + resolvePluginPath: (id: string, source: string) => string | null; + }).resolvePluginPath.bind(pm); + + // These return null only because the files don't exist, not due to validation failure + // We verify resolvePluginPath doesn't throw and returns null (not a hard error) + expect(() => resolvePluginPath("p", "my-plugin")).not.toThrow(); + expect(() => resolvePluginPath("p", "@scope/my-plugin")).not.toThrow(); + }); + + it("checkHealth rejects invalid host strings", async () => { + const pm = new PluginManager("/tmp/test"); + const checkHealth = (pm as unknown as { + checkHealth: (host: string, port: number) => Promise; + }).checkHealth.bind(pm); + + expect(await checkHealth("127.0.0.1/admin#", 8080)).toBe(false); + expect(await checkHealth("user@evil.com", 8080)).toBe(false); + expect(await checkHealth("", 8080)).toBe(false); + expect(await checkHealth("127.0.0.1", 0)).toBe(false); + expect(await checkHealth("127.0.0.1", 99999)).toBe(false); + }); +}); diff --git a/src/__tests__/slack.test.ts b/src/__tests__/slack.test.ts new file mode 100644 index 0000000..6e01bdb --- /dev/null +++ b/src/__tests__/slack.test.ts @@ -0,0 +1,77 @@ +import { describe, test, expect, beforeEach } from "bun:test"; + +// Pure helpers exported for testing +import { + sanitizeUserInput, + extractChannelReadDirectives, + extractReactionDirective, + assistantKey, +} from "../commands/slack"; + +describe("assistantKey", () => { + test("same channel, different thread_ts are distinct keys", () => { + const k1 = assistantKey("C123", "1234567890.000001"); + const k2 = assistantKey("C123", "1234567890.000002"); + expect(k1).not.toBe(k2); + const set = new Set([k1]); + expect(set.has(k1)).toBe(true); + expect(set.has(k2)).toBe(false); + }); + + test("different channels with same thread_ts are distinct", () => { + expect(assistantKey("C111", "ts")).not.toBe(assistantKey("C222", "ts")); + }); +}); + +describe("sanitizeUserInput", () => { + test("strips all directive families", () => { + const input = "hello [react:tada] [delete_all] [upload_file:/etc/passwd] [read_channel:CABC] [[slack_buttons:Yes:y]]"; + const out = sanitizeUserInput(input); + expect(out).not.toContain("[react:"); + expect(out).not.toContain("[delete_all]"); + expect(out).not.toContain("[upload_file:"); + expect(out).not.toContain("[read_channel:"); + expect(out).not.toContain("[[slack_buttons:"); + expect(out).toContain("hello"); + }); + + test("leaves plain text untouched", () => { + expect(sanitizeUserInput("just a normal message")).toBe("just a normal message"); + }); +}); + +describe("extractChannelReadDirectives", () => { + test("parses channel ID and limit", () => { + const { channelReads, cleanedText } = extractChannelReadDirectives("[read_channel:C123ABC:50]"); + expect(channelReads).toHaveLength(1); + expect(channelReads[0].channelId).toBe("C123ABC"); + expect(channelReads[0].limit).toBe(50); + expect(cleanedText.trim()).toBe(""); + }); + + test("defaults limit to 20 when omitted", () => { + const { channelReads } = extractChannelReadDirectives("[read_channel:CXYZ]"); + expect(channelReads[0].limit).toBe(20); + }); + + test("multiple directives in one message", () => { + const { channelReads } = extractChannelReadDirectives("please [read_channel:C1:5] and [read_channel:C2:10]"); + expect(channelReads).toHaveLength(2); + expect(channelReads[0].channelId).toBe("C1"); + expect(channelReads[1].channelId).toBe("C2"); + }); +}); + +describe("extractReactionDirective", () => { + test("extracts emoji and strips tag", () => { + const { cleanedText, reactionEmoji } = extractReactionDirective("Nice! [react:thumbsup] done"); + expect(reactionEmoji).toBe("thumbsup"); + expect(cleanedText).not.toContain("[react:"); + expect(cleanedText).toContain("Nice!"); + }); + + test("returns null emoji when none present", () => { + const { reactionEmoji } = extractReactionDirective("plain text"); + expect(reactionEmoji).toBeNull(); + }); +}); diff --git a/src/commands/discord.ts b/src/commands/discord.ts index 355c9ad..4bfb1b9 100644 --- a/src/commands/discord.ts +++ b/src/commands/discord.ts @@ -1,6 +1,7 @@ -import { ensureProjectClaudeMd, run, runUserMessage, compactCurrentSession } from "../runner"; +import { ensureProjectClaudeMd, run, runUserMessage, compactCurrentSession, compactCurrentThreadSession, agentDirKey } from "../runner"; +import { extractErrorDetail } from "../messaging"; import { getSettings, loadSettings } from "../config"; -import { resetSession, peekSession } from "../sessions"; +import { resetSession, resetFallbackSession, peekSession } from "../sessions"; import { listThreadSessions, removeThreadSession, peekThreadSession } from "../sessionManager"; import { readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; @@ -10,9 +11,13 @@ import { resolveSkillPrompt } from "../skills"; import { fireJob, parseFireArgs } from "./fire"; import { mkdir } from "node:fs/promises"; import { extname, join } from "node:path"; +<<<<<<< HEAD import { processEventWithFallback, setGatewayEnabled } from "../gateway"; import { normalizeDiscordMessage, type NormalizedEvent } from "../gateway/normalizer"; +======= +import { isWizardTrigger, hasActiveWizard, handleWizardInput } from "./plugin-wizard"; +>>>>>>> upstream/master // --- Discord API constants --- @@ -165,7 +170,20 @@ async function fetchDiscordWithSizeLimit(url: string): Promise { } // Track known thread channel IDs and their parent channel IDs for multi-session support -const knownThreads = new Map(); +const knownThreads = new Map(); + +// Upsert knownThreads, preserving any existing agentName when a new one is not supplied. +// The agentName key is "-" to guarantee uniqueness across threads whose +// display names would otherwise map to the same slug. +// Always use this instead of knownThreads.set() to avoid accidental data loss on recovery paths. +function upsertThread(id: string, parentId: string, rawName?: string): void { + const existing = knownThreads.get(id); + let agentName: string | undefined; + if (rawName) { + try { agentName = agentDirKey(rawName, id); } catch { /* unsanitizable — no agent scoping */ } + } + knownThreads.set(id, { parentId, agentName: agentName ?? existing?.agentName }); +} // --- Debug --- @@ -295,13 +313,15 @@ function extractReactionDirective(text: string): { cleanedText: string; reaction async function rejoinThreads(token: string): Promise { const threadSessions = await listThreadSessions(); for (const ts of threadSessions) { + // Skip non-snowflake keys (e.g. job names) — they are not Discord thread IDs + if (!/^\d{17,19}$/.test(ts.threadId)) continue; try { await discordApi(token, "DELETE", `/channels/${ts.threadId}/thread-members/@me`).catch(() => {}); await discordApi(token, "PUT", `/channels/${ts.threadId}/thread-members/@me`); if (!knownThreads.has(ts.threadId)) { - const ch = await discordApi<{ parent_id?: string }>(token, "GET", `/channels/${ts.threadId}`); + const ch = await discordApi<{ parent_id?: string; name?: string }>(token, "GET", `/channels/${ts.threadId}`); if (ch.parent_id) { - knownThreads.set(ts.threadId, { parentId: ch.parent_id }); + upsertThread(ts.threadId, ch.parent_id, ch.name); } } console.log(`[Discord] Rejoined thread: ${ts.threadId}`); @@ -321,7 +341,7 @@ function guildTriggerReason(message: DiscordMessage): string | null { if (botUserId && message.referenced_message?.author?.id === botUserId) return "reply_to_bot"; // Mention via mentions array - if (botUserId && message.mentions.some((m) => m.id === botUserId)) return "mention"; + if (botUserId && message.mentions?.some((m) => m.id === botUserId)) return "mention"; // Mention in content (fallback) if (botUserId && message.content.includes(`<@${botUserId}>`)) return "mention_in_content"; @@ -330,6 +350,9 @@ function guildTriggerReason(message: DiscordMessage): string | null { const config = getSettings().discord; if (config.listenChannels.includes(message.channel_id)) return "listen_channel"; + // Listen guild (respond to all messages in any channel/thread of this guild) + if (message.guild_id && config.listenGuilds.includes(message.guild_id)) return "listen_guild"; + // Thread whose parent channel is a listen channel const threadInfo = knownThreads.get(message.channel_id); if (threadInfo && config.listenChannels.includes(threadInfo.parentId)) return "listen_channel_thread"; @@ -500,10 +523,10 @@ async function handleMessageCreate(token: string, message: DiscordMessage): Prom const persisted = await peekThreadSession(channelId); if (persisted) { try { - const ch = await discordApi<{ parent_id?: string }>(config.token, "GET", `/channels/${channelId}`); + const ch = await discordApi<{ parent_id?: string; name?: string }>(config.token, "GET", `/channels/${channelId}`); if (ch.parent_id) { - knownThreads.set(channelId, { parentId: ch.parent_id }); - debugLog(`Thread recovered from sessions.json: ${channelId} (parent: ${ch.parent_id})`); + upsertThread(channelId, ch.parent_id, ch.name); + debugLog(`Thread recovered from sessions.json: ${channelId} (parent: ${ch.parent_id} name: ${ch.name ?? "unknown"})`); } } catch (err) { debugLog(`Thread recovery failed for ${channelId}: ${err}`); @@ -561,6 +584,17 @@ async function handleMessageCreate(token: string, message: DiscordMessage): Prom `[${new Date().toLocaleTimeString()}] Discord ${label}${mediaSuffix}: "${cleanContent.slice(0, 60)}${cleanContent.length > 60 ? "..." : ""}"`, ); + // Plugin wizard: intercept /plugin and /claudeclaw:plugin before thread management and Claude routing. + // Must run here — after auth + non-empty checks but before AI thread intent classification, + // so an active wizard cannot be bypassed by messages that classify as "hire" / "fire". + const threadInfo = knownThreads.get(channelId); + const wizardCtx = { iface: "discord" as const, scopeId: channelId, agentName: threadInfo?.agentName }; + if ((cleanContent.trim().startsWith("/") && isWizardTrigger(cleanContent.trim().split(/\s+/, 1)[0].toLowerCase())) || hasActiveWizard(wizardCtx)) { + const reply = await handleWizardInput(wizardCtx, cleanContent.trim()); + await sendMessage(config.token, channelId, reply); + return; + } + // Typing indicator loop (Discord typing lasts 10s, fire every 8s) const typingInterval = setInterval(() => sendTyping(config.token, channelId), 8000); @@ -629,7 +663,7 @@ async function handleMessageCreate(token: string, message: DiscordMessage): Prom auto_archive_duration: 4320, // 3 days }, ); - knownThreads.set(thread.id, { parentId: channelId }); + upsertThread(thread.id, channelId, threadName); // Don't pre-create session — let Claude CLI create it on first message // The real UUID will be captured and saved by runner.ts await sendMessage(config.token, thread.id, `🧵 Thread **${threadName}** created with independent session. Start chatting!`); @@ -680,6 +714,7 @@ async function handleMessageCreate(token: string, message: DiscordMessage): Prom // Skill routing: detect slash commands and resolve to SKILL.md prompts const command = cleanContent.startsWith("/") ? cleanContent.trim().split(/\s+/, 1)[0].toLowerCase() : null; +<<<<<<< HEAD // /fire :