diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 63592cfcf..eac574b53 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -8,7 +8,5 @@ "additionalProperties": false, "properties": {} }, - "skills": [ - "./plugins/genie/skills" - ] + "skills": ["./plugins/genie/skills"] } diff --git a/package.json b/package.json index 20b04d1c1..0c91e158f 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,7 @@ "url": "git+https://github.com/automagik-dev/genie.git" }, "openclaw": { - "extensions": [ - "./plugins/genie/index.ts" - ] + "extensions": ["./plugins/genie/index.ts"] }, "engines": { "bun": ">=1.3.10" @@ -56,7 +54,5 @@ "publishConfig": { "access": "public" }, - "trustedDependencies": [ - "@biomejs/biome" - ] + "trustedDependencies": ["@biomejs/biome"] } diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index ddc7fa9d5..c4740054c 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -7,12 +7,5 @@ }, "repository": "https://github.com/automagik-dev/genie", "license": "MIT", - "keywords": [ - "workflow", - "orchestration", - "collaboration", - "claude-code", - "tmux", - "agents" - ] + "keywords": ["workflow", "orchestration", "collaboration", "claude-code", "tmux", "agents"] } diff --git a/src/genie-commands/uninstall.ts b/src/genie-commands/uninstall.ts index 69568fe78..22c31896d 100644 --- a/src/genie-commands/uninstall.ts +++ b/src/genie-commands/uninstall.ts @@ -57,6 +57,18 @@ function removeSymlinks(): string[] { return removed; } +/** Try an uninstall step, logging success or warning on failure. */ +function tryRemoveStep(label: string, successMsg: string, fn: () => void): void { + console.log(`\x1b[2m${label}\x1b[0m`); + try { + fn(); + console.log(` \x1b[32m+\x1b[0m ${successMsg}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(` \x1b[33m!\x1b[0m ${label.replace('...', '')} failed: ${message}`); + } +} + /** * Uninstall Genie CLI entirely */ @@ -67,14 +79,7 @@ function performUninstall( hasGenieDir: boolean, ): void { if (hasHookScript) { - console.log('\x1b[2mRemoving hook script...\x1b[0m'); - try { - removeHookScript(); - console.log(' \x1b[32m+\x1b[0m Hook script removed'); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(` \x1b[33m!\x1b[0m Could not remove hook script: ${message}`); - } + tryRemoveStep('Removing hook script...', 'Hook script removed', () => removeHookScript()); } if (existingSymlinks.length > 0) { @@ -85,27 +90,18 @@ function performUninstall( } } - // Remove orchestration rules from ~/.claude/rules/ if (existsSync(ORCHESTRATION_RULES_PATH)) { - console.log('\x1b[2mRemoving orchestration rules...\x1b[0m'); - try { - unlinkSync(ORCHESTRATION_RULES_PATH); - console.log(' \x1b[32m+\x1b[0m Orchestration rules removed (~/.claude/rules/genie-orchestration.md)'); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(` \x1b[33m!\x1b[0m Could not remove orchestration rules: ${message}`); - } + tryRemoveStep( + 'Removing orchestration rules...', + 'Orchestration rules removed (~/.claude/rules/genie-orchestration.md)', + () => unlinkSync(ORCHESTRATION_RULES_PATH), + ); } if (hasGenieDir) { - console.log('\x1b[2mRemoving genie directory...\x1b[0m'); - try { - rmSync(genieDir, { recursive: true, force: true }); - console.log(' \x1b[32m+\x1b[0m Directory removed'); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(` \x1b[33m!\x1b[0m Could not remove directory: ${message}`); - } + tryRemoveStep('Removing genie directory...', 'Directory removed', () => + rmSync(genieDir, { recursive: true, force: true }), + ); } } diff --git a/src/genie-commands/update.ts b/src/genie-commands/update.ts index e5fdd2894..f0f1b931e 100644 --- a/src/genie-commands/update.ts +++ b/src/genie-commands/update.ts @@ -339,6 +339,27 @@ async function resolveGlobalPkgDir(installType: InstallationType): Promise { log('Syncing Claude Code plugin...'); @@ -379,26 +400,7 @@ async function syncPlugin(installType: InstallationType): Promise { return; } - // Update installed_plugins.json registry - const registryPath = join(claudePlugins, 'installed_plugins.json'); - try { - if (existsSync(registryPath)) { - const registry = JSON.parse(readFileSync(registryPath, 'utf-8')); - const entries = registry.plugins?.['genie@automagik']; - if (Array.isArray(entries)) { - for (const entry of entries) { - if (entry.scope === 'user') { - entry.installPath = cacheDir; - entry.version = version; - entry.lastUpdated = new Date().toISOString(); - } - } - writeFileSync(registryPath, JSON.stringify(registry, null, 2)); - } - } - } catch (err) { - log(`Registry update failed (non-fatal): ${err}`); - } + updatePluginRegistry(claudePlugins, cacheDir, version); success(`Plugin synced to v${version}`); } diff --git a/src/hooks/handlers/auto-spawn.ts b/src/hooks/handlers/auto-spawn.ts index ee992654a..80ddb838d 100644 --- a/src/hooks/handlers/auto-spawn.ts +++ b/src/hooks/handlers/auto-spawn.ts @@ -15,51 +15,62 @@ import type { HandlerResult, HookPayload } from '../types.js'; +/** Build search names from recipient + directory entry for template matching. */ +function buildSearchNames( + recipient: string, + dirEntry: { entry: { name: string; roles?: string[] } } | null, +): Set { + const names = new Set([recipient]); + if (dirEntry) { + names.add(dirEntry.entry.name); + if (dirEntry.entry.roles) { + for (const role of dirEntry.entry.roles) names.add(role); + } + } + return names; +} + +/** Build genie spawn CLI args from a saved template. */ +function buildSpawnArgs(template: { + provider: string; + team: string; + role?: string; + skill?: string; + cwd?: string; + lastSessionId?: string; + extraArgs?: string[]; +}): string[] { + const args = ['spawn', '--provider', template.provider, '--team', template.team]; + if (template.role) args.push('--role', template.role); + if (template.skill) args.push('--skill', template.skill); + if (template.cwd) args.push('--cwd', template.cwd); + if (template.lastSessionId) args.push('--resume', template.lastSessionId); + if (template.extraArgs) args.push(...template.extraArgs); + return args; +} + export async function autoSpawn(payload: HookPayload): Promise { const input = payload.tool_input; - if (!input) return; - - // Only handle direct messages (not broadcasts, shutdown, etc.) - if (input.type !== 'message') return; + if (!input || input.type !== 'message') return; const recipient = input.recipient as string | undefined; - if (!recipient) return; - - // Don't auto-spawn team-lead (it's the orchestrator, always running) - if (recipient === 'team-lead') return; + if (!recipient || recipient === 'team-lead') return; const teamName = process.env.GENIE_TEAM ?? payload.team_name; if (!teamName) return; try { - // Lazy-import to avoid pulling heavy deps at dispatch startup const registryMod = await import('../../lib/agent-registry.js'); const tmuxMod = await import('../../lib/tmux.js'); const directoryMod = await import('../../lib/agent-directory.js'); - // Check if recipient has a live pane const agents = await registryMod.list(); const existing = agents.find((a) => (a.role === recipient || a.id === recipient) && a.team === teamName); + if (existing && (await tmuxMod.isPaneAlive(existing.paneId))) return; - if (existing && (await tmuxMod.isPaneAlive(existing.paneId))) { - // Agent is alive — nothing to do - return; - } - - // Check agent directory for recipient identity (directory-first) const dirEntry = await directoryMod.resolve(recipient); - - // Check for a saved template to respawn from const templates = await registryMod.listTemplates(); - - // Build search candidates: recipient name + directory entry info - const searchNames = new Set([recipient]); - if (dirEntry) { - searchNames.add(dirEntry.entry.name); - if (dirEntry.entry.roles) { - for (const role of dirEntry.entry.roles) searchNames.add(role); - } - } + const searchNames = buildSearchNames(recipient, dirEntry); const template = templates.find((t) => { if (t.team !== teamName) return false; @@ -68,29 +79,15 @@ export async function autoSpawn(payload: HookPayload): Promise { if (!template) { if (dirEntry) { - // Agent is known but has no spawn template — log for debugging console.error( `[genie-hook] Agent "${recipient}" is registered in directory but has no spawn template in team "${teamName}".`, ); } - // No template — can't auto-spawn, let the message go through anyway - // (CC will show "recipient not found" natively) return; } - // Respawn via genie spawn (non-blocking fork) const { spawnSync } = require('node:child_process') as typeof import('node:child_process'); - const args = ['spawn', '--provider', template.provider, '--team', template.team]; - if (template.role) args.push('--role', template.role); - if (template.skill) args.push('--skill', template.skill); - if (template.cwd) args.push('--cwd', template.cwd); - if (template.lastSessionId) args.push('--resume', template.lastSessionId); - if (template.extraArgs) args.push(...template.extraArgs); - - // Run synchronously with short timeout — we need the pane up before - // CC delivers the message. Uses spawnSync with argv array (no shell) - // to prevent command injection. - spawnSync('genie', args, { + spawnSync('genie', buildSpawnArgs(template), { timeout: 10_000, stdio: 'ignore', env: { ...process.env, GENIE_TEAM: teamName }, @@ -98,11 +95,7 @@ export async function autoSpawn(payload: HookPayload): Promise { console.error(`[genie-hook] Auto-spawned "${recipient}" in team "${teamName}"`); } catch (err) { - // Don't block the message on spawn failure — log and allow const msg = err instanceof Error ? err.message : String(err); console.error(`[genie-hook] Auto-spawn failed for "${recipient}": ${msg}`); } - - // Always allow the message through - return; } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 850afb59d..d18d44698 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -17,7 +17,7 @@ import { autoSpawn } from './handlers/auto-spawn.js'; import { identityInject } from './handlers/identity-inject.js'; -import type { Handler, HookDecision, HookPayload } from './types.js'; +import type { Handler, HandlerResult, HookDecision, HookPayload } from './types.js'; import { isBlockingEvent } from './types.js'; // ============================================================================ @@ -56,40 +56,42 @@ function resolveHandlers(event: string, toolName?: string): Handler[] { .sort((a, b) => a.priority - b.priority); } +/** Run a single handler, returning its result or undefined on error. */ +async function runHandler( + handler: Handler, + payload: HookPayload, + currentInput: Record | undefined, +): Promise { + const handlerPayload: HookPayload = { ...payload }; + if (currentInput) handlerPayload.tool_input = currentInput; + try { + return await handler.fn(handlerPayload); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[genie-hook] Handler "${handler.name}" threw: ${msg}`); + return undefined; + } +} + async function executeBlockingChain(matched: Handler[], payload: HookPayload): Promise { let currentInput = payload.tool_input ? { ...payload.tool_input } : undefined; for (const handler of matched) { - try { - // Build the payload with the (potentially modified) input - const handlerPayload: HookPayload = { ...payload }; - if (currentInput) handlerPayload.tool_input = currentInput; - - const result = await handler.fn(handlerPayload); - if (!result) continue; - - // Short-circuit on deny - if (result.decision === 'deny') { - return { decision: 'deny', reason: result.reason ?? `Denied by handler: ${handler.name}` }; - } - - // Accumulate updatedInput for next handler - if (result.updatedInput) { - currentInput = { ...currentInput, ...result.updatedInput }; - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`[genie-hook] Handler "${handler.name}" threw: ${msg}`); - // Don't block on handler errors — continue chain + const result = await runHandler(handler, payload, currentInput); + if (!result) continue; + + if (result.decision === 'deny') { + return { decision: 'deny', reason: result.reason ?? `Denied by handler: ${handler.name}` }; + } + if (result.updatedInput) { + currentInput = { ...currentInput, ...result.updatedInput }; } } - // If any handler produced updatedInput, return it if (currentInput && payload.tool_input && JSON.stringify(currentInput) !== JSON.stringify(payload.tool_input)) { return { updatedInput: currentInput }; } - // Implicit allow return {}; } diff --git a/src/term-commands/dispatch.ts b/src/term-commands/dispatch.ts index 412f90ed9..192f631c0 100644 --- a/src/term-commands/dispatch.ts +++ b/src/term-commands/dispatch.ts @@ -146,8 +146,8 @@ export function parseWishGroups(content: string): GroupDefinition[] { const groups: GroupDefinition[] = []; const groupPattern = /^### Group (\d+):/gim; - let match: RegExpExecArray | null; - while ((match = groupPattern.exec(content)) !== null) { + let match: RegExpExecArray | null = groupPattern.exec(content); + while (match !== null) { const name = match[1]; const start = match.index; @@ -170,6 +170,7 @@ export function parseWishGroups(content: string): GroupDefinition[] { } groups.push({ name, dependsOn }); + match = groupPattern.exec(content); } return groups; diff --git a/src/term-commands/msg.ts b/src/term-commands/msg.ts index 754ee9369..72e9d64b9 100644 --- a/src/term-commands/msg.ts +++ b/src/term-commands/msg.ts @@ -202,6 +202,18 @@ function printChatMessages(teamName: string, messages: teamChatTypes.ChatMessage console.log(''); } +/** Resolve team name from explicit option, agent lookup, or env var. Exits on failure. */ +async function resolveTeamName(explicit: string | undefined, repoPath: string, from: string): Promise { + if (explicit) return explicit; + const team = await findAgentTeam(repoPath, from); + const name = team?.name ?? process.env.GENIE_TEAM; + if (!name) { + console.error('Error: Could not auto-detect team. Use --team .'); + process.exit(1); + } + return name; +} + // ============================================================================ // Command Registration // ============================================================================ @@ -329,36 +341,18 @@ export function registerSendInboxCommands(program: Command): void { try { const repoPath = process.cwd(); const from = options.from ?? (await detectSenderIdentity()); - - // Determine team name - let teamName = options.team; - if (!teamName) { - const team = await findAgentTeam(repoPath, from); - if (team) { - teamName = team.name; - } else { - teamName = process.env.GENIE_TEAM; - } - if (!teamName) { - console.error('Error: Could not auto-detect team. Use --team .'); - process.exit(1); - } - } + const teamName = await resolveTeamName(options.team, repoPath, from); const teamChat = await getTeamChat(); if (args.length === 0 || args[0] === 'read') { - // Read mode const messages = await teamChat.readMessages(repoPath, teamName, options.since); - if (options.json) { console.log(JSON.stringify(messages, null, 2)); return; } - printChatMessages(teamName, messages); } else { - // Post mode const body = args.join(' '); const msg = await teamChat.postMessage(repoPath, teamName, from, body); console.log(`Posted to "${teamName}" channel.`);