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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Freshell is a self-hosted, browser-accessible terminal multiplexer and session o
- Before creating a new worktree, ensure the repo-supported test suite is green on the intended base. If the suite is not green, pause before creating the worktree and notify the user with the failing command and failure summary.
- New behavior changes start on a worktree branch from `origin/main` and are submitted as PRs targeting `main`.
- Do not create or open a PR until the user explicitly approves PR creation for that branch/change. Preparing a branch, committing locally, and pushing the branch is fine; stop before `gh pr create` or any equivalent PR creation step unless approval is explicit.
- Use `dan@danshapiro.com` when using `gh`.
- Everything goes through a PR — never push behavior changes directly to `origin/main`.
- Merge PRs once their required checks pass, then bring `origin/main` down to local `main`. Self-merging your own PRs is the norm. The only exception is a PR the user has said needs someone else to approve it first — leave those unmerged.
- Many agents may be working in the worktree at the same time. If you see activity from other agents (for example test runs or file changes), respect it.
Expand Down
11 changes: 11 additions & 0 deletions server/fresh-agent/adapters/opencode/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,16 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen
state.events.emit('event', { type: 'sdk.session.snapshot', sessionId: state.placeholderId, status })
}

function emitMaterialized(state: OpencodeSessionState): void {
if (!state.realSessionId) return
state.events.emit('event', {
type: 'freshAgent.session.materialized',
previousSessionId: state.placeholderId,
sessionId: state.realSessionId,
sessionRef: { provider: 'opencode', sessionId: state.realSessionId },
})
}

async function materializeOrSend(state: OpencodeSessionState, text: string, settings?: Partial<FreshAgentCreateRequest>): Promise<FreshAgentSendResult> {
const normalized = settings
? normalizeOpencodeInput({ requestId: state.placeholderId, sessionType: 'freshopencode', provider: 'opencode', ...settings } as FreshAgentCreateRequest)
Expand All @@ -173,6 +183,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen
else if (effectiveCwd) state.cwd = effectiveCwd
remember(state)
bindServeStream(state)
emitMaterialized(state)
}

const realId = state.realSessionId!
Expand Down
6 changes: 6 additions & 0 deletions server/fresh-agent/sdk-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export type FreshAgentProviderEvent =
sessionId: string
reason?: string
}
| {
type: 'freshAgent.session.materialized'
previousSessionId: string
sessionId: string
sessionRef?: { provider: string; sessionId: string }
}
| { type: 'freshAgent.session.init'; sessionId: string; cliSessionId?: string; model?: string; cwd?: string; tools?: Array<{ name: string }> }
| { type: 'freshAgent.session.metadata'; sessionId: string; cliSessionId?: string; model?: string; cwd?: string; tools?: Array<{ name: string }> }
| { type: 'freshAgent.assistant'; sessionId: string; content: ContentBlock[]; model?: string; usage?: Usage }
Expand Down
40 changes: 39 additions & 1 deletion server/ws-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ import {
UiScreenshotResultSchema,
WS_PROTOCOL_VERSION,
} from '../shared/ws-protocol.js'
import { LiveTerminalHandleSchema, type RestoreError } from '../shared/session-contract.js'
import { LiveTerminalHandleSchema, sanitizeSessionRef, type RestoreError } from '../shared/session-contract.js'
import { CODEX_DURABILITY_SCHEMA_VERSION, CodexDurabilityRefSchema } from '../shared/codex-durability.js'
import { migrateLegacyFreshAgentContent } from '../shared/fresh-agent.js'
import { UiLayoutSyncSchema } from './agent-api/layout-schema.js'
Expand Down Expand Up @@ -1190,6 +1190,28 @@ export class WsHandler {
}
}

private freshAgentMaterializedMessage(locator: FreshAgentLocator, event: unknown) {
const normalized = normalizeFreshAgentProviderEvent(event)
if (!normalized || typeof normalized !== 'object' || Array.isArray(normalized)) return undefined
const materialized = normalized as Record<string, unknown>
if (materialized.type !== 'freshAgent.session.materialized') return undefined
if (typeof materialized.sessionId !== 'string' || materialized.sessionId.length === 0) return undefined
const sessionRef = sanitizeSessionRef(materialized.sessionRef) ?? {
provider: locator.provider,
sessionId: materialized.sessionId,
}
return {
type: 'freshAgent.session.materialized',
previousSessionId: typeof materialized.previousSessionId === 'string' && materialized.previousSessionId.length > 0
? materialized.previousSessionId
: locator.sessionId,
sessionId: materialized.sessionId,
sessionType: locator.sessionType,
provider: locator.provider,
sessionRef,
}
}

private authorizeFreshAgentSession(state: ClientState, locator: FreshAgentLocator): void {
state.freshAgentAuthorizations.add(this.freshAgentKey(locator))
}
Expand Down Expand Up @@ -1257,6 +1279,21 @@ export class WsHandler {

const listener = (event: unknown) => {
if (!entry.active) return
const materialized = this.freshAgentMaterializedMessage(locator, event)
if (materialized) {
const materializedLocator = {
sessionId: materialized.sessionId,
sessionType: locator.sessionType,
provider: locator.provider,
}
this.authorizeFreshAgentSession(state, materializedLocator)
this.ensureFreshAgentSubscription(ws, state, materializedLocator)
if (materialized.sessionId !== locator.sessionId) {
this.cancelFreshAgentSubscription(state, locator)
}
this.safeSend(ws, materialized)
return
}
this.safeSend(ws, this.freshAgentEventMessage(locator, event))
}

Expand Down Expand Up @@ -3206,6 +3243,7 @@ export class WsHandler {
sessionType: m.sessionType,
provider: m.provider,
})
this.cancelFreshAgentSubscription(state, locator)
this.send(ws, {
type: 'freshAgent.session.materialized',
previousSessionId: m.sessionId,
Expand Down
24 changes: 23 additions & 1 deletion src/lib/fresh-agent-ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { AppDispatch } from '@/store/store'
import type { FreshAgentRuntimeProvider, FreshAgentSessionType } from '@shared/fresh-agent'
import type { SessionRef } from '@shared/session-contract'
import { consumeCancelledCreate } from '@/lib/create-cancellation'
import { flushPersistedLayoutNow } from '@/store/persistControl'
import { materializeFreshAgentSession as materializeFreshAgentPaneSession } from '@/store/panesSlice'
import {
addAssistantMessage,
addPermissionRequest,
Expand All @@ -10,6 +12,7 @@ import {
clearPendingCreateFailure,
createFailed,
markSessionLost,
materializeSession as materializeFreshAgentSessionState,
removePermission,
removeSession,
registerPendingCreate,
Expand Down Expand Up @@ -129,8 +132,27 @@ export function handleFreshAgentMessage(dispatch: AppDispatch, msg: Record<strin
}))
return true
}
case 'freshAgent.session.materialized':
case 'freshAgent.session.materialized': {
const materialized = msg as FreshAgentSessionMaterializedMessage
dispatch(materializeFreshAgentSessionState({
previousSessionId: materialized.previousSessionId,
sessionId: materialized.sessionId,
sessionType: materialized.sessionType,
provider: materialized.provider,
}))
dispatch(materializeFreshAgentPaneSession({
previousSessionId: materialized.previousSessionId,
sessionId: materialized.sessionId,
sessionType: materialized.sessionType,
provider: materialized.provider,
sessionRef: materialized.sessionRef ?? {
provider: materialized.provider,
sessionId: materialized.sessionId,
},
}))
dispatch(flushPersistedLayoutNow())
return true
}
case 'freshAgent.killed': {
const killed = msg as FreshAgentKilledMessage
dispatch(removeSession({
Expand Down
49 changes: 49 additions & 0 deletions src/store/freshAgentSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ type FreshAgentSessionPayload = {
provider: FreshAgentRuntimeProvider
}

type FreshAgentSessionMaterializedPayload = FreshAgentSessionPayload & {
previousSessionId: string
}

type SessionMutationPayload = {
sessionId: string
sessionType?: FreshAgentSessionType
Expand Down Expand Up @@ -311,6 +315,50 @@ const freshAgentSlice = createSlice({
}
},

materializeSession(state, action: PayloadAction<FreshAgentSessionMaterializedPayload>) {
const previousLocator = {
sessionId: action.payload.previousSessionId,
sessionType: action.payload.sessionType,
provider: action.payload.provider,
}
const nextLocator = {
sessionId: action.payload.sessionId,
sessionType: action.payload.sessionType,
provider: action.payload.provider,
}
const previousKey = sessionKey(previousLocator)
const nextKey = sessionKey(nextLocator)
const previousSession = state.sessions[previousKey]
const nextSession = state.sessions[nextKey]

if (previousSession || nextSession) {
state.sessions[nextKey] = {
...(previousSession ?? createSession(nextLocator, 'connected')),
...(nextSession ?? {}),
...nextLocator,
sessionKey: nextKey,
threadId: action.payload.sessionId,
lost: false,
restoreFailureCode: undefined,
restoreFailureMessage: undefined,
}
if (previousKey !== nextKey) {
delete state.sessions[previousKey]
}
} else {
ensureSession(state, nextLocator, 'connected')
}

for (const pending of Object.values(state.pendingCreates)) {
if (pending.sessionId === action.payload.previousSessionId || pending.sessionKey === previousKey) {
pending.sessionId = action.payload.sessionId
pending.sessionKey = nextKey
pending.sessionType = action.payload.sessionType
pending.provider = action.payload.provider
}
}
},

freshAgentSnapshotReceived(state, action: PayloadAction<{ snapshot: FreshAgentSnapshot }>) {
const snapshot = action.payload.snapshot
const session = ensureSession(state, {
Expand Down Expand Up @@ -558,6 +606,7 @@ export const {
clearStreaming,
createFailed,
freshAgentSnapshotReceived,
materializeSession,
markSessionLost,
registerPendingCreate,
removePermission,
Expand Down
73 changes: 73 additions & 0 deletions src/store/panesSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import {
normalizeFreshAgentEffortOverride,
normalizeFreshAgentModelSelection,
normalizeFreshAgentPendingLocalEcho,
type FreshAgentPaneContent,
type LivePaneContentInput,
type PanesState,
type PaneContent,
type PaneContentInput,
type PaneNode,
type PaneRefreshRequest,
type SessionLocator,
type TerminalPaneContent,
} from './paneTypes'
import { derivePaneTitle } from '@/lib/derivePaneTitle'
Expand All @@ -33,6 +35,14 @@ type HydratePanesMeta = {
remoteLayoutPersistedAt?: number
}

type FreshAgentSessionMaterializedPayload = {
previousSessionId: string
sessionId: string
sessionType: FreshAgentPaneContent['sessionType']
provider: FreshAgentPaneContent['provider']
sessionRef?: SessionLocator
}

function buildPreservedSessionRef(
localContent: Extract<PaneContent, { kind: 'terminal' | 'fresh-agent' }>,
_preservedResumeSessionId?: string,
Expand Down Expand Up @@ -512,6 +522,38 @@ function clearRestoreFallbackAttemptForPane(state: PanesState, tabId: string, pa
}
}

function freshAgentPaneMatchesMaterializedSession(
content: FreshAgentPaneContent,
materialized: FreshAgentSessionMaterializedPayload,
): boolean {
if (content.sessionType !== materialized.sessionType || content.provider !== materialized.provider) {
return false
}

return [
content.sessionId,
content.resumeSessionId,
content.sessionRef?.sessionId,
].some((sessionId) => sessionId === materialized.previousSessionId || sessionId === materialized.sessionId)
}

function buildMaterializedFreshAgentContent(
content: FreshAgentPaneContent,
materialized: FreshAgentSessionMaterializedPayload,
): FreshAgentPaneContent {
const sessionRef = sanitizeSessionRef(materialized.sessionRef) ?? {
provider: materialized.provider,
sessionId: materialized.sessionId,
}
return normalizePaneContent({
...content,
sessionId: materialized.sessionId,
resumeSessionId: materialized.sessionId,
sessionRef,
restoreError: undefined,
}, content) as FreshAgentPaneContent
}

function sessionRefsEqual(left?: { provider?: string; sessionId?: string }, right?: { provider?: string; sessionId?: string }): boolean {
return left?.provider === right?.provider && left?.sessionId === right?.sessionId
}
Expand Down Expand Up @@ -1282,6 +1324,36 @@ export const panesSlice = createSlice({
reconcileRefreshRequestsForTab(state, tabId)
},

materializeFreshAgentSession: (
state,
action: PayloadAction<FreshAgentSessionMaterializedPayload>
) => {
for (const [tabId, root] of Object.entries(state.layouts)) {
let changed = false

function updateContent(node: PaneNode): PaneNode {
if (node.type === 'leaf') {
if (node.content.kind !== 'fresh-agent') return node
if (!freshAgentPaneMatchesMaterializedSession(node.content, action.payload)) return node
changed = true
return {
...node,
content: buildMaterializedFreshAgentContent(node.content, action.payload),
}
}
return {
...node,
children: [updateContent(node.children[0]), updateContent(node.children[1])],
}
}

state.layouts[tabId] = updateContent(root)
if (changed) {
reconcileRefreshRequestsForTab(state, tabId)
}
}
},

/** Partially merge fields into existing pane content (avoids stale-ref overwrites
* when multiple effects dispatch in the same render batch). */
mergePaneContent: (
Expand Down Expand Up @@ -1729,6 +1801,7 @@ export const {
replacePane,
swapPanes,
updatePaneContent,
materializeFreshAgentSession,
mergePaneContent,
restartFreshAgentCreate,
requestPaneRefresh,
Expand Down
Loading
Loading