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
40 changes: 40 additions & 0 deletions src/main/ipc/agentSessionStamps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,46 @@ describe('kiro resumability gating', () => {
})
})

describe('hermes profile-aware resumability', () => {
it('waits for the first turn and persists the exact profile', () => {
ingestAgentSessionStamp(runtime, { ...ev(tid, 'hermes', 'session-start', 'id-1', '/w'), profile: 'work' })
expect(stamps(tid)).toEqual([])
ingestAgentSessionStamp(runtime, { ...ev(tid, 'hermes', 'turn-start', 'id-1', '/w'), profile: 'work' })
expect(stamps(tid)).toEqual([{ agentId: 'hermes', sessionId: 'id-1', cwd: '/w', profile: 'work' }])
})

it('does not let an older finalize clear a newer profile session', () => {
ingestAgentSessionStamp(runtime, { ...ev(tid, 'hermes', 'turn-start', 'new', '/w'), profile: 'work', sourcePid: 22 })
ingestAgentSessionStamp(runtime, { ...ev(tid, 'hermes', 'session-end', 'old', '/w'), profile: 'default', sourcePid: 11 })
expect(stamps(tid)).toEqual([{ agentId: 'hermes', sessionId: 'new', cwd: '/w', profile: 'work' }])
})

it('clears the old stamp when a replacement session starts, then rejects the old finalize', () => {
ingestAgentSessionStamp(runtime, { ...ev(tid, 'hermes', 'turn-start', 'old', '/w'), profile: 'default', sourcePid: 11 })
ingestAgentSessionStamp(runtime, { ...ev(tid, 'hermes', 'session-start', 'new', '/w'), profile: 'work', sourcePid: 22 })
ingestAgentSessionStamp(runtime, { ...ev(tid, 'hermes', 'session-end', 'old', '/w'), profile: 'default', sourcePid: 11 })
expect(stamps(tid)).toEqual([
{ agentId: 'hermes', sessionId: 'old', cwd: '/w', profile: 'default' },
null,
])
})

it('does not let an old presence falling edge clear a newer process stamp', () => {
ingestAgentSessionStamp(runtime, {
...ev(tid, 'hermes', 'turn-start', 'old', '/w'),
profile: 'default', sourcePid: 11, sourceStartedAt: '100',
})
ingestAgentSessionStamp(runtime, {
...ev(tid, 'hermes', 'turn-start', 'new', '/w'),
profile: 'work', sourcePid: 22, sourceStartedAt: '200',
})
clearAgentSessionStamp(tid, 11, '100')
expect(stamps(tid).at(-1)).toEqual({
agentId: 'hermes', sessionId: 'new', cwd: '/w', profile: 'work',
})
})
})

describe('agents whose first sessionId-bearing event is already persisted', () => {
it.each(['codex', 'cursor', 'opencode'] as const)('%s stamps on session-start', (agentId) => {
ingestAgentSessionStamp(runtime, ev(tid, agentId, 'session-start', 'id-1', '/w'))
Expand Down
69 changes: 62 additions & 7 deletions src/main/ipc/agentSessionStamps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
// =============================================================================

import { SHELL_AGENT_SESSION_UPDATE } from '../../shared/ipc-channels'
import type { AgentHookEvent } from '../../shared/agentHooks'
import { normalizeAgentSourceStartedAt, type AgentHookEvent } from '../../shared/agentHooks'
import type { AgentId } from '../../shared/agents'
import type { Runtime } from '../runtime/types'
import type { TerminalAgentSession } from '../../shared/types'
Expand All @@ -47,6 +47,8 @@ const RESUMABLE_FROM_SESSION_START: Record<AgentId, boolean> = {
// already open and on disk when the id arrives — a session killed mid-turn,
// before its Stop, still resumes (pinned live).
grok: true,
// Hermes creates the record before its first persisted user turn.
hermes: false,
// Kiro documents that sessions are saved on each conversation turn; its
// agentSpawn event precedes that first turn, so stamp from prompt submit.
kiro: false,
Expand All @@ -57,6 +59,11 @@ interface StampState {
/** Dedup key of the last SHELL_AGENT_SESSION_UPDATE sent, so an unchanged
* stamp doesn't re-emit (and re-touch renderer panel state). */
key?: string | null
latest?: Pick<TerminalAgentSession, 'agentId' | 'sessionId' | 'profile'> & {
sourcePid?: number
sourceStartedAt?: string
}
hermesHighWater?: bigint
/** Monotonic ingest counter — an async cwd lookup captures it and drops its
* result if a newer event (or a clear) landed while it was in flight. */
seq: number
Expand All @@ -78,7 +85,9 @@ function emit(terminalId: string, session: TerminalAgentSession | null): void {
const ownerWindowId = getTerminalOwner(terminalId)
if (ownerWindowId == null) return
const st = stateFor(terminalId)
const key = session ? `${session.agentId}\0${session.sessionId}\0${session.cwd}` : null
const key = session
? `${session.agentId}\0${session.sessionId}\0${session.cwd}\0${session.profile ?? ''}`
: null
if (st.key === key) return
st.key = key
sendToWindow(ownerWindowId, SHELL_AGENT_SESSION_UPDATE, terminalId, session)
Expand All @@ -102,33 +111,79 @@ export function ingestAgentSessionStamp(runtime: Runtime, event: AgentHookEvent)
const { terminalId } = event
if (event.kind === 'session-title' || event.kind === 'input-submit' || event.kind === 'input-interrupt') return
const st = stateFor(terminalId)
st.seq++
if (event.agentId === 'hermes') {
const startedAt = normalizeAgentSourceStartedAt(event.sourceStartedAt)
if (!startedAt && st.hermesHighWater !== undefined) return
if (startedAt) {
const incoming = BigInt(startedAt)
if (st.hermesHighWater !== undefined && incoming < st.hermesHighWater) return
st.hermesHighWater = incoming
}
}
if (event.kind === 'session-end') {
if (!event.sessionId) return
if (st.latest && (
st.latest.agentId !== event.agentId ||
st.latest.sessionId !== event.sessionId ||
(event.profile !== undefined && st.latest.profile !== event.profile) ||
(event.sourcePid !== undefined && st.latest.sourcePid !== undefined && st.latest.sourcePid !== event.sourcePid) ||
(event.sourceStartedAt !== undefined && st.latest.sourceStartedAt !== undefined && st.latest.sourceStartedAt !== event.sourceStartedAt)
)) return
st.seq++
st.latest = undefined
emit(terminalId, null)
return
}
if (event.sessionId == null) return
if (event.kind === 'session-start' && !RESUMABLE_FROM_SESSION_START[event.agentId]) return
st.seq++
const { agentId, sessionId } = event
const profile = event.profile ? { profile: event.profile } : {}
const identity = {
agentId,
sessionId,
...profile,
...(event.sourcePid ? { sourcePid: event.sourcePid } : {}),
...(event.sourceStartedAt ? { sourceStartedAt: event.sourceStartedAt } : {}),
}
if (event.kind === 'session-start' && !RESUMABLE_FROM_SESSION_START[event.agentId]) {
const previous = st.latest
st.latest = identity
if (agentId === 'hermes' && previous?.agentId === 'hermes' && (
previous.sessionId !== sessionId || previous.profile !== event.profile
)) emit(terminalId, null)
return
}
st.latest = identity
if (event.cwd) {
emit(terminalId, { agentId, sessionId, cwd: event.cwd })
emit(terminalId, { agentId, sessionId, cwd: event.cwd, ...profile })
return
}
const seq = st.seq
void runtime.process
.getCwd(terminalId)
.then((cwd) => {
if (states.get(terminalId)?.seq !== seq) return // superseded while in flight
emit(terminalId, { agentId, sessionId, cwd: cwd ?? '' })
emit(terminalId, { agentId, sessionId, cwd: cwd ?? '', ...profile })
})
.catch(() => { /* runtime gone — no stamp beats a cwd-less guess */ })
}

/** Falling edge: the agent exited while the terminal lives on — nothing to
* resume. Clears the stamp; the next agent run re-stamps via fresh events. */
export function clearAgentSessionStamp(terminalId: string): void {
export function clearAgentSessionStamp(
terminalId: string,
endedAgentPid?: number,
endedAgentStartedAt?: string,
): void {
const st = stateFor(terminalId)
if (endedAgentPid !== undefined && st.latest?.sourcePid !== undefined && st.latest.sourcePid !== endedAgentPid) return
if (
endedAgentStartedAt !== undefined &&
st.latest?.sourceStartedAt !== undefined &&
st.latest.sourceStartedAt !== endedAgentStartedAt
) return
st.seq++
st.latest = undefined
emit(terminalId, null)
}

Expand Down
2 changes: 1 addition & 1 deletion src/main/ipc/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ async function runActivityScan(): Promise<void> {
// kills the poll loop itself, leaving the last stamp persisted —
// exactly "what was running at save time".
if (!agentPresent && prev.previousAgentPresent) {
clearAgentSessionStamp(terminalId)
clearAgentSessionStamp(terminalId, scanned?.endedAgentPid, scanned?.endedAgentStartedAt)
}
}
}),
Expand Down
4 changes: 4 additions & 0 deletions src/main/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ export interface PtyActivity {
activity: TerminalActivity
agentName: string | null
agentPresent: boolean
/** Process identity for a confirmed falling edge. Used to avoid clearing a
* resume stamp belonging to a newer overlapping agent process. */
endedAgentPid?: number
endedAgentStartedAt?: string
}

export interface ProcessHost {
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/assets/agentLogos/hermes.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/renderer/canvas/PanelRelationContextToggle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function openCli(agentId: AgentId): void {
codex: 'codex',
cursor: 'cursor-agent',
grok: 'grok',
hermes: 'hermes',
kiro: 'kiro-cli',
opencode: 'opencode',
}
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/lib/agent/agentLogos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import claudeLogo from '../../assets/agentLogos/claude.svg?url'
import codexLogo from '../../assets/agentLogos/codex.svg?url'
import cursorLogo from '../../assets/agentLogos/cursor.svg?url'
import grokLogo from '../../assets/agentLogos/grok.svg?url'
import hermesLogo from '../../assets/agentLogos/hermes.svg?url'
import kiroLogo from '../../assets/agentLogos/kiro.svg?url'
import opencodeLogo from '../../assets/agentLogos/opencode.svg?url'

Expand All @@ -18,6 +19,7 @@ const LOGO_BY_ID: Partial<Record<AgentId, string>> = {
codex: codexLogo,
cursor: cursorLogo,
grok: grokLogo,
hermes: hermesLogo,
kiro: kiroLogo,
opencode: opencodeLogo,
}
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/lib/agent/agentStatus.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ const fixtures: AgentLifecycleFixture[] = [
turnStart: { hookEventName: 'user_prompt_submit', sessionId: SESSION },
turnEnd: { hookEventName: 'stop', sessionId: SESSION },
},
{
agentId: 'hermes',
sessionStart: { hook_event_name: 'on_session_start', session_id: SESSION, profile: 'default', platform: 'cli' },
turnStart: { hook_event_name: 'pre_llm_call', session_id: SESSION, profile: 'default', platform: 'cli' },
turnEnd: { hook_event_name: 'on_session_end', session_id: SESSION, profile: 'default', platform: 'cli' },
},
{
agentId: 'opencode',
sessionStart: { type: 'session.created', sessionID: SESSION },
Expand Down Expand Up @@ -98,6 +104,13 @@ const permissionFixtures: PermissionFixture[] = [
sessionId: SESSION,
},
},
{
agentId: 'hermes',
turnStart: { hook_event_name: 'pre_llm_call', session_id: SESSION, profile: 'default', platform: 'cli' },
permissionWait: {
hook_event_name: 'pre_approval_request', session_id: SESSION, profile: 'default', platform: 'cli',
},
},
{
agentId: 'opencode',
turnStart: { type: 'session.status', sessionID: SESSION, status: { type: 'busy' } },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,10 @@ describe('workspace.json + session.json round-trip', () => {
it('round-trips a terminal agent-session stamp through session.json only', () => {
const { snapshot } = buildSnapshot()
const agentSession = {
agentId: 'claude-code',
agentId: 'hermes',
sessionId: '11111111-1111-4111-8111-111111111111',
cwd: WORKTREE_PATH,
profile: 'work',
}
snapshot.panels!['term-1'] = { ...snapshot.panels!['term-1'], agentSession }

Expand Down
4 changes: 3 additions & 1 deletion src/renderer/panels/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,9 @@ export default function TerminalPanel({
const agentSession = useAppStore.getState().workspaces
.find((w) => w.id === workspaceId)?.panels[panelId]?.agentSession
const resumeCommand = agentSession
? resumeCommandForAgent(agentSession.agentId, agentSession.sessionId) ?? undefined
? resumeCommandForAgent(agentSession.agentId, agentSession.sessionId, {
profile: agentSession.profile,
}) ?? undefined
: undefined

// Keep the dead xterm visible behind the runtime lock while disconnected.
Expand Down
9 changes: 5 additions & 4 deletions src/renderer/settings/AgentHooksSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// =============================================================================
// Agent hooks settings — per-workspace, per-agent control over Cate's hook
// injection (the push-based agent status/session events). Every agent injects
// through workspace files, so every agent gets the same tri-state: Auto (inject
// only when the agent's own config folder is already in the repo), On, or Off.
// injection (the push-based agent status/session events). Most agents inject
// through workspace files; profile-scoped integrations are installed by the
// runtime host. Both use the same tri-state: Auto (enable when the agent's own
// config folder is already in the repo), On, or Off.
//
// Overrides live in settings.agentHookInjection keyed by workspace id and are
// applied by the terminal layer on the NEXT terminal spawn (injection is a
Expand Down Expand Up @@ -74,7 +75,7 @@ export function AgentHooksSettings() {
}

return (
<SearchableBlock keywords="agent hooks injection claude codex cursor grok kiro opencode status presence auto on off">
<SearchableBlock keywords="agent hooks injection claude codex cursor grok hermes kiro opencode status presence auto on off">
{agents === null && <LoadingState label="Loading agent hooks…" size={14} className="justify-start py-3 text-xs" />}
{error && <p role="alert" className="py-3 text-xs text-muted">Could not check agent hooks. Reopen settings to try again.</p>}
{!!agents?.length && <div>
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/settings/AgentSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export function AgentSettings() {
}, [authSession?.phase, authSession?.providerId, refreshProviderStatuses])

return (
<SearchableBlock keywords="t3 code agent providers models sign in authentication codex claude cursor grok opencode kiro hooks activity status advanced display name accent color binary path home launch arguments custom models environment variables server password endpoint auto-compact updates archive chats merge generated titles">
<SearchableBlock keywords="t3 code agent providers models sign in authentication codex claude cursor grok hermes opencode kiro hooks activity status advanced display name accent color binary path home launch arguments custom models environment variables server password endpoint auto-compact updates archive chats merge generated titles">
<div className="flex flex-col gap-4">
<AgentProviderConfiguration workspaceId={workspaceId} cwd={cwd} onChanged={refreshProviderStatuses} authentication={(driver) => (
<div>
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/stores/appStore/panelSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,8 @@ export function createPanelSlice(set: AppSet, get: AppGet): PanelSliceActions {
session && prev &&
prev.agentId === session.agentId &&
prev.sessionId === session.sessionId &&
prev.cwd === session.cwd
prev.cwd === session.cwd &&
prev.profile === session.profile
) return panel
return { ...panel, agentSession: session ?? undefined }
})
Expand Down
Loading
Loading