Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,5 @@
"additionalProperties": false,
"properties": {}
},
"skills": [
"./plugins/genie/skills"
]
"skills": ["./plugins/genie/skills"]
}
8 changes: 2 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,13 @@
"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"
},
"publishConfig": {
"access": "public"
},
"trustedDependencies": [
"@biomejs/biome"
]
"trustedDependencies": ["@biomejs/biome"]
}
9 changes: 1 addition & 8 deletions plugins/genie/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
46 changes: 21 additions & 25 deletions src/genie-commands/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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) {
Expand All @@ -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 }),
);
}
}

Expand Down
42 changes: 22 additions & 20 deletions src/genie-commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,27 @@ async function resolveGlobalPkgDir(installType: InstallationType): Promise<strin
return null;
}

/** Update the installed_plugins.json registry entry for genie. */
function updatePluginRegistry(claudePlugins: string, cacheDir: string, version: string): void {
const registryPath = join(claudePlugins, 'installed_plugins.json');
try {
if (!existsSync(registryPath)) return;
const registry = JSON.parse(readFileSync(registryPath, 'utf-8'));
const entries = registry.plugins?.['genie@automagik'];
if (!Array.isArray(entries)) return;
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}`);
}
}

async function syncPlugin(installType: InstallationType): Promise<void> {
log('Syncing Claude Code plugin...');

Expand Down Expand Up @@ -379,26 +400,7 @@ async function syncPlugin(installType: InstallationType): Promise<void> {
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}`);
}
Expand Down
85 changes: 39 additions & 46 deletions src/hooks/handlers/auto-spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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<HandlerResult> {
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;
Expand All @@ -68,41 +79,23 @@ export async function autoSpawn(payload: HookPayload): Promise<HandlerResult> {

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 },
});

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;
}
50 changes: 26 additions & 24 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

// ============================================================================
Expand Down Expand Up @@ -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<string, unknown> | undefined,
): Promise<HandlerResult> {
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<HookDecision> {
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 {};
}

Expand Down
5 changes: 3 additions & 2 deletions src/term-commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -170,6 +170,7 @@ export function parseWishGroups(content: string): GroupDefinition[] {
}

groups.push({ name, dependsOn });
match = groupPattern.exec(content);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The addition of match = groupPattern.exec(content); inside the while loop is a critical fix. Without this line, the loop would become infinite after the first match if groupPattern.exec(content) initially returned a non-null value, as the match variable would never be updated for subsequent iterations.

}

return groups;
Expand Down
Loading
Loading