diff --git a/Dockerfile b/Dockerfile
index 07d6e31c..c4f905ad 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,7 +9,7 @@
# Or pull pre-built:
# docker pull ghcr.io/outsourc-e/hermes-workspace:latest
#
-FROM tianon/gosu:1.19-bookworm AS gosu_source
+FROM tianon/gosu:1.17-bookworm AS gosu_source
# ─── build stage ─────────────────────────────────────────────────────────
FROM node:22-slim AS build
RUN corepack enable && apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
diff --git a/src/lib/workspace-message-scope.test.ts b/src/lib/workspace-message-scope.test.ts
index ea457ffc..21bb50b1 100644
--- a/src/lib/workspace-message-scope.test.ts
+++ b/src/lib/workspace-message-scope.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
-import { buildWorkspaceScopedTextMessage } from './workspace-message-scope'
+import {
+ buildWorkspaceScopedTextMessage,
+ stripWorkspaceDirective,
+} from './workspace-message-scope'
describe('buildWorkspaceScopedTextMessage', () => {
it('prepends an explicit active workspace directive to plain text chat messages', () => {
@@ -39,4 +42,12 @@ describe('buildWorkspaceScopedTextMessage', () => {
}),
).toBe('hello')
})
+
+ it('strips the workspace directive back out for user-visible rendering', () => {
+ expect(
+ stripWorkspaceDirective(
+ '\n\nRun the tests',
+ ),
+ ).toBe('Run the tests')
+ })
})
diff --git a/src/lib/workspace-message-scope.ts b/src/lib/workspace-message-scope.ts
index 05abde12..779b7bd2 100644
--- a/src/lib/workspace-message-scope.ts
+++ b/src/lib/workspace-message-scope.ts
@@ -4,6 +4,9 @@ export type WorkspaceScope = {
isValid?: boolean
}
+const WORKSPACE_DIRECTIVE_RE =
+ /^\s*\s*/i
+
function escapeAttribute(value: string): string {
return value
.replace(/&/g, '&')
@@ -28,3 +31,8 @@ export function buildWorkspaceScopedTextMessage(
if (!directive) return message
return `${directive}\n\n${message}`
}
+
+export function stripWorkspaceDirective(message: string): string {
+ if (!message.includes(' {
+ onComplete: useCallback((message: ChatMessage) => {
const activeSend = activeSendRef.current
if (activeSend?.clientId) {
updateHistoryMessageByClientIdEverywhere(
@@ -1140,6 +1141,13 @@ export function ChatScreen({
}),
)
}
+ if (activeSend?.sessionKey) {
+ persistRecoveryMessage(activeSend.sessionKey, message)
+ clearPendingSendForSession(
+ activeSend.sessionKey,
+ activeSend.friendlyId,
+ )
+ }
activeSendRef.current = null
refreshHistoryRef.current()
setSending(false)
diff --git a/src/screens/chat/utils.test.ts b/src/screens/chat/utils.test.ts
new file mode 100644
index 00000000..41f6492f
--- /dev/null
+++ b/src/screens/chat/utils.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+
+import { normalizeSessions, textFromMessage } from './utils'
+import type { ChatMessage, SessionSummary } from './types'
+
+describe('chat utils workspace directive cleanup', () => {
+ it('hides workspace_context directives from user-visible message text', () => {
+ const message: ChatMessage = {
+ role: 'user',
+ content: [
+ {
+ type: 'text',
+ text: '\n\nRun the tests',
+ },
+ ],
+ }
+
+ expect(textFromMessage(message)).toBe('Run the tests')
+ })
+
+ it('strips workspace_context directives from session previews and derived titles', () => {
+ const sessions = normalizeSessions([
+ {
+ key: 'session-1',
+ friendlyId: 'session-1',
+ preview:
+ '\n\nReview the open PRs',
+ },
+ {
+ key: 'session-2',
+ friendlyId: 'session-2',
+ derivedTitle:
+ '\n\nFix Docker publish',
+ },
+ ] satisfies Array)
+
+ expect(sessions[0]?.preview).toBe('Review the open PRs')
+ expect(sessions[0]?.derivedTitle).toBe('Review the open PRs')
+ expect(sessions[1]?.derivedTitle).toBe('Fix Docker publish')
+ })
+})
diff --git a/src/screens/chat/utils.ts b/src/screens/chat/utils.ts
index 346074b9..a329003c 100644
--- a/src/screens/chat/utils.ts
+++ b/src/screens/chat/utils.ts
@@ -6,6 +6,7 @@ import type {
SessionTitleStatus,
ToolCallContent,
} from './types'
+import { stripWorkspaceDirective } from '../../lib/workspace-message-scope'
export function deriveFriendlyIdFromKey(key: string | undefined): string {
if (!key) return 'main'
@@ -52,7 +53,7 @@ function stripChannelPrefix(text: string): string {
* and [Telegram/Signal/etc ...] headers, leaving just the user's text.
*/
function cleanUserText(raw: string): string {
- let text = raw
+ let text = stripWorkspaceDirective(raw)
// Remove "Conversation info (untrusted metadata):" headers + JSON block
// Format: "Conversation info (untrusted metadata):\n```json\n{...}\n```\n\n"
@@ -226,15 +227,15 @@ export function normalizeSessions(
: undefined
const explicitTitle =
typeof session.title === 'string' && session.title.trim().length > 0
- ? session.title.trim()
+ ? cleanUserText(session.title.trim()) || session.title.trim()
: undefined
const derivedTitle =
typeof session.derivedTitle === 'string' &&
session.derivedTitle.trim().length > 0
- ? session.derivedTitle.trim()
+ ? cleanUserText(session.derivedTitle.trim()) || session.derivedTitle.trim()
: typeof session.preview === 'string' &&
session.preview.trim().length > 0
- ? session.preview.trim()
+ ? cleanUserText(session.preview.trim()) || session.preview.trim()
: undefined
const titleStatus = deriveTitleStatus(
label,
@@ -261,7 +262,10 @@ export function normalizeSessions(
titleStatus,
titleSource,
titleError: session.titleError ?? null,
- preview: session.preview ?? null,
+ preview:
+ typeof session.preview === 'string'
+ ? cleanUserText(session.preview) || session.preview.trim() || null
+ : session.preview ?? null,
}
})
}
diff --git a/vite.config.ts b/vite.config.ts
index 51a3e318..a0074eba 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -478,7 +478,10 @@ const config = defineConfig(({ mode, command }) => {
// 2. $PORT env var (for containers, reverse proxies, WhatsApp bridge collisions, etc. — see #96)
// 3. default 3000 (matches README/docs/docker-compose expectations)
port: process.env.PORT ? Number(process.env.PORT) : 3000,
- strictPort: false, // allow fallback if port is taken, but log clearly
+ // Managed Workspace launchers expect a stable port. Fail loudly instead
+ // of silently hopping to 3001+ so launchctl/service health matches the
+ // actual listening socket.
+ strictPort: true,
allowedHosts: true,
watch: {
ignored: [