Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
376ff12
feat(fresh-agent): add mono style
codex Jun 17, 2026
76d0f58
fix(fresh-agent): make mono style theme responsive
codex Jun 17, 2026
ae1e2fa
fix(fresh-agent): align mono style with Claude Code
codex Jun 17, 2026
1da4ce4
fix(fresh-agent): keep mono send button contrasted
codex Jun 17, 2026
9a1db1f
fix(fresh-agent): keep serif send button contrasted
codex Jun 17, 2026
1faf3d3
test(fresh-agent): cover styled send button contrast
codex Jun 17, 2026
26e2c8c
Merge pull request #443 from danshapiro/feat/fresh-agent-mono-style
danshapiro Jun 17, 2026
1898a0c
feat(electron): ctrl/shift+click opens links in system browser (#444)
danshapiro Jun 18, 2026
69f4a1f
fix: allow codex update prompt skip during startup
codex Jun 18, 2026
410d7f3
Merge pull request #445 from danshapiro/fix-codex-update-skip
danshapiro Jun 18, 2026
6dcb978
Add GLM 5.2 to freshopencode model options (#447)
danshapiro Jun 19, 2026
9383c2f
Fix mobile long-press menu release
danshapiro Jun 19, 2026
45efc52
Fix fresh agent cwd routing (#450)
danshapiro Jun 19, 2026
8c68ff4
fix: stop opencode transcript updates marking fresh agent busy
codex Jun 19, 2026
d3c2b28
fix: clear freshopencode idle when opencode status drops
codex Jun 19, 2026
6c57ad3
Fix opencode idle poll reset
codex Jun 19, 2026
3e40b51
test: cover freshopencode late change invalidation
codex Jun 19, 2026
a46fe7a
Fix freshopencode onceIdle fallback gating
codex Jun 19, 2026
20d6271
Merge pull request #451 from danshapiro/fix/freshopencode-bouncer
danshapiro Jun 19, 2026
383e8f5
feat: add fresh agent display turn helpers
codex Jun 19, 2026
049dcdd
fix: preserve empty text item in turn text helper
codex Jun 19, 2026
fe31b3d
fix: normalize codex provider turns into stable display turns
codex Jun 19, 2026
d40a609
fix(fresh-agent): keep text item id in turn text predicate
codex Jun 19, 2026
d809abe
fix: require codex display turn ids and secrets
codex Jun 19, 2026
c6d9384
fix: map codex display bodies through exact provider turns
codex Jun 19, 2026
5189eb6
fix: paginate codex transcript history by display rows
codex Jun 19, 2026
056ffc7
fix: distinguish stale codex display body reads
codex Jun 19, 2026
b85e67c
fix: stop codex cursor refetch after final turn
codex Jun 19, 2026
6c3a5c1
fix: enforce one-speaker provider turns
codex Jun 19, 2026
4d7bf5f
fix: consume normalized fresh agent display turns
codex Jun 19, 2026
e8505eb
test: assert codex durable user messages in real contract
codex Jun 19, 2026
c45baa1
fix: stabilize fresh agent session selector
codex Jun 19, 2026
1fe1a2d
test: compare codex display body contract fields
codex Jun 19, 2026
0bbf3e5
fix: clean up fresh eyes transcript findings
codex Jun 19, 2026
92428ee
fix: prune checkpoint metadata and label matching
codex Jun 19, 2026
67f7531
fix: recover stale checkpoint turn links
codex Jun 19, 2026
5675f08
Merge pull request #452 from danshapiro/feature/fresh-agent-transcrip…
danshapiro Jun 19, 2026
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
7 changes: 5 additions & 2 deletions .github/workflows/electron-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ on:
jobs:
build:
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
Expand All @@ -31,13 +32,15 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Run tests
run: npm test
- name: Run Electron tests
run: npm run test:electron

- name: Build Electron app
if: matrix.os != 'windows-latest'
run: npm run electron:build

- name: Upload artifacts
if: matrix.os != 'windows-latest'
uses: actions/upload-artifact@v4
with:
name: electron-${{ matrix.os }}
Expand Down
4 changes: 2 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,7 @@
.fresh-rail-line { font-size: 13px; line-height: 1.45; overflow-wrap: anywhere; color: hsl(var(--foreground)); }
.fresh-style-control { display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 1px solid hsl(var(--border)); border-radius: 6px; background: hsl(var(--background) / .78); padding: 6px 8px; }
.fresh-style-control span { color: hsl(var(--muted-foreground)); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; }
.fresh-style-control strong { color: hsl(var(--foreground)); font-family: Georgia, Cambria, 'Times New Roman', Times, serif; font-size: 13px; font-weight: 650; }
.fresh-style-control strong { color: hsl(var(--foreground)); font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; font-size: 13px; font-weight: 650; }

@media (max-width: 768px) {
.fresh-mock { display: flex; flex-direction: column; }
Expand Down Expand Up @@ -691,7 +691,7 @@ <h2>Task Board</h2>
</div>
</div>
<aside class="fresh-rail">
<div class="fresh-rail-section"><div class="fresh-rail-title">Settings</div><div class="fresh-style-control" aria-label="Fresh Agent style setting"><span>Style</span><strong>Serif</strong></div></div>
<div class="fresh-rail-section"><div class="fresh-rail-title">Settings</div><div class="fresh-style-control" aria-label="Fresh Agent style setting"><span>Style</span><strong>Mono</strong></div></div>
<div class="fresh-rail-section"><div class="fresh-rail-title">Review</div><div class="fresh-rail-line">pending</div><div class="fresh-rail-line">review-responsive-display</div></div>
<div class="fresh-rail-section"><div class="fresh-rail-title">Fork lineage</div><div class="fresh-rail-line">parent-thread-with-long-identifier</div></div>
<div class="fresh-rail-section"><div class="fresh-rail-title">Worktrees</div><div class="fresh-rail-line">codex/fresh-client-responsive-display</div></div>
Expand Down
1,053 changes: 1,053 additions & 0 deletions docs/superpowers/plans/2026-06-19-freshopencode-cwd-scoped-serve.md

Large diffs are not rendered by default.

52 changes: 50 additions & 2 deletions electron/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// Run: electron dist/electron/electron/entry.js
// (or via electron-builder's packaged app)

import { app, BrowserWindow, globalShortcut, ipcMain, Tray, Menu, nativeImage } from 'electron'
import { app, BrowserWindow, globalShortcut, ipcMain, Tray, Menu, nativeImage, shell } from 'electron'
import path from 'path'
import os from 'os'
import http from 'http'
Expand Down Expand Up @@ -36,6 +36,7 @@ import { createChooseLaunchOptionHandler } from './launch-choice-handler.js'
import { buildLaunchOptions } from './launch-options.js'
import { applyProvisioningFile } from './desktop-provisioning.js'
import { createPortAvailabilityCheck } from './port-check.js'
import { registerOpenExternalHandler } from './external-url.js'
import type { ForcedLaunch, LaunchServerCandidate } from './types.js'

const isPortAvailable = createPortAvailabilityCheck()
Expand Down Expand Up @@ -275,12 +276,54 @@ async function main(): Promise<void> {
ipcMain.removeHandler('install-update')
ipcMain.removeHandler('get-launch-options')
ipcMain.removeHandler('choose-launch-option')
ipcMain.removeHandler('open-external-url')

let pendingLaunchChooser: { candidates: LaunchServerCandidate[]; reason: string } | undefined
// webContents id of the launch chooser window, so choose-launch-option only
// webContents id of the launch window, so choose-launch-option only
// honors requests originating from it (the API is exposed to every window).
let chooserWebContentsId: number | undefined

// Identity of the main Freshell window (webContents id + expected origin).
// The open-external-url handler only honors requests from this window and
// origin so other renderer surfaces or navigations cannot drive
// shell.openExternal.
let mainWebContentsId: number | undefined = undefined
let mainServerUrl: string | undefined = undefined

function getExpectedOrigin(): string | undefined {
if (!mainServerUrl) return undefined
try {
return new URL(mainServerUrl).origin
} catch {
return undefined
}
}

// Register system-browser link handler.
registerOpenExternalHandler({
ipcMain,
shell,
isAllowedSender: (event) => {
const typed = event as {
sender?: { id?: number }
senderFrame?: { url?: string }
}
const senderId = typed.sender?.id
if (mainWebContentsId === undefined || senderId !== mainWebContentsId) {
return false
}
const expectedOrigin = getExpectedOrigin()
if (!expectedOrigin) return false
const frameUrl = typed.senderFrame?.url
if (!frameUrl) return false
try {
return new URL(frameUrl).origin === expectedOrigin
} catch {
return false
}
},
})

// Register the complete-setup handler before runStartup so it is available
// when the wizard renderer calls it via the preload API.
ipcMain.handle('complete-setup', async (_event, config: {
Expand Down Expand Up @@ -426,6 +469,11 @@ async function main(): Promise<void> {
// consolidated window-all-closed handler can quit when appropriate.
wizardPhase = false

// Remember the main window's webContents id and origin so privileged IPC
// handlers can verify requests originate from the trusted renderer.
mainWebContentsId = (result.window as unknown as BrowserWindow).webContents?.id
mainServerUrl = result.serverUrl

// Initialize the main process lifecycle (single-instance, close-to-tray, etc.)
await initMainProcess({
app,
Expand Down
44 changes: 44 additions & 0 deletions electron/external-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Electron main-process helpers for opening URLs in the system browser.

const ALLOWED_PROTOCOLS = ['http:', 'https:']
const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/

function canonicalizeExternalUrl(url: string): URL | null {
if (typeof url !== 'string') return null
// Reject control characters and other shell-dangerous whitespace.
if (CONTROL_CHAR_RE.test(url)) return null
let parsed: URL
try {
parsed = new URL(url)
} catch {
return null
}
if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) return null
// Reject URLs that smuggle credentials, which could confuse users or the OS.
if (parsed.username || parsed.password) return null
return parsed
}

export interface ExternalUrlDeps {
ipcMain: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handle(channel: string, listener: (event: any, url: string) => Promise<void>): void
}
shell: {
openExternal(url: string): Promise<void>
}
isAllowedSender: (event: { sender?: { id?: number } }) => boolean
}

export function registerOpenExternalHandler(deps: ExternalUrlDeps): void {
deps.ipcMain.handle('open-external-url', async (event, url) => {
if (!deps.isAllowedSender(event)) {
throw new Error(`open-external-url rejected: sender not allowed`)
}
const canonical = canonicalizeExternalUrl(url)
if (!canonical) {
throw new Error(`open-external-url rejected: only canonical absolute http/https URLs are allowed, got ${JSON.stringify(url)}`)
}
await deps.shell.openExternal(canonical.toString())
})
}
2 changes: 2 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface FreshellDesktopApi {
completeSetup: (config: WizardSetupConfig) => Promise<void>
getLaunchOptions: () => Promise<any>
chooseLaunchOption: (choice: LaunchChoice) => Promise<LaunchChoiceResult>
openExternal: (url: string) => Promise<void>
}

export interface ContextBridgeApi {
Expand Down Expand Up @@ -64,6 +65,7 @@ export function registerPreloadApi(
completeSetup: (config: WizardSetupConfig) => ipcRenderer.invoke('complete-setup', config),
getLaunchOptions: () => ipcRenderer.invoke('get-launch-options'),
chooseLaunchOption: (choice: LaunchChoice) => ipcRenderer.invoke('choose-launch-option', choice),
openExternal: (url: string) => ipcRenderer.invoke('open-external-url', url),
}

contextBridge.exposeInMainWorld('freshellDesktop', api)
Expand Down
74 changes: 65 additions & 9 deletions server/agent-api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const FRESH_AGENT_SEND_IDLE_TIMEOUT_MS = 600_000

async function waitForFreshAgentIdle(
runtimeManager: NonNullable<FreshAgentRuntimeManagerLike>,
locator: { sessionType: string; provider: string; threadId: string },
locator: { sessionType: string; provider: string; threadId: string; cwd?: string },
deadline: number,
): Promise<{ status: string; deadlineMissed: boolean }> {
while (Date.now() < deadline) {
Expand All @@ -99,6 +99,25 @@ async function waitForFreshAgentIdle(
return { status: 'unknown', deadlineMissed: true }
}

function freshAgentPaneCwd(content: Record<string, unknown>): string | undefined {
const cwd = content.initialCwd
return typeof cwd === 'string' && cwd.trim().length > 0 ? cwd : undefined
}

function freshAgentPaneSendSettings(content: Record<string, unknown>): Record<string, unknown> | undefined {
const settings = {
cwd: freshAgentPaneCwd(content),
model: typeof content.model === 'string' && content.model.trim().length > 0 ? content.model : undefined,
permissionMode: typeof content.permissionMode === 'string' && content.permissionMode.trim().length > 0
? content.permissionMode
: undefined,
sandbox: typeof content.sandbox === 'string' && content.sandbox.trim().length > 0 ? content.sandbox : undefined,
effort: typeof content.effort === 'string' && content.effort.trim().length > 0 ? content.effort : undefined,
}
const defined = Object.fromEntries(Object.entries(settings).filter(([, value]) => value !== undefined))
return Object.keys(defined).length > 0 ? defined : undefined
}

function combineWithCleanupError(primary: unknown, cleanupError: unknown): Error {
const primaryMessage = errorMessage(primary)
const cleanupMessage = errorMessage(cleanupError)
Expand Down Expand Up @@ -440,7 +459,7 @@ type CodexPromptBlocker = {

type FreshAgentRuntimeManagerLike = {
create: (input: any) => Promise<{ sessionId: string; sessionType: FreshAgentSessionType; runtimeProvider: FreshAgentRuntimeProvider; sessionRef?: { provider: string; sessionId: string } }>
send: (locator: FreshAgentSessionLocator, input: { text: string; settings?: any }) => Promise<{ sessionId?: string; sessionRef?: { provider: string; sessionId: string } } | void>
send: (locator: FreshAgentSessionLocator, input: { text: string; settings?: any }) => Promise<{ sessionId?: string; submittedTurnId?: string; sessionRef?: { provider: string; sessionId: string } } | void>
attach: (locator: FreshAgentSessionLocator) => Promise<{ sessionId: string; sessionRef?: { provider: string; sessionId: string } }>
getSnapshot: (input: FreshAgentThreadLocator) => Promise<any>
}
Expand Down Expand Up @@ -883,7 +902,12 @@ export function createAgentApiRouter({
const c = paneSnapshot.paneContent || {}
if (!freshAgentRuntimeManager) return res.status(503).json(fail('fresh-agent runtime not available on this server'))
try {
const snapshot = await freshAgentRuntimeManager.getSnapshot({ sessionType: c.sessionType, provider: c.provider, threadId: c.sessionId })
const snapshot = await freshAgentRuntimeManager.getSnapshot({
sessionType: c.sessionType,
provider: c.provider,
threadId: c.sessionId,
cwd: freshAgentPaneCwd(c),
})
return res.type('text/plain').send(renderFreshAgentTranscript(snapshot))
} catch (err: any) {
return res.status(agentRouteErrorStatus(err)).json(fail(err?.message || 'fresh-agent capture failed'))
Expand Down Expand Up @@ -938,7 +962,12 @@ export function createAgentApiRouter({
while (true) {
let status: string | undefined
try {
const snap = await freshAgentRuntimeManager.getSnapshot({ sessionType: c.sessionType, provider: c.provider, threadId: c.sessionId })
const snap = await freshAgentRuntimeManager.getSnapshot({
sessionType: c.sessionType,
provider: c.provider,
threadId: c.sessionId,
cwd: freshAgentPaneCwd(c),
})
status = snap?.status
} catch (err) {
return res.status(freshAgentErrorStatus(err)).json(fail(errorMessage(err)))
Expand Down Expand Up @@ -1638,9 +1667,24 @@ export function createAgentApiRouter({
const text = String(req.body?.data ?? req.body?.keys ?? req.body?.text ?? '')
if (!text) return res.status(400).json(fail('text is required'))
if (!freshAgentRuntimeManager) return res.status(503).json(fail('fresh-agent runtime not available on this server'))
const locator = { sessionId: c.sessionId as string, sessionType: c.sessionType as string, provider: c.provider as string } as FreshAgentSessionLocator
const runSend = () => freshAgentRuntimeManager.send(locator, { text })
const snapshotLocator = { sessionType: c.sessionType as string, provider: c.provider as string, threadId: c.sessionId as string }
const cwd = freshAgentPaneCwd(c)
const settings = freshAgentPaneSendSettings(c)
const locator = {
sessionId: c.sessionId as string,
sessionType: c.sessionType as string,
provider: c.provider as string,
...(cwd ? { cwd } : {}),
} as FreshAgentSessionLocator
const runSend = () => freshAgentRuntimeManager.send(locator, {
text,
...(settings ? { settings } : {}),
})
const snapshotLocator = {
sessionType: c.sessionType as string,
provider: c.provider as string,
threadId: c.sessionId as string,
...(cwd ? { cwd } : {}),
}
try {
let result
try {
Expand All @@ -1661,7 +1705,13 @@ export function createAgentApiRouter({
const deadline = Date.now() + (Number.isFinite(timeoutSec) ? timeoutSec * 1000 : FRESH_AGENT_SEND_IDLE_TIMEOUT_MS)
const idle = await waitForFreshAgentIdle(freshAgentRuntimeManager, snapshotLocator, deadline)
if (idle.deadlineMissed) {
return res.json(approx({ paneId, sessionId: result?.sessionId ?? locator.sessionId, sessionRef: result?.sessionRef, status: idle.status }, 'prompt sent; turn did not complete within deadline'))
return res.json(approx({
paneId,
sessionId: result?.sessionId ?? locator.sessionId,
submittedTurnId: result?.submittedTurnId,
sessionRef: result?.sessionRef,
status: idle.status,
}, 'prompt sent; turn did not complete within deadline'))
}

const finalSessionId = result?.sessionId ?? locator.sessionId
Expand All @@ -1684,7 +1734,13 @@ export function createAgentApiRouter({
})
}

return res.json(ok({ paneId, sessionId: finalSessionId, sessionRef: finalSessionRef, status: idle.status }, 'prompt sent'))
return res.json(ok({
paneId,
sessionId: finalSessionId,
submittedTurnId: result?.submittedTurnId,
sessionRef: finalSessionRef,
status: idle.status,
}, 'prompt sent'))
} catch (err: any) {
return res.status(agentRouteErrorStatus(err)).json(fail(err?.message || 'fresh-agent send failed'))
}
Expand Down
36 changes: 36 additions & 0 deletions server/config-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fsp from 'fs/promises'
import path from 'path'
import { randomBytes } from 'node:crypto'
import { logger } from './logger.js'
import { getFreshellConfigDir } from './freshell-home.js'
import {
Expand Down Expand Up @@ -57,6 +58,9 @@ export type UserConfig = {
version: 1
settings: AppSettings
legacyLocalSettingsSeed?: LocalSettingsPatch
serverSecrets?: {
codexDisplayIdSecret?: string
}
sessionOverrides: Record<string, SessionOverride>
terminalOverrides: Record<string, TerminalOverride>
projectColors: Record<string, string>
Expand Down Expand Up @@ -266,6 +270,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}

function generateServerLocalSecret(): string {
return randomBytes(32).toString('base64url')
}

function migrateLegacyFreshClaudeSettings(rawSettings: Record<string, unknown>): Record<string, unknown> {
if (!isRecord(rawSettings.freshclaude)) {
return rawSettings
Expand Down Expand Up @@ -336,6 +344,14 @@ export class ConfigStore {
...existing,
settings,
legacyLocalSettingsSeed,
serverSecrets: isRecord(existing.serverSecrets)
? {
...(typeof existing.serverSecrets.codexDisplayIdSecret === 'string'
&& existing.serverSecrets.codexDisplayIdSecret.trim().length > 0
? { codexDisplayIdSecret: existing.serverSecrets.codexDisplayIdSecret }
: {}),
}
: undefined,
sessionOverrides: existing.sessionOverrides || {},
terminalOverrides: existing.terminalOverrides || {},
projectColors: existing.projectColors || {},
Expand Down Expand Up @@ -371,6 +387,7 @@ export class ConfigStore {
version: 1,
settings: defaultSettings,
legacyLocalSettingsSeed: undefined,
serverSecrets: undefined,
sessionOverrides: {},
terminalOverrides: {},
projectColors: {},
Expand Down Expand Up @@ -419,6 +436,25 @@ export class ConfigStore {
return cfg.settings
}

async getCodexDisplayIdSecret(): Promise<string> {
return this.writeMutex.acquire(async () => {
const cfg = await this.loadForWrite()
const existing = cfg.serverSecrets?.codexDisplayIdSecret
if (typeof existing === 'string' && existing.trim().length > 0) {
return existing
}
const secret = generateServerLocalSecret()
await this.saveInternal({
...cfg,
serverSecrets: {
...(cfg.serverSecrets ?? {}),
codexDisplayIdSecret: secret,
},
})
return secret
})
}

async getLegacyLocalSettingsSeed(): Promise<LocalSettingsPatch | undefined> {
const cfg = await this.load()
return cfg.legacyLocalSettingsSeed
Expand Down
Loading
Loading