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
4 changes: 4 additions & 0 deletions packages/happy-app/sources/app/(app)/new/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ const agentIcons = {
openclaw: require('@/assets/images/icon-openclaw.png'),
gemini: require('@/assets/images/icon-gemini.png'),
agy: require('@/assets/images/icon-agy.png'),
hermes: require('@/assets/images/icon-hermes.png'),
crush: require('@/assets/images/icon-crush.png'),
};

type AgentKey = NewSessionAgentType;
Expand All @@ -105,6 +107,8 @@ const ALL_AGENTS: { key: AgentKey; label: string }[] = [
{ key: 'codex', label: 'codex' },
{ key: 'openclaw', label: 'openclaw' },
{ key: 'agy', label: 'agy' },
{ key: 'hermes', label: 'hermes' },
{ key: 'crush', label: 'crush' },
Comment on lines +110 to +111

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Define mode defaults before exposing Hermes and Crush

After these new agent keys become selectable, the mode/default helpers still only recognize claude/codex/gemini/openclaw/agy, so Hermes and Crush fall through to Claude defaults and hardcoded Claude model/permission options in the new-session UI. Selecting either backend can show and persist irrelevant Claude choices such as opus, bypassPermissions, and effort levels; add explicit defaults/options for these agents or suppress those controls until backend metadata is available.

Useful? React with 👍 / 👎.

];

type PickerItem = { key: string; label: string; subtitle?: string; dimmed?: boolean };
Expand Down
2 changes: 2 additions & 0 deletions packages/happy-app/sources/app/(app)/settings/agents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ const agentLabels: Record<AgentKey, string> = {
gemini: 'Gemini',
openclaw: 'OpenClaw',
agy: 'Agy',
hermes: 'Hermes',
crush: 'Crush',
};

function optionName(options: ModeOption[], key: string | null | undefined): string {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions packages/happy-app/sources/components/HomeDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ const AGENTS: Array<{ key: NewSessionAgentType; name: string }> = [
{ key: 'openclaw', name: 'OpenClaw' },
{ key: 'gemini', name: 'Gemini' },
{ key: 'agy', name: 'Agy' },
{ key: 'hermes', name: 'Hermes' },
{ key: 'crush', name: 'Crush' },
];

const MOBILE_ICON_MENU_GEOMETRY = resolveMobileComposerMenuGeometry('icon');
Expand Down
11 changes: 11 additions & 0 deletions packages/happy-app/sources/components/modelModeOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ export function getHardcodedPermissionModes(flavor: AgentFlavor, translate: Tran
if (flavor === 'agy') {
return getAgyPermissionModes(translate);
}
// Hermes and Crush report their real permission modes dynamically (ACP
// session config / server events); before a session starts only the
// generic default/bypass pair is offered.
if (flavor === 'hermes' || flavor === 'crush') {
return getOpenClawPermissionModes(translate);
}
return getClaudePermissionModes(translate);
}

Expand Down Expand Up @@ -195,6 +201,11 @@ export function getHardcodedModelModes(flavor: AgentFlavor, _translate: Translat
if (flavor === 'agy') {
return getAgyModelModes();
}
// Hermes and Crush expose their model lists dynamically per session;
// no hardcoded model options are offered before a session starts.
if (flavor === 'hermes' || flavor === 'crush') {
return getOpenClawModelModes();
}
return getClaudeModelModes();
}

Expand Down
11 changes: 9 additions & 2 deletions packages/happy-app/sources/sync/agentDefaults.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as z from 'zod';

export const agentKeys = ['claude', 'codex', 'gemini', 'openclaw', 'agy'] as const;
export const agentKeys = ['claude', 'codex', 'gemini', 'openclaw', 'agy', 'hermes', 'crush'] as const;
export type AgentKey = typeof agentKeys[number];

export const AgentDefaultOverrideSchema = z.object({
Expand All @@ -15,6 +15,8 @@ export const AgentDefaultOverridesSchema = z.object({
gemini: AgentDefaultOverrideSchema.optional(),
openclaw: AgentDefaultOverrideSchema.optional(),
agy: AgentDefaultOverrideSchema.optional(),
hermes: AgentDefaultOverrideSchema.optional(),
crush: AgentDefaultOverrideSchema.optional(),
}).passthrough().default({});

export type AgentDefaultOverride = z.infer<typeof AgentDefaultOverrideSchema>;
Expand All @@ -35,10 +37,15 @@ const codeAgentDefaults: Record<AgentKey, AgentDefaultConfig> = {
gemini: { permissionMode: 'default', modelMode: 'gemini-2.5-pro', effortLevel: null },
openclaw: { permissionMode: 'default', modelMode: 'default', effortLevel: null },
agy: { permissionMode: 'default', modelMode: 'Gemini 3.1 Pro (High)', effortLevel: null },
// Hermes and Crush expose their model/permission options dynamically via
// ACP session config / server events; until those arrive there is no
// meaningful hardcoded default beyond "ask".
hermes: { permissionMode: 'default', modelMode: 'default', effortLevel: null },
crush: { permissionMode: 'default', modelMode: 'default', effortLevel: null },
};

export function normalizeAgentKey(flavor: string | null | undefined): AgentKey {
if (flavor === 'codex' || flavor === 'gemini' || flavor === 'openclaw' || flavor === 'agy') {
if (flavor === 'codex' || flavor === 'gemini' || flavor === 'openclaw' || flavor === 'agy' || flavor === 'hermes' || flavor === 'crush') {
return flavor;
}
return 'claude';
Expand Down
4 changes: 2 additions & 2 deletions packages/happy-app/sources/sync/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export interface SpawnSessionOptions {
directory: string;
approvedNewDirectoryCreation?: boolean;
token?: string;
agent?: 'codex' | 'claude' | 'gemini' | 'openclaw' | 'agy' | 'rig';
agent?: 'codex' | 'claude' | 'gemini' | 'openclaw' | 'agy' | 'hermes' | 'crush' | 'rig';
permissionMode?: string;
modelMode?: string;
effortLevel?: string;
Expand Down Expand Up @@ -268,7 +268,7 @@ export async function machineSpawnNewSession(options: SpawnSessionOptions): Prom
directory: string
approvedNewDirectoryCreation?: boolean,
token?: string,
agent?: 'codex' | 'claude' | 'gemini' | 'openclaw' | 'agy' | 'rig',
agent?: 'codex' | 'claude' | 'gemini' | 'openclaw' | 'agy' | 'hermes' | 'crush' | 'rig',
permissionMode?: string,
modelMode?: string,
effortLevel?: string,
Expand Down
4 changes: 2 additions & 2 deletions packages/happy-app/sources/sync/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const VOICE_SOFT_PAYWALL_SHOWN_KEY = 'voice-soft-paywall-shown';
const VOICE_ONBOARDING_PROMPT_LOAD_COUNT_KEY = 'voice-onboarding-prompt-load-count';
const VOICE_MESSAGE_COUNT_KEY = 'voice-message-count';

export type NewSessionAgentType = 'claude' | 'codex' | 'gemini' | 'openclaw' | 'agy' | 'rig';
export type NewSessionAgentType = 'claude' | 'codex' | 'gemini' | 'openclaw' | 'agy' | 'hermes' | 'crush' | 'rig';
export type NewSessionSessionType = 'simple' | 'worktree';

export interface NewSessionDraft {
Expand Down Expand Up @@ -146,7 +146,7 @@ export function loadNewSessionDraft(): NewSessionDraft | null {
const input = typeof parsed.input === 'string' ? parsed.input : '';
const selectedMachineId = typeof parsed.selectedMachineId === 'string' ? parsed.selectedMachineId : null;
const selectedPath = typeof parsed.selectedPath === 'string' ? parsed.selectedPath : null;
const agentType: NewSessionAgentType = parsed.agentType === 'codex' || parsed.agentType === 'gemini' || parsed.agentType === 'openclaw' || parsed.agentType === 'agy' || parsed.agentType === 'rig'
const agentType: NewSessionAgentType = parsed.agentType === 'codex' || parsed.agentType === 'gemini' || parsed.agentType === 'openclaw' || parsed.agentType === 'agy' || parsed.agentType === 'hermes' || parsed.agentType === 'crush' || parsed.agentType === 'rig'
? parsed.agentType
: 'claude';
const permissionMode: PermissionModeKey | null = typeof parsed.permissionMode === 'string'
Expand Down
2 changes: 2 additions & 0 deletions packages/happy-app/sources/sync/storageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,8 @@ export const MachineMetadataSchema = z.object({
gemini: z.boolean(),
openclaw: z.boolean(),
agy: z.boolean().optional(), // optional: older CLIs don't report agy
hermes: z.boolean().optional(), // optional: older CLIs don't report hermes
crush: z.boolean().optional(), // optional: older CLIs don't report crush
rig: z.boolean().optional(), // Rig runs its own Happy-connected daemon
detectedAt: z.number(),
}).optional(),
Expand Down
2 changes: 2 additions & 0 deletions packages/happy-app/sources/utils/newSessionAgentSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const NEW_SESSION_AGENT_ORDER: readonly NewSessionAgentType[] = [
'openclaw',
'gemini',
'agy',
'hermes',
'crush',
];

type CliAvailability = Partial<Record<NewSessionAgentType, boolean>>;
Expand Down
15 changes: 15 additions & 0 deletions packages/happy-cli/src/agent/acp/AcpSessionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ describe('AcpSessionManager turn lifecycle', () => {
expect(isCuid(envelopes[0].turn!)).toBe(true);
});

it('replaces accumulated output when a fullText model-output arrives', () => {
const mapper = new AcpSessionManager();
mapper.startTurn();
// Accumulate two deltas, then supersede them with an authoritative fullText
mapMany(mapper, [
{ type: 'model-output', textDelta: 'wrong ' },
{ type: 'model-output', textDelta: 'prefix' },
]);
mapper.mapMessage({ type: 'model-output', fullText: 'authoritative answer' });
const ended = mapper.endTurn('completed');

const texts = [...ended,].filter((e) => e.ev.t === 'text').map((e) => (e.ev as { text: string }).text);
expect(texts.join(' ')).toBe('authoritative answer');
});

it('emits completed turn-end from endTurn()', () => {
const mapper = new AcpSessionManager();
const started = mapper.startTurn();
Expand Down
8 changes: 8 additions & 0 deletions packages/happy-cli/src/agent/acp/AcpSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@ export class AcpSessionManager {
}

if (msg.type === 'model-output') {
// An authoritative fullText replaces any accumulated output buffer
// (e.g. a backend that rewrites a cumulative message)
if (msg.fullText !== undefined && msg.textDelta === undefined) {
const flushed = this.pendingType !== 'output' ? this.flush() : [];
this.pendingType = 'output';
this.pendingText = msg.fullText;
return flushed;
}
const text = msg.textDelta ?? '';
if (!text) {
return [];
Expand Down
11 changes: 10 additions & 1 deletion packages/happy-cli/src/agent/acp/acpAgentConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { describe, expect, it } from 'vitest';
import { KNOWN_ACP_AGENTS, resolveAcpAgentConfig } from './acpAgentConfig';

describe('KNOWN_ACP_AGENTS', () => {
it('defines built-in Gemini and OpenCode command mappings', () => {
it('defines built-in Gemini, OpenCode, and Hermes command mappings', () => {
expect(KNOWN_ACP_AGENTS).toEqual({
gemini: { command: 'gemini', args: ['--experimental-acp'] },
opencode: { command: 'opencode', args: ['acp'] },
hermes: { command: 'hermes', args: ['acp'] },
});
});
});
Expand All @@ -27,6 +28,14 @@ describe('resolveAcpAgentConfig', () => {
});
});

it('resolves hermes to the hermes CLI with acp mode', () => {
expect(resolveAcpAgentConfig(['hermes'])).toEqual({
agentName: 'hermes',
command: 'hermes',
args: ['acp'],
});
});

it('strips legacy --acp for opencode compatibility', () => {
expect(resolveAcpAgentConfig(['opencode', '--acp', '--foo'])).toEqual({
agentName: 'opencode',
Expand Down
1 change: 1 addition & 0 deletions packages/happy-cli/src/agent/acp/acpAgentConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type AcpAgentConfig = {
export const KNOWN_ACP_AGENTS: Record<string, AcpAgentConfig> = {
gemini: { command: 'gemini', args: ['--experimental-acp'] },
opencode: { command: 'opencode', args: ['acp'] },
hermes: { command: 'hermes', args: ['acp'] },
};

export type ResolvedAcpAgentConfig = {
Expand Down
Loading