Skip to content
Open
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
1 change: 1 addition & 0 deletions core/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface AgentCreated {
id: number;
folderName?: string;
isExternal?: boolean;
palette?: number;
}

export interface AgentClosed {
Expand Down
2 changes: 2 additions & 0 deletions core/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export type AgentEvent =
/** Working directory the session was started in. Used to match pending
* external sessions against known workspace folders. */
cwd?: string;
/** Character palette index (0-5). Used by the webview to pick a sprite. */
palette?: number;
}
| { kind: 'sessionEnd'; reason?: string };

Expand Down
163 changes: 163 additions & 0 deletions server/__tests__/opencode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest';

import { opencodeProvider } from '../src/providers/hook/opencode/opencode.js';

describe('opencodeProvider', () => {
describe('identity', () => {
it('has kind "hook"', () => {
expect(opencodeProvider.kind).toBe('hook');
});
it('has id "opencode"', () => {
expect(opencodeProvider.id).toBe('opencode');
});
it('has a displayName', () => {
expect(opencodeProvider.displayName).toBe('OpenCode');
});
it('has protocolVersion 1', () => {
expect(opencodeProvider.protocolVersion).toBe(1);
});
it('has reading tools (read/glob/grep/websearch/webfetch)', () => {
for (const tool of ['read', 'glob', 'grep', 'websearch', 'webfetch', 'github-repo-info']) {
expect(opencodeProvider.readingTools.has(tool)).toBe(true);
}
expect(opencodeProvider.readingTools.has('write')).toBe(false);
});
it('has no file fallback (no sessionFilePattern)', () => {
expect(opencodeProvider.sessionFilePattern).toBeUndefined();
});
it('areHooksInstalled returns true (server is always connected)', async () => {
await expect(opencodeProvider.areHooksInstalled()).resolves.toBe(true);
});
});

describe('normalizeHookEvent', () => {
it('returns null when event_type is missing', () => {
expect(opencodeProvider.normalizeHookEvent({ session_id: 'x' })).toBeNull();
});
it('returns null when session_id is missing', () => {
expect(opencodeProvider.normalizeHookEvent({ event_type: 'session.status' })).toBeNull();
});
it('returns null for unknown event types', () => {
expect(
opencodeProvider.normalizeHookEvent({
event_type: 'something.weird',
session_id: 'x',
}),
).toBeNull();
});

it('session.status=busy → sessionStart', () => {
const result = opencodeProvider.normalizeHookEvent({
event_type: 'session.status',
session_id: 'ses-1',
payload: { status: 'busy' },
});
expect(result?.sessionId).toBe('ses-1');
expect(result?.event.kind).toBe('sessionStart');
});

it('session.status=idle → turnEnd', () => {
const result = opencodeProvider.normalizeHookEvent({
event_type: 'session.status',
session_id: 'ses-1',
payload: { status: 'idle' },
});
expect(result?.event.kind).toBe('turnEnd');
});

it('session.status=unknown → null', () => {
expect(
opencodeProvider.normalizeHookEvent({
event_type: 'session.status',
session_id: 'ses-1',
payload: { status: 'unknown' },
}),
).toBeNull();
});

it('message.updated (assistant) → toolStart', () => {
const result = opencodeProvider.normalizeHookEvent({
event_type: 'message.updated',
session_id: 'ses-1',
payload: { role: 'assistant', message_id: 'msg-1', agent: 'plan' },
});
expect(result?.event.kind).toBe('toolStart');
if (result?.event.kind === 'toolStart') {
expect(result.event.toolName).toBe('plan');
expect(result.event.toolId).toBe('msg-1');
}
});

it('message.updated (user) → null (ignored)', () => {
expect(
opencodeProvider.normalizeHookEvent({
event_type: 'message.updated',
session_id: 'ses-1',
payload: { role: 'user' },
}),
).toBeNull();
});

it('tool.start → toolStart with tool_name + input', () => {
const result = opencodeProvider.normalizeHookEvent({
event_type: 'tool.start',
session_id: 'ses-1',
payload: { tool_id: 't1', tool_name: 'github-clone-repo', input: { repo: 'escal-ai-platform' } },
});
expect(result?.event.kind).toBe('toolStart');
if (result?.event.kind === 'toolStart') {
expect(result.event.toolName).toBe('github-clone-repo');
expect(result.event.toolId).toBe('t1');
expect(result.event.input).toEqual({ repo: 'escal-ai-platform' });
}
});

it('tool.end → toolEnd', () => {
const result = opencodeProvider.normalizeHookEvent({
event_type: 'tool.end',
session_id: 'ses-1',
payload: { tool_id: 't1' },
});
expect(result?.event.kind).toBe('toolEnd');
});

it('session.start → sessionStart with source', () => {
const result = opencodeProvider.normalizeHookEvent({
event_type: 'session.start',
session_id: 'ses-1',
payload: { source: 'telegram' },
});
expect(result?.event.kind).toBe('sessionStart');
if (result?.event.kind === 'sessionStart') {
expect(result.event.source).toBe('telegram');
}
});

it('session.end → sessionEnd', () => {
const result = opencodeProvider.normalizeHookEvent({
event_type: 'session.end',
session_id: 'ses-1',
payload: { reason: 'user-cancelled' },
});
expect(result?.event.kind).toBe('sessionEnd');
if (result?.event.kind === 'sessionEnd') {
expect(result.event.reason).toBe('user-cancelled');
}
});
});

describe('formatToolStatus', () => {
it('formats read', () => {
expect(opencodeProvider.formatToolStatus('read')).toBe('Reading file');
});
it('formats github-clone-repo', () => {
expect(opencodeProvider.formatToolStatus('github-clone-repo')).toBe('Cloning repository');
});
it('formats plane-create-ticket', () => {
expect(opencodeProvider.formatToolStatus('plane-create-ticket')).toBe('Creating Plane ticket');
});
it('falls back to "Using X" for unknown tools', () => {
expect(opencodeProvider.formatToolStatus('FancyTool')).toBe('Using FancyTool');
});
});
});
3 changes: 2 additions & 1 deletion server/src/agentRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export class AgentRuntime {

// Wire hook lifecycle callbacks to shared agent operations
this.hookEventHandler.setLifecycleCallbacks({
onExternalSessionDetected: (sessionId, transcriptPath, cwd) => {
onExternalSessionDetected: (sessionId, transcriptPath, cwd, palette) => {
const projectDir = transcriptPath ? path.dirname(transcriptPath) : cwd;
if (!isTrackedProjectDir(projectDir) && !this.watchAllSessions.current) {
return;
Expand All @@ -103,6 +103,7 @@ export class AgentRuntime {
sessionId,
transcriptPath,
cwd,
palette,
this.knownJsonlFiles,
this.store.nextAgentId,
this.store,
Expand Down
10 changes: 6 additions & 4 deletions server/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ import {
loadWallTiles,
} from './assetLoader.js';
import type { AssetCache } from './clientMessageHandler.js';
import { createCompositeProvider } from './compositeProvider.js';
import { FileStateAdapter } from './fileStateAdapter.js';
import { claudeProvider, copyHookScript } from './providers/index.js';
import { claudeProvider, copyHookScript, opencodeProvider } from './providers/index.js';
import { PixelAgentsServer } from './server.js';

// ── Argument parsing ──────────────────────────────────────────
Expand Down Expand Up @@ -89,8 +90,9 @@ async function main(): Promise<void> {
const server = new PixelAgentsServer();

try {
// Create runtime first (before server.start, so we can pass it in)
const runtime = new AgentRuntime(store, claudeProvider);
// Create runtime with combined provider (Claude + OpenCode)
const provider = createCompositeProvider(opencodeProvider, claudeProvider);
const runtime = new AgentRuntime(store, provider);

// Wire hook events: HTTP POST -> runtime -> hookEventHandler -> agents
server.onHookEvent((providerId, event) => {
Expand Down Expand Up @@ -129,7 +131,7 @@ async function main(): Promise<void> {

// Sync runtime refs with persisted settings BEFORE first scan tick
runtime.hooksEnabled.current = adapter.getSetting('pixel-agents.hooksEnabled', true);
runtime.watchAllSessions.current = adapter.getSetting('pixel-agents.watchAllSessions', false);
runtime.watchAllSessions.current = adapter.getSetting('pixel-agents.watchAllSessions', true);

// Install hooks on startup if the persisted setting says so
if (runtime.hooksEnabled.current) {
Expand Down
54 changes: 54 additions & 0 deletions server/src/compositeProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Composite HookProvider that dispatches to multiple providers.
*
* Used in the standalone CLI to support both Claude Code (default) and
* OpenCode (via webhook bridge) simultaneously without modifying the
* core AgentRuntime or HookEventHandler.
*
* The composite delegates by trying each provider's normalizeHookEvent
* in registration order; the first non-null result wins.
* Methods that are provider-specific (getSessionDirs, buildLaunchCommand,
* formatToolStatus, etc.) check the agent's session and route to the
* matching provider.
*/

import type { AgentEvent, HookProvider } from '../../core/src/provider.js';

export function createCompositeProvider(...providers: HookProvider[]): HookProvider {
if (providers.length === 0) throw new Error('At least one provider required');
const primary = providers[0];

return {
kind: 'hook',
id: 'composite',
displayName: providers.map((p) => p.displayName).join(' + '),
protocolVersion: primary.protocolVersion,

normalizeHookEvent(raw: Record<string, unknown>): { sessionId: string; event: AgentEvent } | null {
for (const p of providers) {
const result = p.normalizeHookEvent(raw);
if (result) return result;
}
return null;
},

installHooks: (serverUrl: string, authToken: string) => primary.installHooks(serverUrl, authToken),
uninstallHooks: () => primary.uninstallHooks(),
areHooksInstalled: () => primary.areHooksInstalled(),

formatToolStatus: (toolName: string, input?: unknown) => primary.formatToolStatus(toolName, input),
permissionExemptTools: primary.permissionExemptTools,
subagentToolNames: primary.subagentToolNames,
readingTools: primary.readingTools,
terminalNamePrefix: primary.terminalNamePrefix,

getSessionDirs: (workspacePath: string) => primary.getSessionDirs?.(workspacePath) ?? [],
getAllSessionRoots: () => primary.getAllSessionRoots?.() ?? [],
sessionFilePattern: primary.sessionFilePattern,
parseTranscriptLine: (line: string) => primary.parseTranscriptLine?.(line) ?? null,
buildLaunchCommand: (sessionId: string, cwd: string, opts?: { bypassPermissions?: boolean }) =>
primary.buildLaunchCommand?.(sessionId, cwd, opts) ?? { command: '', args: [], env: { PWD: cwd } },

team: primary.team,
};
}
2 changes: 2 additions & 0 deletions server/src/fileWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,7 @@ export function adoptExternalSessionFromHook(
sessionId: string,
transcriptPath: string | undefined,
cwd: string,
palette: number | undefined,
knownJsonlFiles: Set<string>,
nextAgentIdRef: { current: number },
agents: AgentStateStore,
Expand Down Expand Up @@ -862,6 +863,7 @@ export function adoptExternalSessionFromHook(
terminalRef: undefined,
isExternal: true,
projectDir: cwd,
palette,
jsonlFile: '',
fileOffset: 0,
lineBuffer: '',
Expand Down
3 changes: 3 additions & 0 deletions server/src/hookEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ interface SessionLifecycleCallbacks {
sessionId: string,
transcriptPath: string | undefined,
cwd: string,
palette?: number,
) => void;
/** Called when /clear is detected via hooks (SessionEnd reason=clear + SessionStart source=clear). */
onSessionClear?: (
Expand Down Expand Up @@ -241,6 +242,7 @@ export class HookEventHandler {
sessionId: event.session_id,
transcriptPath,
cwd: cwd ?? '',
palette: normEvent.palette,
});
} else {
if (debug && tracked)
Expand Down Expand Up @@ -273,6 +275,7 @@ export class HookEventHandler {
pending.sessionId,
pending.transcriptPath,
pending.cwd,
pending.palette,
);
// Re-process this event now that the agent exists
this.handleEvent(_providerId, event);
Expand Down
1 change: 1 addition & 0 deletions server/src/httpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ function registerWebSocketRoute(app: FastifyInstance, options: HttpServerOptions
id,
folderName: agent.folderName,
isExternal: agent.isExternal || undefined,
palette: agent.palette,
isTeammate: agent.leadAgentId !== undefined || undefined,
teammateName: agent.agentName,
parentAgentId: agent.leadAgentId,
Expand Down
41 changes: 41 additions & 0 deletions server/src/providers/hook/opencode/opencode-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* OpenCode provider constants.
*
* Tool classification sets for the OpenCode agent runtime.
*/

export const OPENCODE_TERMINAL_NAME_PREFIX = 'opencode';

/** Tools that emit "reading" animation (file examination, search, fetch). */
export const OPENCODE_READING_TOOLS = new Set([
'read',
'glob',
'grep',
'websearch',
'webfetch',
'github-repo-info',
'github-list-branches',
]);

/** Tools that spawn sub-agent characters. */
export const OPENCODE_SUBAGENT_TOOLS = new Set<string>([
'Task',
'Agent',
]);

/** Tools that don't trigger permission timers. */
export const OPENCODE_PERMISSION_EXEMPT_TOOLS = new Set<string>([
'read',
'glob',
'grep',
]);

/** Event types that the bridge sends to the webhook endpoint. */
export const OPENCODE_EVENT_TYPES = {
SESSION_STATUS: 'session.status',
MESSAGE_UPDATED: 'message.updated',
TOOL_START: 'tool.start',
TOOL_END: 'tool.end',
SESSION_START: 'session.start',
SESSION_END: 'session.end',
} as const;
Loading