add tmux sessions
This commit is contained in:
@@ -115,7 +115,14 @@ When Claude Code requests permission for file operations (Write/Edit) or shell c
|
|||||||
File operations show expandable diffs displaying the exact changes being made.
|
File operations show expandable diffs displaying the exact changes being made.
|
||||||
|
|
||||||
### Auto-Accept Edits
|
### Auto-Accept Edits
|
||||||
Claude sessions can enable "Auto-accept edits" in session settings to automatically grant Write/Edit permissions, reducing interruptions during coding sessions.
|
Claude sessions can enable "Auto-accept edits" in session settings (gear icon) to automatically grant file operation permissions. When enabled:
|
||||||
|
|
||||||
|
- **Applies to**: `Write` and `Edit` tools only (file create/modify operations)
|
||||||
|
- **Does NOT apply to**: `Bash`, `WebFetch`, `WebSearch`, `NotebookEdit`, or other tools
|
||||||
|
- **Behavior**: Permission is still recorded in message history (green "accepted" status) but no user interaction required
|
||||||
|
- **Use case**: Reduces interruptions during coding sessions when you trust Claude to make file changes
|
||||||
|
|
||||||
|
Other permission types (shell commands, web access, etc.) will still prompt for manual approval.
|
||||||
|
|
||||||
### Session Management
|
### Session Management
|
||||||
- **Rename**: Click session title to rename
|
- **Rename**: Click session title to rename
|
||||||
|
|||||||
@@ -58,6 +58,42 @@ cd ../server
|
|||||||
clj -M:run
|
clj -M:run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Permission Handling
|
||||||
|
|
||||||
|
When Claude Code requests permission for tools, Spiceflow intercepts and presents them for approval:
|
||||||
|
|
||||||
|
- **Accept**: Grant permission and continue
|
||||||
|
- **Deny**: Reject the request
|
||||||
|
- **Steer ("No, and...")**: Redirect Claude with alternative instructions
|
||||||
|
|
||||||
|
Supported tool types with human-readable descriptions:
|
||||||
|
- `Bash` - Shell commands (shows the command)
|
||||||
|
- `Write` - File creation (shows file path + diff preview)
|
||||||
|
- `Edit` - File modification (shows file path + diff preview)
|
||||||
|
- `WebFetch` - URL fetching (shows URL)
|
||||||
|
- `WebSearch` - Web searches (shows query)
|
||||||
|
- `NotebookEdit` - Jupyter notebook edits
|
||||||
|
- `Skill` - Slash command execution
|
||||||
|
|
||||||
|
### Auto-Accept Edits
|
||||||
|
|
||||||
|
Claude sessions can enable "Auto-accept edits" via the settings gear icon to automatically grant file operation permissions:
|
||||||
|
|
||||||
|
- **Applies to**: `Write` and `Edit` tools only (file create/modify)
|
||||||
|
- **Does NOT apply to**: `Bash`, `WebFetch`, `WebSearch`, or other tools
|
||||||
|
- **Behavior**: Permission is recorded in message history (green "accepted" status) without user interaction
|
||||||
|
- **Use case**: Reduces interruptions during coding sessions when you trust Claude to make file changes
|
||||||
|
|
||||||
|
### Real-time Streaming
|
||||||
|
|
||||||
|
Messages stream in real-time via WebSocket with:
|
||||||
|
- Content deltas as Claude types
|
||||||
|
- Permission request notifications
|
||||||
|
- Working directory updates
|
||||||
|
- Session status changes
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|
|||||||
+13
-1
@@ -2,7 +2,7 @@ const API_BASE = '/api';
|
|||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
provider: 'claude' | 'opencode';
|
provider: 'claude' | 'opencode' | 'tmux';
|
||||||
'external-id'?: string;
|
'external-id'?: string;
|
||||||
externalId?: string;
|
externalId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -150,6 +150,18 @@ class ApiClient {
|
|||||||
async health(): Promise<{ status: string; service: string }> {
|
async health(): Promise<{ status: string; service: string }> {
|
||||||
return this.request<{ status: string; service: string }>('/health');
|
return this.request<{ status: string; service: string }>('/health');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Terminal (tmux)
|
||||||
|
async getTerminalContent(sessionId: string): Promise<{ content: string; alive: boolean; 'session-name': string }> {
|
||||||
|
return this.request<{ content: string; alive: boolean; 'session-name': string }>(`/sessions/${sessionId}/terminal`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTerminalInput(sessionId: string, input: string): Promise<{ status: string }> {
|
||||||
|
return this.request<{ status: string }>(`/sessions/${sessionId}/terminal/input`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ input })
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ApiClient();
|
export const api = new ApiClient();
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
|
|
||||||
export let disabled = false;
|
export let disabled = false;
|
||||||
export let placeholder = 'Type a message...';
|
export let placeholder = 'Type a message...';
|
||||||
|
export let autoFocus = false;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{ send: string }>();
|
const dispatch = createEventDispatcher<{ send: string }>();
|
||||||
|
|
||||||
let message = '';
|
let message = '';
|
||||||
let textarea: HTMLTextAreaElement;
|
let textarea: HTMLTextAreaElement;
|
||||||
let expanded = false;
|
let expanded = false;
|
||||||
|
let isFocused = false;
|
||||||
|
|
||||||
const LINE_HEIGHT = 22; // approximate line height in pixels
|
const LINE_HEIGHT = 22; // approximate line height in pixels
|
||||||
const PADDING = 24; // vertical padding
|
const PADDING = 24; // vertical padding
|
||||||
const MIN_HEIGHT_COLLAPSED = LINE_HEIGHT + PADDING; // 1 line
|
const MIN_HEIGHT_COLLAPSED = LINE_HEIGHT + PADDING; // 1 line
|
||||||
const MIN_HEIGHT_EXPANDED = LINE_HEIGHT * 4 + PADDING; // 4 lines
|
const MIN_HEIGHT_EXPANDED = LINE_HEIGHT * 4 + PADDING; // 4 lines
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (autoFocus) {
|
||||||
|
// Small delay to ensure page is ready
|
||||||
|
setTimeout(() => textarea?.focus(), 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
const trimmed = message.trim();
|
const trimmed = message.trim();
|
||||||
if (!trimmed || disabled) return;
|
if (!trimmed || disabled) return;
|
||||||
@@ -49,9 +58,29 @@
|
|||||||
export function focus() {
|
export function focus() {
|
||||||
textarea?.focus();
|
textarea?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function blur() {
|
||||||
|
textarea?.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleKeyboard() {
|
||||||
|
if (isFocused) {
|
||||||
|
textarea?.blur();
|
||||||
|
} else {
|
||||||
|
textarea?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
isFocused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
isFocused = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form on:submit|preventDefault={handleSubmit} class="border-t border-zinc-700 bg-zinc-900 safe-bottom">
|
<form on:submit|preventDefault={handleSubmit} class="flex-shrink-0 border-t border-zinc-700 bg-zinc-900 safe-bottom">
|
||||||
<div class="flex items-end gap-2 p-3">
|
<div class="flex items-end gap-2 p-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -74,12 +103,34 @@
|
|||||||
bind:value={message}
|
bind:value={message}
|
||||||
on:keydown={handleKeydown}
|
on:keydown={handleKeydown}
|
||||||
on:input={resizeTextarea}
|
on:input={resizeTextarea}
|
||||||
|
on:focus={handleFocus}
|
||||||
|
on:blur={handleBlur}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
{disabled}
|
{disabled}
|
||||||
rows={expanded ? 4 : 1}
|
rows={expanded ? 4 : 1}
|
||||||
class="input resize-none py-3 {expanded ? 'min-h-[112px]' : 'min-h-[44px]'}"
|
class="input resize-none py-3 {expanded ? 'min-h-[112px]' : 'min-h-[44px]'}"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
|
<!-- Keyboard toggle button (useful for mobile PWA) -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={toggleKeyboard}
|
||||||
|
class="h-[44px] w-[44px] flex items-center justify-center text-zinc-500 hover:text-zinc-300 transition-colors flex-shrink-0"
|
||||||
|
aria-label={isFocused ? 'Hide keyboard' : 'Show keyboard'}
|
||||||
|
>
|
||||||
|
{#if isFocused}
|
||||||
|
<!-- Keyboard hide icon -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- Keyboard show icon -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={disabled || !message.trim()}
|
disabled={disabled || !message.trim()}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Message, PermissionDenial, ToolInput } from '$lib/api';
|
import type { Message, PermissionDenial, ToolInput, Session } from '$lib/api';
|
||||||
import { onMount, afterUpdate } from 'svelte';
|
import { onMount, afterUpdate } from 'svelte';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import FileDiff from './FileDiff.svelte';
|
import FileDiff from './FileDiff.svelte';
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
export let messages: Message[] = [];
|
export let messages: Message[] = [];
|
||||||
export let streamingContent: string = '';
|
export let streamingContent: string = '';
|
||||||
export let isThinking: boolean = false;
|
export let isThinking: boolean = false;
|
||||||
|
export let provider: Session['provider'] = 'claude';
|
||||||
|
export let autoScroll: boolean = true;
|
||||||
|
|
||||||
// Configure marked for safe rendering
|
// Configure marked for safe rendering
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
@@ -19,7 +21,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let autoScroll = true;
|
|
||||||
let collapsedMessages: Set<string> = new Set();
|
let collapsedMessages: Set<string> = new Set();
|
||||||
|
|
||||||
const COLLAPSE_THRESHOLD = 5; // show toggle if more than this many lines
|
const COLLAPSE_THRESHOLD = 5; // show toggle if more than this many lines
|
||||||
@@ -30,14 +31,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScroll() {
|
|
||||||
if (!container) return;
|
|
||||||
const threshold = 100;
|
|
||||||
const distanceFromBottom =
|
|
||||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
|
||||||
autoScroll = distanceFromBottom < threshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleCollapse(id: string) {
|
function toggleCollapse(id: string) {
|
||||||
if (collapsedMessages.has(id)) {
|
if (collapsedMessages.has(id)) {
|
||||||
collapsedMessages.delete(id);
|
collapsedMessages.delete(id);
|
||||||
@@ -123,8 +116,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={container}
|
bind:this={container}
|
||||||
on:scroll={handleScroll}
|
class="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-4"
|
||||||
class="flex-1 overflow-y-auto px-4 py-4 space-y-4"
|
|
||||||
>
|
>
|
||||||
{#if messages.length === 0 && !streamingContent && !isThinking}
|
{#if messages.length === 0 && !streamingContent && !isThinking}
|
||||||
<div class="h-full flex items-center justify-center">
|
<div class="h-full flex items-center justify-center">
|
||||||
@@ -259,11 +251,25 @@
|
|||||||
role={isCollapsible ? 'button' : undefined}
|
role={isCollapsible ? 'button' : undefined}
|
||||||
tabindex={isCollapsible ? 0 : undefined}
|
tabindex={isCollapsible ? 0 : undefined}
|
||||||
>
|
>
|
||||||
<div
|
{#if provider === 'tmux' && message.role === 'assistant'}
|
||||||
class="text-sm break-words font-mono leading-relaxed markdown-content {isCollapsed ? 'line-clamp-3' : ''} {isCollapsible ? 'pr-6' : ''}"
|
<!-- Terminal output styling for tmux -->
|
||||||
>
|
<pre
|
||||||
{@html renderMarkdown(message.content)}
|
class="text-sm break-words font-mono leading-relaxed bg-black/40 rounded p-2 overflow-x-auto whitespace-pre-wrap text-green-300 {isCollapsed ? 'line-clamp-3' : ''} {isCollapsible ? 'pr-6' : ''}"
|
||||||
</div>
|
>{message.content}</pre>
|
||||||
|
{:else if provider === 'tmux' && message.role === 'user'}
|
||||||
|
<!-- Command input styling for tmux -->
|
||||||
|
<div
|
||||||
|
class="text-sm break-words font-mono leading-relaxed {isCollapsed ? 'line-clamp-3' : ''} {isCollapsible ? 'pr-6' : ''}"
|
||||||
|
>
|
||||||
|
<span class="text-cyan-400">$</span> {message.content}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="text-sm break-words font-mono leading-relaxed markdown-content {isCollapsed ? 'line-clamp-3' : ''} {isCollapsible ? 'pr-6' : ''}"
|
||||||
|
>
|
||||||
|
{@html renderMarkdown(message.content)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if isCollapsible}
|
{#if isCollapsible}
|
||||||
<span
|
<span
|
||||||
class="absolute right-3 top-3 text-zinc-500 transition-transform {isCollapsed ? '' : 'rotate-90'}"
|
class="absolute right-3 top-3 text-zinc-500 transition-transform {isCollapsed ? '' : 'rotate-90'}"
|
||||||
@@ -291,9 +297,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if streamingContent}
|
{:else if streamingContent}
|
||||||
<div class="rounded-lg border p-3 {roleStyles.assistant}">
|
<div class="rounded-lg border p-3 {roleStyles.assistant}">
|
||||||
<div class="text-sm break-words font-mono leading-relaxed markdown-content">
|
{#if provider === 'tmux'}
|
||||||
{@html renderMarkdown(streamingContent)}<span class="animate-pulse">|</span>
|
<pre
|
||||||
</div>
|
class="text-sm break-words font-mono leading-relaxed bg-black/40 rounded p-2 overflow-x-auto whitespace-pre-wrap text-green-300"
|
||||||
|
>{streamingContent}<span class="animate-pulse text-white">|</span></pre>
|
||||||
|
{:else}
|
||||||
|
<div class="text-sm break-words font-mono leading-relaxed markdown-content">
|
||||||
|
{@html renderMarkdown(streamingContent)}<span class="animate-pulse">|</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="border-t border-amber-500/30 bg-amber-500/10 px-4 py-3">
|
<div class="flex-shrink-0 border-t border-amber-500/30 bg-amber-500/10 px-4 py-3">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div class="flex-shrink-0 mt-0.5">
|
<div class="flex-shrink-0 mt-0.5">
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { pushStore, pushState } from '$lib/stores/push';
|
||||||
|
|
||||||
|
let loading = false;
|
||||||
|
let errorMessage: string | null = null;
|
||||||
|
|
||||||
|
// Subscribe to store changes
|
||||||
|
$: storeLoading = $pushStore.loading;
|
||||||
|
$: storeError = $pushStore.error;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
pushStore.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleToggle() {
|
||||||
|
if (loading || storeLoading) return;
|
||||||
|
loading = true;
|
||||||
|
errorMessage = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pushStore.toggle();
|
||||||
|
} catch (err) {
|
||||||
|
errorMessage = err instanceof Error ? err.message : 'An error occurred';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: isSubscribed = $pushState === 'subscribed';
|
||||||
|
$: isDenied = $pushState === 'denied';
|
||||||
|
$: isUnsupported = $pushState === 'unsupported';
|
||||||
|
$: isDisabled = isDenied || isUnsupported || loading || storeLoading;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !isUnsupported}
|
||||||
|
<button
|
||||||
|
on:click={handleToggle}
|
||||||
|
disabled={isDisabled}
|
||||||
|
class="p-1.5 hover:bg-zinc-700 rounded transition-colors relative"
|
||||||
|
class:opacity-50={isDisabled}
|
||||||
|
aria-label={isSubscribed ? 'Disable notifications' : 'Enable notifications'}
|
||||||
|
title={isDenied
|
||||||
|
? 'Notifications blocked in browser settings'
|
||||||
|
: isSubscribed
|
||||||
|
? 'Notifications enabled - click to disable'
|
||||||
|
: 'Enable push notifications'}
|
||||||
|
>
|
||||||
|
{#if loading || storeLoading}
|
||||||
|
<svg class="h-5 w-5 text-zinc-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
class:text-spice-500={isSubscribed}
|
||||||
|
class:text-zinc-400={!isSubscribed}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
{#if isSubscribed}
|
||||||
|
<!-- Bell with active indicator -->
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||||
|
/>
|
||||||
|
{:else if isDenied}
|
||||||
|
<!-- Bell with slash -->
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||||
|
/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3l18 18" />
|
||||||
|
{:else}
|
||||||
|
<!-- Bell outline -->
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Active indicator dot -->
|
||||||
|
{#if isSubscribed && !loading && !storeLoading}
|
||||||
|
<span class="absolute top-0.5 right-0.5 h-2 w-2 rounded-full bg-spice-500" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if errorMessage || storeError}
|
||||||
|
<div
|
||||||
|
class="absolute top-full right-0 mt-1 px-2 py-1 bg-red-900/90 text-red-200 text-xs rounded shadow-lg max-w-[200px] z-50"
|
||||||
|
>
|
||||||
|
{errorMessage || storeError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$: externalId = session['external-id'] || session.externalId || '';
|
$: externalId = session['external-id'] || session.externalId || '';
|
||||||
$: workingDir = session['working-dir'] || session.workingDir || '';
|
|
||||||
$: updatedAt = session['updated-at'] || session.updatedAt || session['created-at'] || session.createdAt || '';
|
$: updatedAt = session['updated-at'] || session.updatedAt || session['created-at'] || session.createdAt || '';
|
||||||
$: shortId = externalId.slice(0, 8);
|
$: shortId = externalId.slice(0, 8);
|
||||||
|
|
||||||
@@ -38,9 +37,12 @@
|
|||||||
'awaiting-permission': 'bg-amber-500 animate-pulse'
|
'awaiting-permission': 'bg-amber-500 animate-pulse'
|
||||||
};
|
};
|
||||||
|
|
||||||
const providerColors = {
|
$: statusColor = session.provider === 'tmux' ? 'bg-green-500' : statusColors[session.status];
|
||||||
|
|
||||||
|
const providerColors: Record<string, string> = {
|
||||||
claude: 'text-spice-400',
|
claude: 'text-spice-400',
|
||||||
opencode: 'text-emerald-400'
|
opencode: 'text-emerald-400',
|
||||||
|
tmux: 'text-cyan-400'
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@
|
|||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<span class="w-2 h-2 rounded-full {statusColors[session.status]}"></span>
|
<span class="w-2 h-2 rounded-full {statusColor}"></span>
|
||||||
<span class="text-xs font-medium uppercase tracking-wide {providerColors[session.provider]}">
|
<span class="text-xs font-medium uppercase tracking-wide {providerColors[session.provider]}">
|
||||||
{session.provider}
|
{session.provider}
|
||||||
</span>
|
</span>
|
||||||
@@ -60,12 +62,6 @@
|
|||||||
<h3 class="font-medium text-zinc-100 truncate">
|
<h3 class="font-medium text-zinc-100 truncate">
|
||||||
{session.title || `Session ${shortId}`}
|
{session.title || `Session ${shortId}`}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{#if workingDir}
|
|
||||||
<p class="text-sm text-zinc-400 mt-1 break-all">
|
|
||||||
{workingDir}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-right flex-shrink-0 flex items-center gap-2">
|
<div class="text-right flex-shrink-0 flex items-center gap-2">
|
||||||
|
|||||||
@@ -2,12 +2,19 @@
|
|||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
export let autoAcceptEdits: boolean = false;
|
export let autoAcceptEdits: boolean = false;
|
||||||
export let provider: 'claude' | 'opencode' = 'claude';
|
export let autoScroll: boolean = true;
|
||||||
|
export let provider: 'claude' | 'opencode' | 'tmux' = 'claude';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
toggleAutoAccept: boolean;
|
toggleAutoAccept: boolean;
|
||||||
|
toggleAutoScroll: boolean;
|
||||||
|
condenseAll: void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
function handleToggleAutoScroll() {
|
||||||
|
dispatch('toggleAutoScroll', !autoScroll);
|
||||||
|
}
|
||||||
|
|
||||||
let open = false;
|
let open = false;
|
||||||
|
|
||||||
function handleToggle() {
|
function handleToggle() {
|
||||||
@@ -15,6 +22,11 @@
|
|||||||
dispatch('toggleAutoAccept', newValue);
|
dispatch('toggleAutoAccept', newValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCondenseAll() {
|
||||||
|
dispatch('condenseAll');
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (!target.closest('.settings-dropdown')) {
|
if (!target.closest('.settings-dropdown')) {
|
||||||
@@ -56,6 +68,36 @@
|
|||||||
<span class="text-sm font-medium text-zinc-200">Session Settings</span>
|
<span class="text-sm font-medium text-zinc-200">Session Settings</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-scroll toggle (all providers) -->
|
||||||
|
<label
|
||||||
|
class="flex items-start gap-3 px-3 py-2.5 hover:bg-zinc-700/50 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoScroll}
|
||||||
|
on:change={handleToggleAutoScroll}
|
||||||
|
class="mt-0.5 h-4 w-4 rounded border-zinc-600 bg-zinc-700 text-spice-500 focus:ring-spice-500 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span class="text-sm text-zinc-200 block">Auto-scroll</span>
|
||||||
|
<span class="text-xs text-zinc-500 block mt-0.5"
|
||||||
|
>Scroll to bottom on new content</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if provider !== 'tmux'}
|
||||||
|
<button
|
||||||
|
on:click={handleCondenseAll}
|
||||||
|
class="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-zinc-700/50 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm text-zinc-200">Condense all</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if provider === 'claude'}
|
{#if provider === 'claude'}
|
||||||
<label
|
<label
|
||||||
class="flex items-start gap-3 px-3 py-2.5 hover:bg-zinc-700/50 cursor-pointer transition-colors"
|
class="flex items-start gap-3 px-3 py-2.5 hover:bg-zinc-700/50 cursor-pointer transition-colors"
|
||||||
@@ -73,10 +115,6 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
{:else}
|
|
||||||
<div class="px-3 py-2.5 text-xs text-zinc-500">
|
|
||||||
No settings available for OpenCode sessions yet.
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy, tick, createEventDispatcher } from 'svelte';
|
||||||
|
import { api, wsClient, type StreamEvent } from '$lib/api';
|
||||||
|
|
||||||
|
export let sessionId: string;
|
||||||
|
export let autoScroll: boolean = true;
|
||||||
|
export let autoFocus: boolean = true;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ aliveChange: boolean }>();
|
||||||
|
|
||||||
|
let terminalContent = '';
|
||||||
|
let prevContentLength = 0;
|
||||||
|
let isAlive = false;
|
||||||
|
let loading = true;
|
||||||
|
let error = '';
|
||||||
|
let terminalElement: HTMLPreElement;
|
||||||
|
let terminalInput: HTMLInputElement;
|
||||||
|
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
let ctrlMode = false;
|
||||||
|
|
||||||
|
async function fetchTerminalContent() {
|
||||||
|
try {
|
||||||
|
const result = await api.getTerminalContent(sessionId);
|
||||||
|
const contentGrew = result.content.length > prevContentLength;
|
||||||
|
prevContentLength = result.content.length;
|
||||||
|
terminalContent = result.content;
|
||||||
|
if (isAlive !== result.alive) {
|
||||||
|
isAlive = result.alive;
|
||||||
|
dispatch('aliveChange', isAlive);
|
||||||
|
}
|
||||||
|
error = '';
|
||||||
|
if (contentGrew) {
|
||||||
|
await tick();
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to fetch terminal content';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
if (autoScroll && terminalElement) {
|
||||||
|
terminalElement.scrollTop = terminalElement.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (!isAlive) return;
|
||||||
|
|
||||||
|
// Prevent default for all keys we handle
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (event.key === 'Enter' && event.shiftKey) {
|
||||||
|
// Shift+Enter sends literal newline
|
||||||
|
await sendInput('\n');
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
// Enter sends Enter key
|
||||||
|
await sendInput('\r');
|
||||||
|
} else if (event.key === 'Backspace') {
|
||||||
|
await sendInput('\x7f');
|
||||||
|
} else if (event.key === 'Tab' && event.shiftKey) {
|
||||||
|
// Shift+Tab escape sequence
|
||||||
|
await sendInput('\x1b[Z');
|
||||||
|
} else if (event.key === 'Tab') {
|
||||||
|
await sendInput('\t');
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
await sendInput('\x1b');
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
await sendInput('\x1b[A');
|
||||||
|
} else if (event.key === 'ArrowDown') {
|
||||||
|
await sendInput('\x1b[B');
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
await sendInput('\x1b[C');
|
||||||
|
} else if (event.key === 'ArrowLeft') {
|
||||||
|
await sendInput('\x1b[D');
|
||||||
|
} else if (event.ctrlKey && event.key.length === 1) {
|
||||||
|
// Ctrl+letter = control character
|
||||||
|
const code = event.key.toLowerCase().charCodeAt(0) - 96;
|
||||||
|
if (code > 0 && code < 27) {
|
||||||
|
await sendInput(String.fromCharCode(code));
|
||||||
|
}
|
||||||
|
} else if (ctrlMode && event.key.length === 1 && /[a-z]/i.test(event.key)) {
|
||||||
|
// Ctrl mode toggle + letter = control character
|
||||||
|
const code = event.key.toLowerCase().charCodeAt(0) - 96;
|
||||||
|
if (code > 0 && code < 27) {
|
||||||
|
await sendInput(String.fromCharCode(code));
|
||||||
|
ctrlMode = false;
|
||||||
|
}
|
||||||
|
} else if (event.key.length === 1) {
|
||||||
|
// Regular printable character
|
||||||
|
await sendInput(event.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendInput(input: string) {
|
||||||
|
try {
|
||||||
|
await api.sendTerminalInput(sessionId, input);
|
||||||
|
setTimeout(fetchTerminalContent, 200);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to send input';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCtrlC() {
|
||||||
|
await sendInput('\x03');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendKey(key: string) {
|
||||||
|
await sendInput(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWebSocketEvent(event: StreamEvent) {
|
||||||
|
if (event.event === 'terminal-update' && event.content !== undefined) {
|
||||||
|
const newContent = event.content as string;
|
||||||
|
const contentGrew = newContent.length > prevContentLength;
|
||||||
|
prevContentLength = newContent.length;
|
||||||
|
terminalContent = newContent;
|
||||||
|
if (contentGrew) {
|
||||||
|
tick().then(scrollToBottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Initial fetch
|
||||||
|
await fetchTerminalContent();
|
||||||
|
|
||||||
|
// Subscribe to WebSocket updates
|
||||||
|
await wsClient.connect();
|
||||||
|
unsubscribe = wsClient.subscribe(sessionId, handleWebSocketEvent);
|
||||||
|
|
||||||
|
// Periodic refresh every 1 second
|
||||||
|
refreshInterval = setInterval(fetchTerminalContent, 1000);
|
||||||
|
|
||||||
|
// Auto-focus input after content loads
|
||||||
|
if (autoFocus) {
|
||||||
|
setTimeout(() => terminalInput?.focus(), 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval);
|
||||||
|
}
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col flex-1 min-h-0 bg-black">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex-1 flex items-center justify-center">
|
||||||
|
<svg class="animate-spin h-8 w-8 text-cyan-500" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex-1 flex items-center justify-center p-4">
|
||||||
|
<div class="text-red-400">{error}</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Terminal output area -->
|
||||||
|
<pre
|
||||||
|
bind:this={terminalElement}
|
||||||
|
class="flex-1 min-h-0 overflow-auto p-3 font-mono text-sm text-green-400 whitespace-pre-wrap break-words leading-relaxed"
|
||||||
|
>{terminalContent || 'Terminal ready. Type a command below.'}</pre>
|
||||||
|
|
||||||
|
<!-- Quick action buttons -->
|
||||||
|
<div class="flex-shrink-0 px-2 py-1.5 bg-zinc-900 border-t border-zinc-800 flex items-center gap-1.5 overflow-x-auto">
|
||||||
|
<button
|
||||||
|
on:click={() => ctrlMode = !ctrlMode}
|
||||||
|
disabled={!isAlive}
|
||||||
|
class="px-2.5 py-1 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-xs font-mono text-zinc-200 transition-colors {ctrlMode ? 'font-bold ring-2 ring-cyan-400 rounded-lg' : 'rounded'}"
|
||||||
|
>
|
||||||
|
Ctrl
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={sendCtrlC}
|
||||||
|
disabled={!isAlive}
|
||||||
|
class="px-2.5 py-1 bg-red-600/80 hover:bg-red-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
||||||
|
>
|
||||||
|
Ctrl+C
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => sendInput('\x04')}
|
||||||
|
disabled={!isAlive}
|
||||||
|
class="px-2.5 py-1 bg-amber-600/80 hover:bg-amber-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
||||||
|
>
|
||||||
|
Ctrl+D
|
||||||
|
</button>
|
||||||
|
<span class="w-px h-4 bg-zinc-700"></span>
|
||||||
|
<button
|
||||||
|
on:click={() => sendKey('y')}
|
||||||
|
disabled={!isAlive}
|
||||||
|
class="px-2.5 py-1 bg-green-600/80 hover:bg-green-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
||||||
|
>
|
||||||
|
y
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => sendKey('n')}
|
||||||
|
disabled={!isAlive}
|
||||||
|
class="px-2.5 py-1 bg-red-600/80 hover:bg-red-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
||||||
|
>
|
||||||
|
n
|
||||||
|
</button>
|
||||||
|
<span class="w-px h-4 bg-zinc-700"></span>
|
||||||
|
{#each ['1', '2', '3', '4', '5'] as num}
|
||||||
|
<button
|
||||||
|
on:click={() => sendKey(num)}
|
||||||
|
disabled={!isAlive}
|
||||||
|
class="px-2.5 py-1 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded text-xs font-mono text-zinc-200 transition-colors"
|
||||||
|
>
|
||||||
|
{num}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<span class="w-px h-4 bg-zinc-700"></span>
|
||||||
|
<button
|
||||||
|
on:click={() => sendInput('\t')}
|
||||||
|
disabled={!isAlive}
|
||||||
|
class="px-2.5 py-1 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
||||||
|
>
|
||||||
|
Tab
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => sendInput('\x1b[Z')}
|
||||||
|
disabled={!isAlive}
|
||||||
|
class="px-2.5 py-1 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
||||||
|
>
|
||||||
|
S-Tab
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={scrollToBottom}
|
||||||
|
class="ml-auto p-1 bg-zinc-700 hover:bg-zinc-600 rounded text-zinc-200 transition-colors"
|
||||||
|
aria-label="Scroll to bottom"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input area -->
|
||||||
|
<div class="flex-shrink-0 border-t border-zinc-800 bg-zinc-900 p-2 safe-bottom">
|
||||||
|
<div class="flex items-center gap-2 bg-black rounded border border-zinc-700 px-3 py-2">
|
||||||
|
<span class="text-cyan-400 font-mono text-sm">$</span>
|
||||||
|
<input
|
||||||
|
bind:this={terminalInput}
|
||||||
|
type="text"
|
||||||
|
on:keydown={handleKeydown}
|
||||||
|
class="flex-1 bg-transparent border-none outline-none font-mono text-sm text-green-400 placeholder-zinc-600"
|
||||||
|
placeholder="Keys sent immediately..."
|
||||||
|
disabled={!isAlive}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Web Push notification utilities for Spiceflow PWA
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if push notifications are supported
|
||||||
|
*/
|
||||||
|
export function isPushSupported(): boolean {
|
||||||
|
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current notification permission status
|
||||||
|
*/
|
||||||
|
export function getPermissionStatus(): NotificationPermission {
|
||||||
|
if (!isPushSupported()) {
|
||||||
|
return 'denied';
|
||||||
|
}
|
||||||
|
return Notification.permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request notification permission from the user
|
||||||
|
*/
|
||||||
|
export async function requestPermission(): Promise<NotificationPermission> {
|
||||||
|
if (!isPushSupported()) {
|
||||||
|
throw new Error('Push notifications are not supported');
|
||||||
|
}
|
||||||
|
return await Notification.requestPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert URL-safe base64 string to Uint8Array
|
||||||
|
* (for applicationServerKey)
|
||||||
|
*/
|
||||||
|
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||||
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the VAPID public key from the server
|
||||||
|
*/
|
||||||
|
export async function getVapidKey(): Promise<string> {
|
||||||
|
const response = await fetch(`${API_BASE}/push/vapid-key`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to get VAPID key');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data.publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current push subscription if one exists
|
||||||
|
*/
|
||||||
|
export async function getSubscription(): Promise<PushSubscription | null> {
|
||||||
|
if (!isPushSupported()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
return await registration.pushManager.getSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to push notifications
|
||||||
|
*/
|
||||||
|
export async function subscribe(): Promise<PushSubscription> {
|
||||||
|
if (!isPushSupported()) {
|
||||||
|
throw new Error('Push notifications are not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request permission first
|
||||||
|
const permission = await requestPermission();
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
throw new Error('Notification permission denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get VAPID public key
|
||||||
|
const vapidKey = await getVapidKey();
|
||||||
|
const applicationServerKey = urlBase64ToUint8Array(vapidKey);
|
||||||
|
|
||||||
|
// Get service worker registration
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
|
||||||
|
// Subscribe to push
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: applicationServerKey as BufferSource
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send subscription to server
|
||||||
|
const response = await fetch(`${API_BASE}/push/subscribe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(subscription.toJSON())
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Unsubscribe if server save failed
|
||||||
|
await subscription.unsubscribe();
|
||||||
|
throw new Error('Failed to save subscription on server');
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from push notifications
|
||||||
|
*/
|
||||||
|
export async function unsubscribe(): Promise<void> {
|
||||||
|
const subscription = await getSubscription();
|
||||||
|
if (!subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify server first
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/push/unsubscribe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ endpoint: subscription.endpoint })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to notify server of unsubscribe:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe locally
|
||||||
|
await subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if currently subscribed to push notifications
|
||||||
|
*/
|
||||||
|
export async function isSubscribed(): Promise<boolean> {
|
||||||
|
const subscription = await getSubscription();
|
||||||
|
return subscription !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import {
|
||||||
|
isPushSupported,
|
||||||
|
getPermissionStatus,
|
||||||
|
isSubscribed,
|
||||||
|
subscribe as pushSubscribe,
|
||||||
|
unsubscribe as pushUnsubscribe
|
||||||
|
} from '$lib/push';
|
||||||
|
|
||||||
|
export type PushState = 'unsupported' | 'default' | 'denied' | 'subscribed' | 'unsubscribed';
|
||||||
|
|
||||||
|
interface PushStore {
|
||||||
|
supported: boolean;
|
||||||
|
permission: NotificationPermission;
|
||||||
|
subscribed: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPushStore() {
|
||||||
|
const store = writable<PushStore>({
|
||||||
|
supported: false,
|
||||||
|
permission: 'default',
|
||||||
|
subscribed: false,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const { subscribe: storeSubscribe, set, update } = store;
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe: storeSubscribe,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the push store state
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
const supported = isPushSupported();
|
||||||
|
if (!supported) {
|
||||||
|
set({
|
||||||
|
supported: false,
|
||||||
|
permission: 'denied',
|
||||||
|
subscribed: false,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = getPermissionStatus();
|
||||||
|
const subscribed = await isSubscribed();
|
||||||
|
|
||||||
|
set({
|
||||||
|
supported,
|
||||||
|
permission,
|
||||||
|
subscribed,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to push notifications
|
||||||
|
*/
|
||||||
|
async enablePush() {
|
||||||
|
update((s) => ({ ...s, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pushSubscribe();
|
||||||
|
update((s) => ({
|
||||||
|
...s,
|
||||||
|
subscribed: true,
|
||||||
|
permission: 'granted',
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to subscribe';
|
||||||
|
update((s) => ({
|
||||||
|
...s,
|
||||||
|
loading: false,
|
||||||
|
error: message,
|
||||||
|
permission: getPermissionStatus()
|
||||||
|
}));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from push notifications
|
||||||
|
*/
|
||||||
|
async disablePush() {
|
||||||
|
update((s) => ({ ...s, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pushUnsubscribe();
|
||||||
|
update((s) => ({
|
||||||
|
...s,
|
||||||
|
subscribed: false,
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to unsubscribe';
|
||||||
|
update((s) => ({
|
||||||
|
...s,
|
||||||
|
loading: false,
|
||||||
|
error: message
|
||||||
|
}));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle subscription state
|
||||||
|
*/
|
||||||
|
async toggle() {
|
||||||
|
const state = await new Promise<PushStore>((resolve) => {
|
||||||
|
const unsub = storeSubscribe((s) => {
|
||||||
|
resolve(s);
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.subscribed) {
|
||||||
|
await this.disablePush();
|
||||||
|
} else {
|
||||||
|
await this.enablePush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pushStore = createPushStore();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived store for the overall push state
|
||||||
|
*/
|
||||||
|
export const pushState = derived(pushStore, ($push): PushState => {
|
||||||
|
if (!$push.supported) return 'unsupported';
|
||||||
|
if ($push.permission === 'denied') return 'denied';
|
||||||
|
if ($push.subscribed) return 'subscribed';
|
||||||
|
if ($push.permission === 'granted') return 'unsubscribed';
|
||||||
|
return 'default';
|
||||||
|
});
|
||||||
@@ -212,19 +212,29 @@ function createActiveSessionStore() {
|
|||||||
update((s) => ({ ...s, error: (e as Error).message }));
|
update((s) => ({ ...s, error: (e as Error).message }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async rename(title: string) {
|
async rename(title: string): Promise<{ updated: Session; idChanged: boolean }> {
|
||||||
const state = get();
|
const state = get();
|
||||||
if (!state.session) return;
|
if (!state.session) throw new Error('No active session');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const oldId = state.session.id;
|
||||||
const updated = await api.updateSession(state.session.id, { title });
|
const updated = await api.updateSession(state.session.id, { title });
|
||||||
|
const idChanged = updated.id !== oldId;
|
||||||
|
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
session: s.session ? { ...s.session, ...updated } : null
|
session: s.session ? { ...s.session, ...updated } : null
|
||||||
}));
|
}));
|
||||||
// Also update in the sessions list
|
|
||||||
sessions.updateSession(state.session.id, { title });
|
if (idChanged) {
|
||||||
return updated;
|
// For tmux sessions, ID changes on rename - remove old, add new in sessions list
|
||||||
|
sessions.updateSession(oldId, { ...updated });
|
||||||
|
} else {
|
||||||
|
// Regular session - just update title
|
||||||
|
sessions.updateSession(state.session.id, { title });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { updated, idChanged };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
update((s) => ({ ...s, error: (e as Error).message }));
|
update((s) => ({ ...s, error: (e as Error).message }));
|
||||||
throw e;
|
throw e;
|
||||||
@@ -329,11 +339,20 @@ function createActiveSessionStore() {
|
|||||||
const permReq = event['permission-request'] || event.permissionRequest;
|
const permReq = event['permission-request'] || event.permissionRequest;
|
||||||
const permMessage = (event as StreamEvent & { message?: Message }).message;
|
const permMessage = (event as StreamEvent & { message?: Message }).message;
|
||||||
const messageId = (event as StreamEvent & { 'message-id'?: string })['message-id'];
|
const messageId = (event as StreamEvent & { 'message-id'?: string })['message-id'];
|
||||||
console.log('[WS] Permission request received:', permReq, 'message:', permMessage);
|
const autoAccepted = (event as StreamEvent & { 'auto-accepted'?: boolean })['auto-accepted'];
|
||||||
|
console.log('[WS] Permission request received:', permReq, 'message:', permMessage, 'autoAccepted:', autoAccepted);
|
||||||
if (permReq) {
|
if (permReq) {
|
||||||
// Store the message-id in the permission request for later status update
|
// Store the message-id in the permission request for later status update
|
||||||
const permReqWithMsgId = messageId ? { ...permReq, 'message-id': messageId } : permReq;
|
const permReqWithMsgId = messageId ? { ...permReq, 'message-id': messageId } : permReq;
|
||||||
update((s) => {
|
update((s) => {
|
||||||
|
// If auto-accepted, just add message to history (don't show permission UI)
|
||||||
|
if (autoAccepted && permMessage) {
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
messages: [...s.messages, permMessage]
|
||||||
|
// Note: pendingPermission stays null, so no permission UI shown
|
||||||
|
};
|
||||||
|
}
|
||||||
// If we received the full message, add it to messages array
|
// If we received the full message, add it to messages array
|
||||||
// Otherwise just update pendingPermission
|
// Otherwise just update pendingPermission
|
||||||
if (permMessage) {
|
if (permMessage) {
|
||||||
@@ -345,7 +364,7 @@ function createActiveSessionStore() {
|
|||||||
}
|
}
|
||||||
return { ...s, pendingPermission: permReqWithMsgId };
|
return { ...s, pendingPermission: permReqWithMsgId };
|
||||||
});
|
});
|
||||||
console.log('[WS] pendingPermission state updated with message-id:', messageId);
|
console.log('[WS] pendingPermission state updated with message-id:', messageId, 'autoAccepted:', autoAccepted);
|
||||||
}
|
}
|
||||||
} else if (event.event === 'error') {
|
} else if (event.event === 'error') {
|
||||||
update((s) => ({ ...s, error: event.message || 'Stream error', isThinking: false }));
|
update((s) => ({ ...s, error: event.message || 'Stream error', isThinking: false }));
|
||||||
@@ -366,5 +385,5 @@ export const sortedSessions: Readable<Session[]> = derived(sessions, ($sessions)
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const processingSessions: Readable<Session[]> = derived(sessions, ($sessions) =>
|
export const processingSessions: Readable<Session[]> = derived(sessions, ($sessions) =>
|
||||||
$sessions.sessions.filter((s) => s.status === 'processing')
|
$sessions.sessions.filter((s) => s.status === 'processing' && s.provider !== 'tmux')
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,12 +2,23 @@
|
|||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { sessions } from '$lib/stores/sessions';
|
import { sessions } from '$lib/stores/sessions';
|
||||||
|
import { pushStore } from '$lib/stores/push';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
sessions.load();
|
sessions.load();
|
||||||
|
pushStore.init();
|
||||||
|
|
||||||
|
// Listen for navigation messages from service worker
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === 'NAVIGATE' && event.data?.url) {
|
||||||
|
window.location.href = event.data.url;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-screen flex flex-col bg-zinc-900 text-zinc-100 safe-top">
|
<div class="h-dvh flex flex-col bg-zinc-900 text-zinc-100 safe-top">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { sessions, sortedSessions, processingSessions } from '$lib/stores/sessions';
|
import { sessions, sortedSessions, processingSessions } from '$lib/stores/sessions';
|
||||||
import type { Session } from '$lib/api';
|
import type { Session } from '$lib/api';
|
||||||
import SessionCard from '$lib/components/SessionCard.svelte';
|
import SessionCard from '$lib/components/SessionCard.svelte';
|
||||||
|
import PushToggle from '$lib/components/PushToggle.svelte';
|
||||||
|
|
||||||
let showNewSessionMenu = false;
|
let showNewSessionMenu = false;
|
||||||
let creating = false;
|
let creating = false;
|
||||||
@@ -46,6 +47,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<PushToggle />
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
on:click|stopPropagation={() => (showNewSessionMenu = !showNewSessionMenu)}
|
on:click|stopPropagation={() => (showNewSessionMenu = !showNewSessionMenu)}
|
||||||
@@ -83,6 +86,13 @@
|
|||||||
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
|
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||||
OpenCode
|
OpenCode
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => createNewSession('tmux')}
|
||||||
|
class="w-full px-4 py-2.5 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
<span class="w-2 h-2 rounded-full bg-cyan-500"></span>
|
||||||
|
Terminal (tmux)
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount, onDestroy, tick } from 'svelte';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
@@ -7,23 +8,41 @@
|
|||||||
import InputBar from '$lib/components/InputBar.svelte';
|
import InputBar from '$lib/components/InputBar.svelte';
|
||||||
import PermissionRequest from '$lib/components/PermissionRequest.svelte';
|
import PermissionRequest from '$lib/components/PermissionRequest.svelte';
|
||||||
import SessionSettings from '$lib/components/SessionSettings.svelte';
|
import SessionSettings from '$lib/components/SessionSettings.svelte';
|
||||||
|
import TerminalView from '$lib/components/TerminalView.svelte';
|
||||||
|
|
||||||
$: sessionId = $page.params.id;
|
$: sessionId = $page.params.id;
|
||||||
|
|
||||||
let inputBar: InputBar;
|
let inputBar: InputBar;
|
||||||
let messageList: MessageList;
|
let messageList: MessageList;
|
||||||
|
let terminalView: TerminalView;
|
||||||
let steerMode = false;
|
let steerMode = false;
|
||||||
let isEditingTitle = false;
|
let isEditingTitle = false;
|
||||||
let editedTitle = '';
|
let editedTitle = '';
|
||||||
let titleInput: HTMLInputElement;
|
let titleInput: HTMLInputElement;
|
||||||
let menuOpen = false;
|
let menuOpen = false;
|
||||||
|
let autoScroll = true;
|
||||||
|
let tmuxAlive = false;
|
||||||
|
|
||||||
|
// Load auto-scroll preference from localStorage
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
if (browser) {
|
||||||
|
const stored = localStorage.getItem('spiceflow-auto-scroll');
|
||||||
|
if (stored !== null) {
|
||||||
|
autoScroll = stored === 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
activeSession.load(sessionId);
|
activeSession.load(sessionId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handleToggleAutoScroll(event: CustomEvent<boolean>) {
|
||||||
|
autoScroll = event.detail;
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem('spiceflow-auto-scroll', String(autoScroll));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
activeSession.clear();
|
activeSession.clear();
|
||||||
});
|
});
|
||||||
@@ -70,7 +89,11 @@
|
|||||||
const newTitle = editedTitle.trim();
|
const newTitle = editedTitle.trim();
|
||||||
isEditingTitle = false;
|
isEditingTitle = false;
|
||||||
if (newTitle !== (session.title || '')) {
|
if (newTitle !== (session.title || '')) {
|
||||||
await activeSession.rename(newTitle);
|
const result = await activeSession.rename(newTitle);
|
||||||
|
// For tmux sessions, the ID changes on rename - navigate to new URL
|
||||||
|
if (result?.idChanged && result.updated?.id) {
|
||||||
|
goto(`/session/${result.updated.id}`, { replaceState: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +112,17 @@
|
|||||||
|
|
||||||
$: session = $activeSession.session;
|
$: session = $activeSession.session;
|
||||||
$: externalId = session?.['external-id'] || session?.externalId || '';
|
$: externalId = session?.['external-id'] || session?.externalId || '';
|
||||||
$: workingDir = session?.['working-dir'] || session?.workingDir || '';
|
|
||||||
$: shortId = externalId ? externalId.slice(0, 8) : session?.id?.slice(0, 8) || '';
|
$: shortId = externalId ? externalId.slice(0, 8) : session?.id?.slice(0, 8) || '';
|
||||||
$: projectName = workingDir.split('/').pop() || '';
|
|
||||||
$: isNewSession = !externalId && $activeSession.messages.length === 0;
|
$: isNewSession = !externalId && $activeSession.messages.length === 0;
|
||||||
$: assistantName = session?.provider === 'opencode' ? 'OpenCode' : 'Claude';
|
$: assistantName = session?.provider === 'opencode' ? 'OpenCode' : 'Claude';
|
||||||
$: autoAcceptEdits = session?.['auto-accept-edits'] || session?.autoAcceptEdits || false;
|
$: autoAcceptEdits = session?.['auto-accept-edits'] || session?.autoAcceptEdits || false;
|
||||||
|
$: isTmuxSession = session?.provider === 'tmux';
|
||||||
|
|
||||||
|
const providerColors: Record<string, string> = {
|
||||||
|
claude: 'text-spice-400',
|
||||||
|
opencode: 'text-emerald-400',
|
||||||
|
tmux: 'text-cyan-400'
|
||||||
|
};
|
||||||
|
|
||||||
function handleToggleAutoAccept(event: CustomEvent<boolean>) {
|
function handleToggleAutoAccept(event: CustomEvent<boolean>) {
|
||||||
activeSession.setAutoAcceptEdits(event.detail);
|
activeSession.setAutoAcceptEdits(event.detail);
|
||||||
@@ -105,6 +133,15 @@
|
|||||||
processing: 'bg-green-500 animate-pulse',
|
processing: 'bg-green-500 animate-pulse',
|
||||||
'awaiting-permission': 'bg-amber-500 animate-pulse'
|
'awaiting-permission': 'bg-amber-500 animate-pulse'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function handleTmuxAliveChange(event: CustomEvent<boolean>) {
|
||||||
|
tmuxAlive = event.detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For tmux sessions, use tmuxAlive; for others, use session status
|
||||||
|
$: statusIndicator = isTmuxSession
|
||||||
|
? (tmuxAlive ? 'bg-green-500' : 'bg-zinc-600')
|
||||||
|
: (statusColors[session?.status || 'idle'] || statusColors.idle);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -134,7 +171,7 @@
|
|||||||
{:else if session}
|
{:else if session}
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="w-2 h-2 rounded-full {statusColors[session.status] || statusColors.idle}"></span>
|
<span class="w-2 h-2 rounded-full {statusIndicator}"></span>
|
||||||
{#if isEditingTitle}
|
{#if isEditingTitle}
|
||||||
<input
|
<input
|
||||||
bind:this={titleInput}
|
bind:this={titleInput}
|
||||||
@@ -154,23 +191,19 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if projectName}
|
|
||||||
<p class="text-xs text-zinc-500 truncate">{projectName}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SessionSettings
|
<SessionSettings
|
||||||
{autoAcceptEdits}
|
{autoAcceptEdits}
|
||||||
|
{autoScroll}
|
||||||
provider={session.provider}
|
provider={session.provider}
|
||||||
on:toggleAutoAccept={handleToggleAutoAccept}
|
on:toggleAutoAccept={handleToggleAutoAccept}
|
||||||
|
on:toggleAutoScroll={handleToggleAutoScroll}
|
||||||
|
on:condenseAll={() => messageList?.condenseAll()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span
|
<span class="text-xs font-medium uppercase {providerColors[session.provider] || 'text-zinc-400'}">
|
||||||
class="text-xs font-medium uppercase {session.provider === 'claude'
|
{session.provider === 'tmux' ? 'terminal' : session.provider}
|
||||||
? 'text-spice-400'
|
|
||||||
: 'text-emerald-400'}"
|
|
||||||
>
|
|
||||||
{session.provider}
|
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -194,12 +227,9 @@
|
|||||||
{#if session}
|
{#if session}
|
||||||
<div class="px-3 py-2 border-b border-zinc-700">
|
<div class="px-3 py-2 border-b border-zinc-700">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="w-2 h-2 rounded-full {statusColors[session.status] || statusColors.idle}"></span>
|
<span class="w-2 h-2 rounded-full {statusIndicator}"></span>
|
||||||
<span class="font-semibold truncate">{session.title || `Session ${shortId}`}</span>
|
<span class="font-semibold truncate">{session.title || `Session ${shortId}`}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if projectName}
|
|
||||||
<p class="text-xs text-zinc-500 truncate mt-0.5">{projectName}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
@@ -211,15 +241,26 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Back to sessions
|
Back to sessions
|
||||||
</button>
|
</button>
|
||||||
<button
|
<label class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors cursor-pointer">
|
||||||
on:click={() => { menuOpen = false; messageList?.condenseAll(); }}
|
<input
|
||||||
class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors"
|
type="checkbox"
|
||||||
>
|
checked={autoScroll}
|
||||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
on:change={() => { autoScroll = !autoScroll; localStorage.setItem('spiceflow-auto-scroll', String(autoScroll)); }}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
class="h-4 w-4 rounded border-zinc-600 bg-zinc-700 text-spice-500 focus:ring-spice-500 focus:ring-offset-0"
|
||||||
</svg>
|
/>
|
||||||
Condense all
|
<span>Auto-scroll</span>
|
||||||
</button>
|
</label>
|
||||||
|
{#if !isTmuxSession}
|
||||||
|
<button
|
||||||
|
on:click={() => { menuOpen = false; messageList?.condenseAll(); }}
|
||||||
|
class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
Condense all
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if session?.provider === 'claude'}
|
{#if session?.provider === 'claude'}
|
||||||
<label class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors cursor-pointer border-t border-zinc-700">
|
<label class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors cursor-pointer border-t border-zinc-700">
|
||||||
<input
|
<input
|
||||||
@@ -255,23 +296,11 @@
|
|||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if isTmuxSession}
|
||||||
|
<!-- Terminal view for tmux sessions -->
|
||||||
|
<TerminalView bind:this={terminalView} sessionId={sessionId || ''} {autoScroll} on:aliveChange={handleTmuxAliveChange} />
|
||||||
{:else}
|
{:else}
|
||||||
{#if workingDir}
|
<MessageList bind:this={messageList} messages={$activeSession.messages} streamingContent={$activeSession.streamingContent} isThinking={$activeSession.isThinking} provider={session?.provider} {autoScroll} />
|
||||||
<div class="flex-shrink-0 px-4 py-2 bg-zinc-900/50 border-b border-zinc-800 flex items-center gap-2 text-xs text-zinc-500 landscape-mobile:hidden">
|
|
||||||
<svg class="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
||||||
</svg>
|
|
||||||
<span class="truncate font-mono">{workingDir}</span>
|
|
||||||
<span class="flex-1"></span>
|
|
||||||
<button
|
|
||||||
on:click={() => messageList?.condenseAll()}
|
|
||||||
class="text-zinc-400 hover:text-zinc-200 transition-colors whitespace-nowrap"
|
|
||||||
>
|
|
||||||
condense all
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<MessageList bind:this={messageList} messages={$activeSession.messages} streamingContent={$activeSession.streamingContent} isThinking={$activeSession.isThinking} />
|
|
||||||
|
|
||||||
{#if $activeSession.pendingPermission}
|
{#if $activeSession.pendingPermission}
|
||||||
<PermissionRequest
|
<PermissionRequest
|
||||||
@@ -287,6 +316,7 @@
|
|||||||
bind:this={inputBar}
|
bind:this={inputBar}
|
||||||
on:send={handleSend}
|
on:send={handleSend}
|
||||||
disabled={session?.status === 'processing' && $activeSession.streamingContent !== ''}
|
disabled={session?.status === 'processing' && $activeSession.streamingContent !== ''}
|
||||||
|
autoFocus={true}
|
||||||
placeholder={steerMode
|
placeholder={steerMode
|
||||||
? `Tell ${assistantName} what to do instead...`
|
? `Tell ${assistantName} what to do instead...`
|
||||||
: session?.status === 'processing'
|
: session?.status === 'processing'
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
|
||||||
|
|
||||||
|
declare let self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
// Clean up old caches
|
||||||
|
cleanupOutdatedCaches();
|
||||||
|
|
||||||
|
// Precache all assets generated by the build
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
|
||||||
|
// Push notification payload type
|
||||||
|
interface PushPayload {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
sessionId: string;
|
||||||
|
sessionTitle: string;
|
||||||
|
tools: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle push events
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
if (!event.data) {
|
||||||
|
console.log('[SW] Push event with no data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: PushPayload = event.data.json();
|
||||||
|
console.log('[SW] Push received:', payload);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
body: payload.body,
|
||||||
|
icon: '/pwa-192x192.png',
|
||||||
|
badge: '/pwa-192x192.png',
|
||||||
|
tag: `permission-${payload.sessionId}`,
|
||||||
|
renotify: true,
|
||||||
|
requireInteraction: true,
|
||||||
|
data: {
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
url: `/session/${payload.sessionId}`
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
action: 'open',
|
||||||
|
title: 'Open Session'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} satisfies NotificationOptions & { renotify?: boolean; requireInteraction?: boolean; actions?: { action: string; title: string }[] };
|
||||||
|
|
||||||
|
event.waitUntil(self.registration.showNotification(payload.title, options));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SW] Error processing push:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle notification click
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
console.log('[SW] Notification clicked:', event.action);
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
const url = event.notification.data?.url || '/';
|
||||||
|
|
||||||
|
// Focus existing window or open new one
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
|
// Try to find an existing window with the app
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||||
|
client.focus();
|
||||||
|
// Navigate to the session
|
||||||
|
client.postMessage({
|
||||||
|
type: 'NAVIGATE',
|
||||||
|
url: url
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No existing window, open a new one
|
||||||
|
if (self.clients.openWindow) {
|
||||||
|
return self.clients.openWindow(url);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle messages from the main app
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
+7
-16
@@ -8,6 +8,9 @@ export default defineConfig({
|
|||||||
sveltekit(),
|
sveltekit(),
|
||||||
basicSsl(),
|
basicSsl(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'src',
|
||||||
|
filename: 'sw.ts',
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
|
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
|
||||||
manifest: {
|
manifest: {
|
||||||
@@ -39,24 +42,12 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
workbox: {
|
injectManifest: {
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}']
|
||||||
runtimeCaching: [
|
|
||||||
{
|
|
||||||
urlPattern: /^https:\/\/.*\/api\/.*/i,
|
|
||||||
handler: 'NetworkFirst',
|
|
||||||
options: {
|
|
||||||
cacheName: 'api-cache',
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 100,
|
|
||||||
maxAgeSeconds: 60 * 5 // 5 minutes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: true
|
enabled: true,
|
||||||
|
type: 'module'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// Use unique filename for each test run to avoid conflicts
|
||||||
|
const TEST_FILE = `e2e-autoaccept-test-${Date.now()}.md`;
|
||||||
|
const TEST_FILE_PATH = path.join(process.env.HOME || '/home/ajet', TEST_FILE);
|
||||||
|
|
||||||
|
test.describe('Claude Auto-Accept Edits', () => {
|
||||||
|
test.afterEach(async () => {
|
||||||
|
// Cleanup: Delete the test file directly via filesystem
|
||||||
|
console.log('[Cleanup] Attempting to delete test file:', TEST_FILE_PATH);
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(TEST_FILE_PATH)) {
|
||||||
|
fs.unlinkSync(TEST_FILE_PATH);
|
||||||
|
console.log('[Cleanup] Deleted test file');
|
||||||
|
} else {
|
||||||
|
console.log('[Cleanup] Test file does not exist');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[Cleanup] Could not clean up:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto-accept enables file operations without permission prompts', async ({ page }) => {
|
||||||
|
// Increase timeout for this test since it involves real Claude interaction
|
||||||
|
test.setTimeout(300000); // 5 minutes
|
||||||
|
|
||||||
|
// Enable console logging for debugging
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
console.log(`[Browser ${msg.type()}]`, msg.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log WebSocket frames for debugging
|
||||||
|
page.on('websocket', (ws) => {
|
||||||
|
console.log(`[WebSocket] Connected to ${ws.url()}`);
|
||||||
|
ws.on('framesent', (frame) => console.log(`[WS Sent]`, frame.payload));
|
||||||
|
ws.on('framereceived', (frame) => console.log(`[WS Received]`, frame.payload));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Navigate to homepage
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveTitle(/Spiceflow/i);
|
||||||
|
|
||||||
|
// 2. Click the + button to open new session menu
|
||||||
|
const createButton = page.locator('button[title="New Session"]');
|
||||||
|
await expect(createButton).toBeVisible();
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
// 3. Select Claude Code from the dropdown
|
||||||
|
const claudeOption = page.locator('button:has-text("Claude Code")');
|
||||||
|
await expect(claudeOption).toBeVisible();
|
||||||
|
await claudeOption.click();
|
||||||
|
|
||||||
|
// 4. Wait for navigation to session page
|
||||||
|
await page.waitForURL(/\/session\/.+/);
|
||||||
|
console.log('[Test] Navigated to session page:', page.url());
|
||||||
|
|
||||||
|
// 5. Wait for the page to load
|
||||||
|
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('text=No messages yet')).toBeVisible();
|
||||||
|
|
||||||
|
// 6. Enable auto-accept edits via settings
|
||||||
|
const settingsButton = page.locator('button[aria-label="Session settings"]');
|
||||||
|
await expect(settingsButton).toBeVisible();
|
||||||
|
await settingsButton.click();
|
||||||
|
console.log('[Test] Opened settings dropdown');
|
||||||
|
|
||||||
|
// Wait for dropdown to appear and click the auto-accept checkbox
|
||||||
|
const autoAcceptLabel = page.locator('label:has-text("Auto-accept edits")');
|
||||||
|
await expect(autoAcceptLabel).toBeVisible();
|
||||||
|
const autoAcceptCheckbox = autoAcceptLabel.locator('input[type="checkbox"]');
|
||||||
|
|
||||||
|
// Listen for the API response to ensure setting is persisted
|
||||||
|
const updatePromise = page.waitForResponse(
|
||||||
|
(response) => response.url().includes('/api/sessions/') && response.request().method() === 'PATCH'
|
||||||
|
);
|
||||||
|
await autoAcceptCheckbox.check();
|
||||||
|
const updateResponse = await updatePromise;
|
||||||
|
console.log('[Test] Auto-accept update response:', updateResponse.status());
|
||||||
|
|
||||||
|
// Verify the response contains auto-accept-edits: true
|
||||||
|
const responseBody = await updateResponse.json();
|
||||||
|
console.log('[Test] Updated session:', JSON.stringify(responseBody));
|
||||||
|
expect(responseBody['auto-accept-edits']).toBe(true);
|
||||||
|
console.log('[Test] Verified auto-accept-edits is persisted');
|
||||||
|
|
||||||
|
// Close dropdown by clicking elsewhere
|
||||||
|
await page.locator('header').click();
|
||||||
|
await expect(autoAcceptLabel).not.toBeVisible();
|
||||||
|
|
||||||
|
// 7. Send message asking Claude to CREATE a file
|
||||||
|
const textarea = page.locator('textarea');
|
||||||
|
await expect(textarea).toBeVisible();
|
||||||
|
await textarea.fill(
|
||||||
|
`Create a file called ${TEST_FILE} with the content "# Test File\\n\\nOriginal content for testing auto-accept.". Just create the file, no other commentary.`
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendButton = page.locator('button[type="submit"]');
|
||||||
|
await expect(sendButton).toBeEnabled();
|
||||||
|
await sendButton.click();
|
||||||
|
console.log('[Test] Sent create file request');
|
||||||
|
|
||||||
|
// 8. Verify user message appears
|
||||||
|
await expect(page.locator(`text=Create a file called ${TEST_FILE}`)).toBeVisible();
|
||||||
|
|
||||||
|
// 9. Verify thinking indicator appears
|
||||||
|
const bouncingDots = page.locator('.animate-bounce');
|
||||||
|
await expect(bouncingDots.first()).toBeVisible({ timeout: 5000 });
|
||||||
|
console.log('[Test] Thinking indicator appeared');
|
||||||
|
|
||||||
|
// 10. CRITICAL: Permission UI should NOT appear (auto-accept should handle it)
|
||||||
|
// Wait a bit to give permission request time to appear if it would
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// Check that permission UI is not visible
|
||||||
|
const permissionUI = page.locator('text=Claude needs permission');
|
||||||
|
const permissionVisible = await permissionUI.isVisible();
|
||||||
|
|
||||||
|
if (permissionVisible) {
|
||||||
|
// If permission UI appears, the auto-accept didn't work - fail the test
|
||||||
|
console.log('[Test] ERROR: Permission UI appeared when auto-accept should have handled it');
|
||||||
|
// Take screenshot for debugging
|
||||||
|
await page.screenshot({ path: 'test-results/autoaccept-permission-appeared.png' });
|
||||||
|
expect(permissionVisible).toBe(false);
|
||||||
|
}
|
||||||
|
console.log('[Test] Confirmed: No permission UI appeared (auto-accept working)');
|
||||||
|
|
||||||
|
// 11. Wait for streaming to complete
|
||||||
|
const pulsingCursor = page.locator('.markdown-content .animate-pulse');
|
||||||
|
await expect(bouncingDots).toHaveCount(0, { timeout: 120000 });
|
||||||
|
await expect(pulsingCursor).toHaveCount(0, { timeout: 120000 });
|
||||||
|
console.log('[Test] Create file streaming complete');
|
||||||
|
|
||||||
|
// 12. Check for permission message in history with "accept" status (green)
|
||||||
|
// The permission message should be in the message list with status "accept"
|
||||||
|
// Look for the "Permission granted" header text which indicates accepted status
|
||||||
|
const acceptedPermissionHeader = page.locator('text=Permission granted');
|
||||||
|
await expect(acceptedPermissionHeader.first()).toBeVisible({ timeout: 10000 });
|
||||||
|
console.log('[Test] Found "Permission granted" header in history');
|
||||||
|
|
||||||
|
// Find the permission message container with green styling
|
||||||
|
const acceptedPermission = page.locator('.rounded-lg.border.bg-green-500\\/10');
|
||||||
|
const permissionCount = await acceptedPermission.count();
|
||||||
|
console.log('[Test] Found accepted permission messages with green styling:', permissionCount);
|
||||||
|
expect(permissionCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify the permission message contains Write tool and the test file
|
||||||
|
const firstPermission = acceptedPermission.first();
|
||||||
|
const permissionText = await firstPermission.textContent();
|
||||||
|
console.log('[Test] Permission message content:', permissionText?.substring(0, 200));
|
||||||
|
expect(permissionText).toContain('Write');
|
||||||
|
expect(permissionText).toContain('e2e-autoaccept-test');
|
||||||
|
console.log('[Test] Verified accepted permission shows Write tool and test file');
|
||||||
|
|
||||||
|
// Verify the green checkmark icon is present
|
||||||
|
const greenCheckmark = firstPermission.locator('svg.text-green-400');
|
||||||
|
await expect(greenCheckmark).toBeVisible();
|
||||||
|
console.log('[Test] Verified green checkmark icon is present');
|
||||||
|
|
||||||
|
// 13. Verify file was created by asking Claude to read it
|
||||||
|
await textarea.fill(`Read the contents of ${TEST_FILE} and quote what it says.`);
|
||||||
|
await sendButton.click();
|
||||||
|
console.log('[Test] Sent read file request');
|
||||||
|
|
||||||
|
// Wait for response
|
||||||
|
await expect(bouncingDots.first()).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
|
||||||
|
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
|
||||||
|
console.log('[Test] Read file streaming complete');
|
||||||
|
|
||||||
|
// Verify response contains the original content
|
||||||
|
const assistantMessages = page.locator('.rounded-lg.border').filter({
|
||||||
|
has: page.locator('.markdown-content')
|
||||||
|
});
|
||||||
|
const readResponse = await assistantMessages.last().locator('.markdown-content').textContent();
|
||||||
|
console.log('[Test] Read response:', readResponse?.substring(0, 200));
|
||||||
|
expect(readResponse).toContain('Original content');
|
||||||
|
console.log('[Test] Verified file was created with correct content');
|
||||||
|
|
||||||
|
// 14. EDIT the file (should also auto-accept)
|
||||||
|
await textarea.fill(
|
||||||
|
`Edit the file ${TEST_FILE} to change "Original content" to "UPDATED content". Just make the edit.`
|
||||||
|
);
|
||||||
|
await sendButton.click();
|
||||||
|
console.log('[Test] Sent edit file request');
|
||||||
|
|
||||||
|
// Wait and verify no permission UI
|
||||||
|
await expect(bouncingDots.first()).toBeVisible({ timeout: 5000 });
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
const editPermissionVisible = await permissionUI.isVisible();
|
||||||
|
if (editPermissionVisible) {
|
||||||
|
console.log('[Test] ERROR: Permission UI appeared for edit when auto-accept should have handled it');
|
||||||
|
await page.screenshot({ path: 'test-results/autoaccept-edit-permission-appeared.png' });
|
||||||
|
expect(editPermissionVisible).toBe(false);
|
||||||
|
}
|
||||||
|
console.log('[Test] Confirmed: No permission UI for edit (auto-accept working)');
|
||||||
|
|
||||||
|
// Wait for edit to complete
|
||||||
|
await expect(bouncingDots).toHaveCount(0, { timeout: 120000 });
|
||||||
|
await expect(pulsingCursor).toHaveCount(0, { timeout: 120000 });
|
||||||
|
console.log('[Test] Edit file streaming complete');
|
||||||
|
|
||||||
|
// 14b. Verify Edit permission was also auto-accepted and appears in history
|
||||||
|
// Should now have at least 2 accepted permissions (Write + Edit)
|
||||||
|
const acceptedPermissionCountAfterEdit = await acceptedPermission.count();
|
||||||
|
console.log('[Test] Accepted permission count after edit:', acceptedPermissionCountAfterEdit);
|
||||||
|
expect(acceptedPermissionCountAfterEdit).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// Find the Edit permission message (should be the latest one)
|
||||||
|
const editPermissionMessages = page.locator('.rounded-lg.border.bg-green-500\\/10').filter({
|
||||||
|
hasText: /Edit.*e2e-autoaccept-test/i
|
||||||
|
});
|
||||||
|
const editPermCount = await editPermissionMessages.count();
|
||||||
|
console.log('[Test] Found Edit permission messages:', editPermCount);
|
||||||
|
expect(editPermCount).toBeGreaterThan(0);
|
||||||
|
console.log('[Test] Verified Edit permission was auto-accepted and appears in history');
|
||||||
|
|
||||||
|
// 15. Verify edit worked by reading file again
|
||||||
|
await textarea.fill(`Read ${TEST_FILE} again and tell me what it says now.`);
|
||||||
|
await sendButton.click();
|
||||||
|
console.log('[Test] Sent second read request');
|
||||||
|
|
||||||
|
await expect(bouncingDots.first()).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
|
||||||
|
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
|
||||||
|
|
||||||
|
const editVerifyResponse = await assistantMessages.last().locator('.markdown-content').textContent();
|
||||||
|
console.log('[Test] Edit verify response:', editVerifyResponse?.substring(0, 200));
|
||||||
|
expect(editVerifyResponse).toContain('UPDATED');
|
||||||
|
console.log('[Test] Verified file was edited successfully');
|
||||||
|
|
||||||
|
// 16. Verify file exists on filesystem
|
||||||
|
const fileExists = fs.existsSync(TEST_FILE_PATH);
|
||||||
|
expect(fileExists).toBe(true);
|
||||||
|
console.log('[Test] Verified file exists on filesystem');
|
||||||
|
|
||||||
|
// Read actual file content to double-check
|
||||||
|
const fileContent = fs.readFileSync(TEST_FILE_PATH, 'utf-8');
|
||||||
|
console.log('[Test] Actual file content:', fileContent);
|
||||||
|
expect(fileContent).toContain('UPDATED');
|
||||||
|
console.log('[Test] Verified file contains UPDATED content');
|
||||||
|
|
||||||
|
console.log('[Test] Auto-accept E2E test completed successfully!');
|
||||||
|
// File cleanup happens in afterEach
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,29 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
// Path to the test file that gets created during the test
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const TEST_FILE_PATH = path.resolve(__dirname, '../../server/test-opencode.md');
|
||||||
|
|
||||||
test.describe('OpenCode File Workflow', () => {
|
test.describe('OpenCode File Workflow', () => {
|
||||||
|
// Clean up any leftover test file before each test
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
if (fs.existsSync(TEST_FILE_PATH)) {
|
||||||
|
fs.unlinkSync(TEST_FILE_PATH);
|
||||||
|
console.log('[Setup] Cleaned up leftover test file:', TEST_FILE_PATH);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up test file after each test (even on failure)
|
||||||
|
test.afterEach(async () => {
|
||||||
|
if (fs.existsSync(TEST_FILE_PATH)) {
|
||||||
|
fs.unlinkSync(TEST_FILE_PATH);
|
||||||
|
console.log('[Teardown] Cleaned up test file:', TEST_FILE_PATH);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('create, read, and delete file without permission prompts', async ({ page }) => {
|
test('create, read, and delete file without permission prompts', async ({ page }) => {
|
||||||
// Increase timeout for this test since it involves multiple AI interactions
|
// Increase timeout for this test since it involves multiple AI interactions
|
||||||
test.setTimeout(180000);
|
test.setTimeout(180000);
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { E2E_BACKEND_URL } from '../playwright.config';
|
|
||||||
|
|
||||||
test.describe('Claude Working Directory Auto-Update', () => {
|
|
||||||
test('working directory updates automatically after cd command', async ({ page, request }) => {
|
|
||||||
// Increase timeout for this test since it involves real Claude interaction
|
|
||||||
test.setTimeout(180000);
|
|
||||||
|
|
||||||
// Enable console logging to debug issues
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
console.log(`[Browser ${msg.type()}]`, msg.text());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log WebSocket frames
|
|
||||||
page.on('websocket', (ws) => {
|
|
||||||
console.log(`[WebSocket] Connected to ${ws.url()}`);
|
|
||||||
ws.on('framesent', (frame) => console.log(`[WS Sent]`, frame.payload));
|
|
||||||
ws.on('framereceived', (frame) => console.log(`[WS Received]`, frame.payload));
|
|
||||||
ws.on('close', () => console.log('[WebSocket] Closed'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log network requests to /api
|
|
||||||
page.on('request', (req) => {
|
|
||||||
if (req.url().includes('/api')) {
|
|
||||||
console.log(`[Request] ${req.method()} ${req.url()}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
page.on('response', (response) => {
|
|
||||||
if (response.url().includes('/api')) {
|
|
||||||
console.log(`[Response] ${response.status()} ${response.url()}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1. Navigate to homepage
|
|
||||||
await page.goto('/');
|
|
||||||
await expect(page).toHaveTitle(/Spiceflow/i);
|
|
||||||
|
|
||||||
// 2. Click the + button to open new session menu
|
|
||||||
const createButton = page.locator('button[title="New Session"]');
|
|
||||||
await expect(createButton).toBeVisible();
|
|
||||||
await createButton.click();
|
|
||||||
|
|
||||||
// 3. Select Claude Code from the dropdown
|
|
||||||
const claudeOption = page.locator('button:has-text("Claude Code")');
|
|
||||||
await expect(claudeOption).toBeVisible();
|
|
||||||
await claudeOption.click();
|
|
||||||
|
|
||||||
// 4. Wait for navigation to session page
|
|
||||||
await page.waitForURL(/\/session\/.+/);
|
|
||||||
const sessionUrl = page.url();
|
|
||||||
const sessionId = sessionUrl.split('/session/')[1];
|
|
||||||
console.log('[Test] Navigated to session page:', sessionUrl);
|
|
||||||
console.log('[Test] Session ID:', sessionId);
|
|
||||||
|
|
||||||
// 5. Wait for the page to load (no loading spinner)
|
|
||||||
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
|
|
||||||
|
|
||||||
// 6. Verify we see the empty message state
|
|
||||||
await expect(page.locator('text=No messages yet')).toBeVisible();
|
|
||||||
|
|
||||||
// 7. Send a message to Claude asking it to cd into repos (natural language)
|
|
||||||
// Claude should run the cd command and ideally output the current directory
|
|
||||||
const textarea = page.locator('textarea');
|
|
||||||
await expect(textarea).toBeVisible();
|
|
||||||
await textarea.fill('change directory to ~/repos and tell me where you are now');
|
|
||||||
|
|
||||||
// 8. Click the send button
|
|
||||||
const sendButton = page.locator('button[type="submit"]');
|
|
||||||
await expect(sendButton).toBeEnabled();
|
|
||||||
await sendButton.click();
|
|
||||||
|
|
||||||
// 9. Wait for streaming to complete
|
|
||||||
const bouncingDots = page.locator('.animate-bounce');
|
|
||||||
// Only look for pulsing cursor inside markdown-content (not the header status indicator)
|
|
||||||
const pulsingCursor = page.locator('.markdown-content .animate-pulse');
|
|
||||||
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
|
|
||||||
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
|
|
||||||
console.log('[Test] Message complete');
|
|
||||||
|
|
||||||
// 10. The working directory bar should now show the repos path (automatically updated)
|
|
||||||
// The working dir bar is in a specific container with bg-zinc-900/50
|
|
||||||
const workingDirBar = page.locator('div.bg-zinc-900\\/50');
|
|
||||||
await expect(workingDirBar).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// The working dir text is in a span.truncate.font-mono inside the bar
|
|
||||||
const workingDirText = workingDirBar.locator('span.truncate.font-mono');
|
|
||||||
await expect(workingDirText).toBeVisible();
|
|
||||||
|
|
||||||
// 11. Wait for the working directory to contain 'repos' (automatic update from tool result)
|
|
||||||
await expect(workingDirText).toContainText('repos', { timeout: 10000 });
|
|
||||||
const displayedWorkingDir = await workingDirText.textContent();
|
|
||||||
console.log('[Test] Working directory in UI:', displayedWorkingDir);
|
|
||||||
expect(displayedWorkingDir).toContain('repos');
|
|
||||||
|
|
||||||
// 12. Verify the working directory in the database via API
|
|
||||||
const sessionResponse = await request.get(`${E2E_BACKEND_URL}/api/sessions/${sessionId}`);
|
|
||||||
expect(sessionResponse.ok()).toBeTruthy();
|
|
||||||
const sessionData = await sessionResponse.json();
|
|
||||||
console.log('[Test] Session data from API:', JSON.stringify(sessionData, null, 2));
|
|
||||||
|
|
||||||
// The API returns session data directly (not nested under 'session')
|
|
||||||
const dbWorkingDir = sessionData['working-dir'] || sessionData.workingDir || '';
|
|
||||||
console.log('[Test] Working directory from DB:', dbWorkingDir);
|
|
||||||
|
|
||||||
// DB should have the repos path
|
|
||||||
expect(dbWorkingDir).toContain('repos');
|
|
||||||
|
|
||||||
// UI and DB should match
|
|
||||||
expect(displayedWorkingDir).toBe(dbWorkingDir);
|
|
||||||
|
|
||||||
console.log('[Test] Auto-sync test passed - working directory automatically updated to repos path');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { E2E_BACKEND_URL } from '../playwright.config';
|
||||||
|
|
||||||
|
// Helper to clean up any existing tmux sessions via UI
|
||||||
|
async function cleanupTmuxSessions(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Delete all tmux session cards
|
||||||
|
while (true) {
|
||||||
|
const tmuxCards = page.locator('a.card').filter({ has: page.locator('span:text-is("tmux")') });
|
||||||
|
const count = await tmuxCards.count();
|
||||||
|
if (count === 0) break;
|
||||||
|
|
||||||
|
// Set up one-time dialog handler for this deletion
|
||||||
|
page.once('dialog', async (dialog) => {
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteButton = tmuxCards.first().locator('button[title="Delete session"]');
|
||||||
|
await deleteButton.click();
|
||||||
|
await page.waitForTimeout(500); // Wait for deletion to process
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Tmux Terminal Session', () => {
|
||||||
|
test('create tmux session and run shell commands', async ({ page }) => {
|
||||||
|
// Clean up any stale tmux sessions first
|
||||||
|
await cleanupTmuxSessions(page);
|
||||||
|
|
||||||
|
// Enable console logging for debugging
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
console.log(`[Browser ${msg.type()}]`, msg.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log network requests to /api
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.url().includes('/api')) {
|
||||||
|
console.log(`[Request] ${request.method()} ${request.url()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on('response', (response) => {
|
||||||
|
if (response.url().includes('/api')) {
|
||||||
|
console.log(`[Response] ${response.status()} ${response.url()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Navigate to homepage
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveTitle(/Spiceflow/i);
|
||||||
|
|
||||||
|
// 2. Click the + button to open new session menu
|
||||||
|
const createButton = page.locator('button[title="New Session"]');
|
||||||
|
await expect(createButton).toBeVisible();
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
// 3. Select Terminal (tmux) from the dropdown
|
||||||
|
const tmuxOption = page.locator('button:has-text("Terminal (tmux)")');
|
||||||
|
await expect(tmuxOption).toBeVisible();
|
||||||
|
await tmuxOption.click();
|
||||||
|
|
||||||
|
// 4. Wait for navigation to session page
|
||||||
|
await page.waitForURL(/\/session\/.+/);
|
||||||
|
const sessionUrl = page.url();
|
||||||
|
const sessionId = decodeURIComponent(sessionUrl.split('/session/')[1]);
|
||||||
|
console.log('[Test] Navigated to session page:', sessionUrl);
|
||||||
|
|
||||||
|
// 5. Wait for terminal view to load (should see the terminal pre element)
|
||||||
|
const terminalOutput = page.locator('pre.text-green-400');
|
||||||
|
await expect(terminalOutput).toBeVisible({ timeout: 10000 });
|
||||||
|
console.log('[Test] Terminal view loaded');
|
||||||
|
|
||||||
|
// 6. Verify the session status indicator shows active
|
||||||
|
const statusIndicator = page.locator('.bg-green-500').first();
|
||||||
|
await expect(statusIndicator).toBeVisible({ timeout: 5000 });
|
||||||
|
console.log('[Test] Session is active');
|
||||||
|
|
||||||
|
// 7. Verify the terminal badge shows "TERMINAL"
|
||||||
|
const terminalBadge = page.locator('text=TERMINAL');
|
||||||
|
await expect(terminalBadge).toBeVisible();
|
||||||
|
|
||||||
|
// 8. Find the command input
|
||||||
|
const commandInput = page.locator('input[placeholder*="Type command"]');
|
||||||
|
await expect(commandInput).toBeVisible();
|
||||||
|
await expect(commandInput).toBeEnabled();
|
||||||
|
|
||||||
|
// 9. Run `pwd` command
|
||||||
|
await commandInput.fill('pwd');
|
||||||
|
await commandInput.press('Enter');
|
||||||
|
console.log('[Test] Sent pwd command');
|
||||||
|
|
||||||
|
// 10. Wait for output and verify it contains a path (starts with /)
|
||||||
|
await page.waitForTimeout(1000); // Give time for command to execute
|
||||||
|
let terminalContent = await terminalOutput.textContent();
|
||||||
|
console.log('[Test] Terminal content after pwd:', terminalContent);
|
||||||
|
|
||||||
|
// The output should contain a path starting with /
|
||||||
|
await expect(async () => {
|
||||||
|
terminalContent = await terminalOutput.textContent();
|
||||||
|
expect(terminalContent).toMatch(/\/[a-zA-Z]/);
|
||||||
|
}).toPass({ timeout: 5000 });
|
||||||
|
console.log('[Test] pwd output verified - contains path');
|
||||||
|
|
||||||
|
// 11. Run `ls -al` command
|
||||||
|
await commandInput.fill('ls -al');
|
||||||
|
await commandInput.press('Enter');
|
||||||
|
console.log('[Test] Sent ls -al command');
|
||||||
|
|
||||||
|
// 12. Wait for output and verify it contains typical ls -al output
|
||||||
|
await page.waitForTimeout(1000); // Give time for command to execute
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
terminalContent = await terminalOutput.textContent();
|
||||||
|
// ls -al output typically contains "total" at the start and permission strings like "drwx"
|
||||||
|
expect(terminalContent).toMatch(/total \d+/);
|
||||||
|
}).toPass({ timeout: 5000 });
|
||||||
|
console.log('[Test] ls -al output verified - contains "total" line');
|
||||||
|
|
||||||
|
// 13. Verify the output contains directory entries with permissions
|
||||||
|
await expect(async () => {
|
||||||
|
terminalContent = await terminalOutput.textContent();
|
||||||
|
// Should see permission patterns like drwx or -rw-
|
||||||
|
expect(terminalContent).toMatch(/[d-][rwx-]{9}/);
|
||||||
|
}).toPass({ timeout: 5000 });
|
||||||
|
console.log('[Test] ls -al output verified - contains permission strings');
|
||||||
|
|
||||||
|
// 14. Verify the output contains the . and .. directory entries
|
||||||
|
terminalContent = await terminalOutput.textContent();
|
||||||
|
// The . entry appears at end of line as " .\n" or " ." followed by newline
|
||||||
|
expect(terminalContent).toMatch(/ \.$/m);
|
||||||
|
console.log('[Test] ls -al output verified - contains current directory entry');
|
||||||
|
|
||||||
|
console.log('[Test] Tmux terminal test completed successfully');
|
||||||
|
|
||||||
|
// 15. Cleanup: Delete the session via UI
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveTitle(/Spiceflow/i);
|
||||||
|
|
||||||
|
// Find the tmux session card and delete it
|
||||||
|
const tmuxCards = page.locator('a.card').filter({ has: page.locator('span:text-is("tmux")') });
|
||||||
|
await expect(tmuxCards).toHaveCount(1);
|
||||||
|
|
||||||
|
// Set up one-time dialog handler
|
||||||
|
page.once('dialog', async (dialog) => {
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteButton = tmuxCards.first().locator('button[title="Delete session"]');
|
||||||
|
await deleteButton.click();
|
||||||
|
|
||||||
|
// Wait for no tmux cards to remain
|
||||||
|
await expect(tmuxCards).toHaveCount(0, { timeout: 5000 });
|
||||||
|
console.log('[Test] Cleanup: Session deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting tmux session kills the tmux process', async ({ page, request }) => {
|
||||||
|
// Clean up any stale tmux sessions first
|
||||||
|
await cleanupTmuxSessions(page);
|
||||||
|
|
||||||
|
// 1. Navigate to homepage and create a tmux session
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveTitle(/Spiceflow/i);
|
||||||
|
|
||||||
|
const createButton = page.locator('button[title="New Session"]');
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
const tmuxOption = page.locator('button:has-text("Terminal (tmux)")');
|
||||||
|
await tmuxOption.click();
|
||||||
|
|
||||||
|
// Wait for navigation to session page
|
||||||
|
await page.waitForURL(/\/session\/.+/);
|
||||||
|
const sessionUrl = page.url();
|
||||||
|
// URL decode the session ID since it contains special characters
|
||||||
|
const sessionId = decodeURIComponent(sessionUrl.split('/session/')[1]);
|
||||||
|
console.log('[Test] Created session:', sessionId);
|
||||||
|
|
||||||
|
// 2. Wait for terminal to load and session to be active
|
||||||
|
const terminalOutput = page.locator('pre.text-green-400');
|
||||||
|
await expect(terminalOutput).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// 3. For ephemeral tmux sessions, the session ID IS the tmux session name
|
||||||
|
const tmuxSessionName = sessionId;
|
||||||
|
console.log('[Test] Tmux session name:', tmuxSessionName);
|
||||||
|
|
||||||
|
// 4. Verify the tmux session exists by running a command
|
||||||
|
const commandInput = page.locator('input[placeholder*="Type command"]');
|
||||||
|
await commandInput.fill('echo "session-alive"');
|
||||||
|
await commandInput.press('Enter');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// 5. Go back to home page
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveTitle(/Spiceflow/i);
|
||||||
|
|
||||||
|
// 6. Find and delete the session by provider badge (more generic than session ID)
|
||||||
|
const tmuxCards = page.locator('a.card').filter({ has: page.locator('span:text-is("tmux")') });
|
||||||
|
await expect(tmuxCards).toHaveCount(1);
|
||||||
|
|
||||||
|
// Set up one-time dialog handler to accept the confirmation
|
||||||
|
page.once('dialog', async (dialog) => {
|
||||||
|
console.log('[Test] Dialog appeared:', dialog.message());
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click the delete button (X icon) on the session card
|
||||||
|
const deleteButton = tmuxCards.first().locator('button[title="Delete session"]');
|
||||||
|
await deleteButton.click();
|
||||||
|
|
||||||
|
// 7. Wait for session to be deleted (no more tmux cards)
|
||||||
|
await expect(tmuxCards).toHaveCount(0, { timeout: 5000 });
|
||||||
|
console.log('[Test] Session deleted from UI');
|
||||||
|
|
||||||
|
// 8. Verify the tmux session was killed by checking the API
|
||||||
|
// The session should no longer exist
|
||||||
|
const sessionResponse = await request.get(`${E2E_BACKEND_URL}/api/sessions/${sessionId}`);
|
||||||
|
expect(sessionResponse.status()).toBe(404);
|
||||||
|
console.log('[Test] Session no longer exists in API');
|
||||||
|
|
||||||
|
// 9. Verify the tmux session is no longer alive
|
||||||
|
// We can check this by trying to get terminal content - it should fail
|
||||||
|
const terminalCheckResponse = await request.get(`${E2E_BACKEND_URL}/api/sessions/${sessionId}/terminal`);
|
||||||
|
expect(terminalCheckResponse.status()).toBe(404);
|
||||||
|
console.log('[Test] Tmux session properly cleaned up');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -92,13 +92,5 @@ test.describe('Claude Chat Workflow', () => {
|
|||||||
console.log('[Test] Assistant response text:', responseText);
|
console.log('[Test] Assistant response text:', responseText);
|
||||||
expect(responseText).toBeTruthy();
|
expect(responseText).toBeTruthy();
|
||||||
expect(responseText!.length).toBeGreaterThan(0);
|
expect(responseText!.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// 13. Verify working directory indicator appears
|
|
||||||
// The working directory should be captured from the init event and displayed
|
|
||||||
const workingDirIndicator = page.locator('.font-mono').filter({ hasText: /^\// }).first();
|
|
||||||
await expect(workingDirIndicator).toBeVisible({ timeout: 5000 });
|
|
||||||
const workingDirText = await workingDirIndicator.textContent();
|
|
||||||
console.log('[Test] Working directory displayed:', workingDirText);
|
|
||||||
expect(workingDirText).toMatch(/^\//); // Should start with /
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -100,13 +100,5 @@ test.describe('OpenCode Chat Workflow', () => {
|
|||||||
console.log('[Test] Assistant response text:', responseText);
|
console.log('[Test] Assistant response text:', responseText);
|
||||||
expect(responseText).toBeTruthy();
|
expect(responseText).toBeTruthy();
|
||||||
expect(responseText!.length).toBeGreaterThan(0);
|
expect(responseText!.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// 13. Verify working directory indicator appears
|
|
||||||
// The working directory should be captured from the init event and displayed
|
|
||||||
const workingDirIndicator = page.locator('.font-mono').filter({ hasText: /^\// }).first();
|
|
||||||
await expect(workingDirIndicator).toBeVisible({ timeout: 5000 });
|
|
||||||
const workingDirText = await workingDirIndicator.textContent();
|
|
||||||
console.log('[Test] Working directory displayed:', workingDirText);
|
|
||||||
expect(workingDirText).toMatch(/^\//); // Should start with /
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-1
@@ -23,7 +23,12 @@
|
|||||||
aero/aero {:mvn/version "1.1.6"}
|
aero/aero {:mvn/version "1.1.6"}
|
||||||
mount/mount {:mvn/version "0.1.18"}
|
mount/mount {:mvn/version "0.1.18"}
|
||||||
org.clojure/tools.logging {:mvn/version "1.2.4"}
|
org.clojure/tools.logging {:mvn/version "1.2.4"}
|
||||||
ch.qos.logback/logback-classic {:mvn/version "1.4.11"}}
|
ch.qos.logback/logback-classic {:mvn/version "1.4.11"}
|
||||||
|
|
||||||
|
;; Web Push notifications
|
||||||
|
buddy/buddy-core {:mvn/version "1.11.423"}
|
||||||
|
buddy/buddy-sign {:mvn/version "3.5.351"}
|
||||||
|
clj-http/clj-http {:mvn/version "3.12.3"}}
|
||||||
|
|
||||||
:aliases
|
:aliases
|
||||||
{:run {:main-opts ["-m" "spiceflow.core"]}
|
{:run {:main-opts ["-m" "spiceflow.core"]}
|
||||||
|
|||||||
@@ -102,6 +102,12 @@
|
|||||||
"Write" (str "create " (:file_path tool_input))
|
"Write" (str "create " (:file_path tool_input))
|
||||||
"Edit" (str "edit " (:file_path tool_input))
|
"Edit" (str "edit " (:file_path tool_input))
|
||||||
"Read" (str "read " (:file_path tool_input))
|
"Read" (str "read " (:file_path tool_input))
|
||||||
|
"NotebookEdit" (str (:edit_mode tool_input "edit") " cell in " (:notebook_path tool_input))
|
||||||
|
"WebFetch" (str "fetch " (:url tool_input))
|
||||||
|
"WebSearch" (str "search: " (:query tool_input))
|
||||||
|
"Skill" (str "run /" (:skill tool_input) (when-let [args (:args tool_input)] (str " " args)))
|
||||||
|
"ExitPlanMode" "exit plan mode"
|
||||||
|
"Task" (str "spawn " (:subagent_type tool_input) " agent")
|
||||||
(pr-str tool_input))})
|
(pr-str tool_input))})
|
||||||
denials)})))
|
denials)})))
|
||||||
;; Unknown type
|
;; Unknown type
|
||||||
@@ -167,6 +173,7 @@
|
|||||||
(let [json-msg (json/write-value-as-string {:type "user"
|
(let [json-msg (json/write-value-as-string {:type "user"
|
||||||
:message {:role "user"
|
:message {:role "user"
|
||||||
:content message}})]
|
:content message}})]
|
||||||
|
(log/debug "[Claude stdin]" json-msg)
|
||||||
(.write stdin json-msg)
|
(.write stdin json-msg)
|
||||||
(.newLine stdin)
|
(.newLine stdin)
|
||||||
(.flush stdin)
|
(.flush stdin)
|
||||||
@@ -177,17 +184,21 @@
|
|||||||
|
|
||||||
(read-stream [this {:keys [stdout]} callback]
|
(read-stream [this {:keys [stdout]} callback]
|
||||||
(try
|
(try
|
||||||
|
(log/debug "[Claude] Starting stdout read loop")
|
||||||
(loop []
|
(loop []
|
||||||
(when-let [line (.readLine stdout)]
|
(when-let [line (.readLine stdout)]
|
||||||
|
(log/debug "[Claude stdout]" line)
|
||||||
(let [parsed (proto/parse-output this line)]
|
(let [parsed (proto/parse-output this line)]
|
||||||
(when parsed
|
(when parsed
|
||||||
|
(log/debug "[Claude parsed]" (:event parsed))
|
||||||
(callback parsed))
|
(callback parsed))
|
||||||
;; Stop reading after result event - response is complete
|
;; Stop reading after result event - response is complete
|
||||||
(if (= :result (:event parsed))
|
(if (= :result (:event parsed))
|
||||||
(log/debug "Received result, stopping stream read")
|
(log/debug "[Claude] Received result, stopping stream read")
|
||||||
(recur)))))
|
(recur)))))
|
||||||
|
(log/debug "[Claude] stdout read loop ended")
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
(log/debug "Stream ended:" (.getMessage e)))))
|
(log/debug "[Claude] Stream ended:" (.getMessage e)))))
|
||||||
|
|
||||||
(kill-process [_ {:keys [process]}]
|
(kill-process [_ {:keys [process]}]
|
||||||
(when process
|
(when process
|
||||||
|
|||||||
@@ -157,7 +157,7 @@
|
|||||||
;; Wrap with script -qc to create a pseudo-terminal
|
;; Wrap with script -qc to create a pseudo-terminal
|
||||||
;; This forces Go to flush stdout properly (Go binaries ignore stdbuf)
|
;; This forces Go to flush stdout properly (Go binaries ignore stdbuf)
|
||||||
args ["script" "-qc" opencode-cmd "/dev/null"]
|
args ["script" "-qc" opencode-cmd "/dev/null"]
|
||||||
_ (log/info "Starting OpenCode with args:" args)
|
_ (log/debug "[OpenCode] Starting with args:" args)
|
||||||
pb (ProcessBuilder. (vec args))]
|
pb (ProcessBuilder. (vec args))]
|
||||||
;; Set working directory
|
;; Set working directory
|
||||||
(when working-dir
|
(when working-dir
|
||||||
@@ -169,6 +169,7 @@
|
|||||||
(let [process (.start pb)
|
(let [process (.start pb)
|
||||||
stdout (BufferedReader. (InputStreamReader. (.getInputStream process) StandardCharsets/UTF_8))
|
stdout (BufferedReader. (InputStreamReader. (.getInputStream process) StandardCharsets/UTF_8))
|
||||||
stderr (BufferedReader. (InputStreamReader. (.getErrorStream process) StandardCharsets/UTF_8))]
|
stderr (BufferedReader. (InputStreamReader. (.getErrorStream process) StandardCharsets/UTF_8))]
|
||||||
|
(log/debug "[OpenCode] Process started successfully")
|
||||||
;; Update the handle with the running process
|
;; Update the handle with the running process
|
||||||
;; Note: We're mutating the handle here by storing process info
|
;; Note: We're mutating the handle here by storing process info
|
||||||
;; The caller should use the returned handle
|
;; The caller should use the returned handle
|
||||||
@@ -181,7 +182,7 @@
|
|||||||
nil)))
|
nil)))
|
||||||
|
|
||||||
(read-stream [this {:keys [stdout stderr process]} callback]
|
(read-stream [this {:keys [stdout stderr process]} callback]
|
||||||
(log/info "read-stream starting, stdout:" (boolean stdout) "process:" (boolean process) "process-alive:" (when process (.isAlive process)))
|
(log/debug "[OpenCode] read-stream starting, stdout:" (boolean stdout) "process:" (boolean process) "process-alive:" (when process (.isAlive process)))
|
||||||
(try
|
(try
|
||||||
;; Start a thread to log stderr
|
;; Start a thread to log stderr
|
||||||
(when stderr
|
(when stderr
|
||||||
@@ -189,20 +190,19 @@
|
|||||||
(try
|
(try
|
||||||
(loop []
|
(loop []
|
||||||
(when-let [line (.readLine stderr)]
|
(when-let [line (.readLine stderr)]
|
||||||
(log/info "[OpenCode stderr]" line)
|
(log/debug "[OpenCode stderr]" line)
|
||||||
(recur)))
|
(recur)))
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
(log/info "Stderr stream ended:" (.getMessage e))))))
|
(log/debug "[OpenCode] Stderr stream ended:" (.getMessage e))))))
|
||||||
|
|
||||||
;; Read stdout for JSON events
|
;; Read stdout for JSON events
|
||||||
(log/info "Starting stdout read loop")
|
(log/debug "[OpenCode] Starting stdout read loop")
|
||||||
(loop []
|
(loop []
|
||||||
(log/debug "Waiting for line from stdout...")
|
|
||||||
(when-let [line (.readLine stdout)]
|
(when-let [line (.readLine stdout)]
|
||||||
(log/info "[OpenCode stdout]" line)
|
(log/debug "[OpenCode stdout]" line)
|
||||||
(let [parsed (proto/parse-output this line)]
|
(let [parsed (proto/parse-output this line)]
|
||||||
(when parsed
|
(when parsed
|
||||||
(log/info "Parsed event:" (:event parsed))
|
(log/debug "[OpenCode parsed]" (:event parsed))
|
||||||
(callback parsed))
|
(callback parsed))
|
||||||
;; Continue reading unless we hit a terminal event
|
;; Continue reading unless we hit a terminal event
|
||||||
;; Note: step_finish with reason "tool-calls" is NOT terminal - OpenCode
|
;; Note: step_finish with reason "tool-calls" is NOT terminal - OpenCode
|
||||||
@@ -211,15 +211,15 @@
|
|||||||
(if (or (= :error (:event parsed))
|
(if (or (= :error (:event parsed))
|
||||||
(and (= :result (:event parsed))
|
(and (= :result (:event parsed))
|
||||||
(not= "tool-calls" (:stop-reason parsed))))
|
(not= "tool-calls" (:stop-reason parsed))))
|
||||||
(log/info "Received terminal event, stopping stream read. stop-reason:" (:stop-reason parsed))
|
(log/debug "[OpenCode] Received terminal event, stopping stream read. stop-reason:" (:stop-reason parsed))
|
||||||
(recur)))))
|
(recur)))))
|
||||||
(log/info "stdout read loop ended (nil line)")
|
(log/debug "[OpenCode] stdout read loop ended")
|
||||||
|
|
||||||
;; Wait for process to complete
|
;; Wait for process to complete
|
||||||
(when process
|
(when process
|
||||||
(log/info "Waiting for process to complete")
|
(log/debug "[OpenCode] Waiting for process to complete")
|
||||||
(.waitFor process)
|
(.waitFor process)
|
||||||
(log/info "Process completed with exit code:" (.exitValue process)))
|
(log/debug "[OpenCode] Process completed with exit code:" (.exitValue process)))
|
||||||
|
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
(log/error "Stream error:" (.getMessage e) (class e)))))
|
(log/error "Stream error:" (.getMessage e) (class e)))))
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
(ns spiceflow.adapters.tmux
|
||||||
|
"Adapter for tmux terminal sessions.
|
||||||
|
|
||||||
|
Provides shell access without an LLM. Users can interact with tmux sessions
|
||||||
|
as if they were chat sessions - commands are sent to the shell and output
|
||||||
|
is streamed back as messages."
|
||||||
|
(:require [spiceflow.adapters.protocol :as proto]
|
||||||
|
[clojure.java.io :as io]
|
||||||
|
[clojure.java.shell :as shell]
|
||||||
|
[clojure.string :as str]
|
||||||
|
[clojure.tools.logging :as log])
|
||||||
|
(:import [java.io BufferedReader FileReader File RandomAccessFile]
|
||||||
|
[java.util UUID]))
|
||||||
|
|
||||||
|
(def ^:private session-prefix "spiceflow-")
|
||||||
|
(def ^:private output-dir "/tmp")
|
||||||
|
(def ^:private end-marker-prefix "---SPICEFLOW-END-")
|
||||||
|
|
||||||
|
;; Word lists for generating readable random session names (like Docker containers)
|
||||||
|
(def ^:private adjectives
|
||||||
|
["brave" "calm" "clever" "cool" "eager" "fancy" "happy" "jolly" "kind"
|
||||||
|
"lively" "merry" "neat" "nice" "proud" "quick" "sharp" "swift" "warm"
|
||||||
|
"wise" "bold" "bright" "fair" "keen" "mild" "pure" "rare" "safe" "sure"])
|
||||||
|
|
||||||
|
(def ^:private nouns
|
||||||
|
["fox" "owl" "bee" "elk" "ant" "bat" "cat" "dog" "eel" "hen" "jay"
|
||||||
|
"koi" "ram" "yak" "ape" "cod" "cow" "doe" "gnu" "hog" "orb" "oak"
|
||||||
|
"pine" "fern" "moss" "leaf" "rose" "wave" "star" "moon" "sun" "wind"])
|
||||||
|
|
||||||
|
(defn- generate-random-name
|
||||||
|
"Generate a random adjective-noun name for a tmux session"
|
||||||
|
[]
|
||||||
|
(let [adj (rand-nth adjectives)
|
||||||
|
noun (rand-nth nouns)
|
||||||
|
suffix (format "%04d" (rand-int 10000))]
|
||||||
|
(str adj "-" noun "-" suffix)))
|
||||||
|
|
||||||
|
;; Pattern to match ANSI escape sequences
|
||||||
|
(def ^:private ansi-pattern #"\u001b\[[0-9;?]*[a-zA-Z]|\u001b\].*?\u0007")
|
||||||
|
|
||||||
|
(defn- strip-ansi
|
||||||
|
"Remove ANSI escape sequences from a string"
|
||||||
|
[s]
|
||||||
|
(str/replace s ansi-pattern ""))
|
||||||
|
|
||||||
|
(defn- tmux-session-name
|
||||||
|
"Generate tmux session name from spiceflow session id"
|
||||||
|
[session-id]
|
||||||
|
(str session-prefix session-id))
|
||||||
|
|
||||||
|
(defn- output-file-path
|
||||||
|
"Get path to output file for a session"
|
||||||
|
[session-name]
|
||||||
|
(str output-dir "/spiceflow-tmux-" session-name ".log"))
|
||||||
|
|
||||||
|
(defn- run-tmux
|
||||||
|
"Run a tmux command and return result"
|
||||||
|
[& args]
|
||||||
|
(let [result (apply shell/sh "tmux" args)]
|
||||||
|
(when (zero? (:exit result))
|
||||||
|
(str/trim (:out result)))))
|
||||||
|
|
||||||
|
(defn- tmux-session-exists?
|
||||||
|
"Check if a tmux session exists"
|
||||||
|
[session-name]
|
||||||
|
(some-> (run-tmux "has-session" "-t" session-name)
|
||||||
|
(= "")))
|
||||||
|
|
||||||
|
(defn- parse-session-list
|
||||||
|
"Parse output from 'tmux list-sessions'"
|
||||||
|
[output]
|
||||||
|
(when output
|
||||||
|
(->> (str/split-lines output)
|
||||||
|
(filter #(str/starts-with? % session-prefix))
|
||||||
|
(map (fn [line]
|
||||||
|
;; Format: "session_name:path"
|
||||||
|
(let [[name path] (str/split line #":" 2)]
|
||||||
|
{:external-id name
|
||||||
|
:provider :tmux
|
||||||
|
:title name
|
||||||
|
:working-dir (or path (System/getProperty "user.home"))}))))))
|
||||||
|
|
||||||
|
(defn- is-prompt-line?
|
||||||
|
"Check if a line looks like a shell prompt (ends with $ or #)"
|
||||||
|
[line]
|
||||||
|
(boolean (re-find #"[$#]\s*$" (str/trim line))))
|
||||||
|
|
||||||
|
(defn- is-echo-command?
|
||||||
|
"Check if a line is our echo command for the end marker"
|
||||||
|
[line]
|
||||||
|
(str/includes? line (str "echo '" end-marker-prefix)))
|
||||||
|
|
||||||
|
(defn- tail-file
|
||||||
|
"Tail a file for new content, calling callback with each new line.
|
||||||
|
Stops when end-marker is seen. Returns the marker UUID if found."
|
||||||
|
[file-path end-marker callback timeout-ms original-cmd]
|
||||||
|
(let [file (File. ^String file-path)
|
||||||
|
start-time (System/currentTimeMillis)
|
||||||
|
marker-found (atom nil)
|
||||||
|
seen-first-line (atom false)]
|
||||||
|
;; Wait for file to exist
|
||||||
|
(while (and (not (.exists file))
|
||||||
|
(< (- (System/currentTimeMillis) start-time) timeout-ms))
|
||||||
|
(Thread/sleep 50))
|
||||||
|
(when (.exists file)
|
||||||
|
(with-open [raf (RandomAccessFile. file "r")]
|
||||||
|
(loop [last-pos 0]
|
||||||
|
(let [current-len (.length raf)]
|
||||||
|
(when (> current-len last-pos)
|
||||||
|
(.seek raf last-pos)
|
||||||
|
(let [new-bytes (byte-array (- current-len last-pos))]
|
||||||
|
(.readFully raf new-bytes)
|
||||||
|
(let [new-content (String. new-bytes "UTF-8")
|
||||||
|
lines (str/split-lines new-content)]
|
||||||
|
(doseq [line lines]
|
||||||
|
(let [clean-line (strip-ansi line)]
|
||||||
|
(cond
|
||||||
|
;; Found end marker - extract UUID and stop
|
||||||
|
(str/includes? clean-line end-marker-prefix)
|
||||||
|
(let [marker-match (re-find (re-pattern (str end-marker-prefix "([a-f0-9-]+)---")) clean-line)]
|
||||||
|
(when marker-match
|
||||||
|
(reset! marker-found (second marker-match))))
|
||||||
|
|
||||||
|
;; Skip blank lines, prompt lines, echo command, and echoed user command
|
||||||
|
(or (str/blank? clean-line)
|
||||||
|
(is-prompt-line? clean-line)
|
||||||
|
(is-echo-command? clean-line)
|
||||||
|
;; Skip the first line if it's the echoed command
|
||||||
|
(and (not @seen-first-line)
|
||||||
|
original-cmd
|
||||||
|
(str/includes? clean-line original-cmd)))
|
||||||
|
(reset! seen-first-line true)
|
||||||
|
|
||||||
|
;; Regular output line
|
||||||
|
:else
|
||||||
|
(do
|
||||||
|
(reset! seen-first-line true)
|
||||||
|
(callback {:event :content-delta :text (str clean-line "\n")}))))))))
|
||||||
|
;; Check if we should stop
|
||||||
|
(if @marker-found
|
||||||
|
@marker-found
|
||||||
|
(if (< (- (System/currentTimeMillis) start-time) timeout-ms)
|
||||||
|
(do
|
||||||
|
(Thread/sleep 50)
|
||||||
|
(recur (.length raf)))
|
||||||
|
nil))))))))
|
||||||
|
|
||||||
|
(defrecord TmuxAdapter []
|
||||||
|
proto/AgentAdapter
|
||||||
|
|
||||||
|
(provider-name [_] :tmux)
|
||||||
|
|
||||||
|
(discover-sessions [_]
|
||||||
|
;; List only spiceflow-managed tmux sessions
|
||||||
|
(if-let [output (run-tmux "list-sessions" "-F" "#{session_name}:#{pane_current_path}")]
|
||||||
|
(vec (parse-session-list output))
|
||||||
|
[]))
|
||||||
|
|
||||||
|
(spawn-session [_ session-id opts]
|
||||||
|
(let [{:keys [working-dir]} opts
|
||||||
|
;; If session-id is already a spiceflow- prefixed name, use it directly
|
||||||
|
;; Otherwise, create a new session with a random readable name
|
||||||
|
session-name (if (and session-id (str/starts-with? session-id session-prefix))
|
||||||
|
session-id
|
||||||
|
(tmux-session-name (or session-id (generate-random-name))))
|
||||||
|
output-file (output-file-path session-name)
|
||||||
|
work-dir (or working-dir (System/getProperty "user.home"))]
|
||||||
|
;; Check if session already exists
|
||||||
|
(if (run-tmux "has-session" "-t" session-name)
|
||||||
|
;; Session exists, just set up pipe-pane
|
||||||
|
(do
|
||||||
|
(log/debug "[Tmux] Reconnecting to existing session:" session-name)
|
||||||
|
;; Clear and restart pipe-pane
|
||||||
|
(run-tmux "pipe-pane" "-t" session-name)
|
||||||
|
(spit output-file "") ;; Clear the log file
|
||||||
|
(run-tmux "pipe-pane" "-t" session-name (str "cat >> " output-file)))
|
||||||
|
;; Create new session
|
||||||
|
(do
|
||||||
|
(log/debug "[Tmux] Creating new session:" session-name "in" work-dir)
|
||||||
|
;; Create output file
|
||||||
|
(spit output-file "")
|
||||||
|
;; Create tmux session in detached mode
|
||||||
|
(let [result (shell/sh "tmux" "new-session" "-d" "-s" session-name "-c" work-dir)]
|
||||||
|
(when-not (zero? (:exit result))
|
||||||
|
(throw (ex-info "Failed to create tmux session" {:error (:err result)}))))
|
||||||
|
;; Verify the session was created with the expected name
|
||||||
|
(when-not (run-tmux "has-session" "-t" session-name)
|
||||||
|
(throw (ex-info "Tmux session created but not found with expected name"
|
||||||
|
{:expected-name session-name})))
|
||||||
|
;; Double-check by querying the actual session name from tmux
|
||||||
|
(let [actual-name (run-tmux "display-message" "-t" session-name "-p" "#{session_name}")]
|
||||||
|
(when (and actual-name (not= actual-name session-name))
|
||||||
|
(log/warn "[Tmux] Session name mismatch! Expected:" session-name "Actual:" actual-name)
|
||||||
|
(throw (ex-info "Tmux session name mismatch"
|
||||||
|
{:expected-name session-name :actual-name actual-name}))))
|
||||||
|
(log/debug "[Tmux] Verified session exists with correct name:" session-name)
|
||||||
|
;; Set up pipe-pane to capture output
|
||||||
|
(run-tmux "pipe-pane" "-t" session-name (str "cat >> " output-file))))
|
||||||
|
;; Return handle
|
||||||
|
{:session-name session-name
|
||||||
|
:output-file output-file
|
||||||
|
:working-dir work-dir
|
||||||
|
:process nil}))
|
||||||
|
|
||||||
|
(send-message [_ handle message]
|
||||||
|
(let [{:keys [session-name output-file]} handle
|
||||||
|
marker-id (str (UUID/randomUUID))
|
||||||
|
end-marker (str end-marker-prefix marker-id "---")]
|
||||||
|
(log/debug "[Tmux] Sending command to" session-name ":" message)
|
||||||
|
;; Clear output file before sending command to avoid reading stale output
|
||||||
|
(spit output-file "")
|
||||||
|
;; Send the user's command
|
||||||
|
(run-tmux "send-keys" "-t" session-name message "Enter")
|
||||||
|
;; Send end marker after a tiny delay to let command start
|
||||||
|
(Thread/sleep 100)
|
||||||
|
(run-tmux "send-keys" "-t" session-name (str "echo '" end-marker "'") "Enter")
|
||||||
|
;; Return handle with marker and original command for read-stream to use
|
||||||
|
(assoc handle
|
||||||
|
:end-marker end-marker
|
||||||
|
:marker-id marker-id
|
||||||
|
:original-cmd message)))
|
||||||
|
|
||||||
|
(read-stream [_ handle callback]
|
||||||
|
(let [{:keys [output-file end-marker marker-id original-cmd]} handle
|
||||||
|
timeout-ms 300000] ;; 5 minute timeout
|
||||||
|
(log/debug "[Tmux] Starting to read output from" output-file "waiting for marker:" marker-id)
|
||||||
|
(when end-marker
|
||||||
|
(let [found-marker (tail-file output-file end-marker callback timeout-ms original-cmd)]
|
||||||
|
(log/debug "[Tmux] Read complete, found marker:" found-marker)
|
||||||
|
;; Emit message-stop event
|
||||||
|
(callback {:event :message-stop})))))
|
||||||
|
|
||||||
|
(kill-process [_ handle]
|
||||||
|
(let [{:keys [session-name output-file]} handle]
|
||||||
|
(log/debug "[Tmux] Killing session:" session-name)
|
||||||
|
;; Kill the tmux session
|
||||||
|
(run-tmux "kill-session" "-t" session-name)
|
||||||
|
;; Clean up output file
|
||||||
|
(when output-file
|
||||||
|
(let [f (File. ^String output-file)]
|
||||||
|
(when (.exists f)
|
||||||
|
(.delete f))))))
|
||||||
|
|
||||||
|
(parse-output [_ line]
|
||||||
|
;; For tmux, we parse in read-stream directly
|
||||||
|
;; This is here for protocol compliance
|
||||||
|
(when (and line (not (str/blank? line)))
|
||||||
|
{:event :content-delta :text line})))
|
||||||
|
|
||||||
|
(defn create-adapter
|
||||||
|
"Create a tmux adapter"
|
||||||
|
[]
|
||||||
|
(->TmuxAdapter))
|
||||||
|
|
||||||
|
(defn capture-pane
|
||||||
|
"Capture the current content of a tmux pane.
|
||||||
|
Returns the visible terminal content as a string, or nil if session doesn't exist."
|
||||||
|
[session-name]
|
||||||
|
(when session-name
|
||||||
|
;; Use capture-pane with -p to print to stdout, -e to include escape sequences (then strip them)
|
||||||
|
;; -S - and -E - captures the entire scrollback history
|
||||||
|
(let [result (shell/sh "tmux" "capture-pane" "-t" session-name "-p" "-S" "-1000")]
|
||||||
|
(when (zero? (:exit result))
|
||||||
|
(strip-ansi (:out result))))))
|
||||||
|
|
||||||
|
(defn get-session-name
|
||||||
|
"Get the tmux session name for a spiceflow session.
|
||||||
|
Checks if the external-id is already a session name or needs the prefix."
|
||||||
|
[external-id]
|
||||||
|
(when external-id
|
||||||
|
(if (str/starts-with? external-id session-prefix)
|
||||||
|
external-id
|
||||||
|
(tmux-session-name external-id))))
|
||||||
|
|
||||||
|
(defn session-alive?
|
||||||
|
"Check if a tmux session is still alive"
|
||||||
|
[session-name]
|
||||||
|
(when session-name
|
||||||
|
(let [result (shell/sh "tmux" "has-session" "-t" session-name)]
|
||||||
|
(zero? (:exit result)))))
|
||||||
|
|
||||||
|
(defn get-actual-session-name
|
||||||
|
"Get the actual session name from tmux for a given session.
|
||||||
|
This queries tmux directly to ensure the name matches what's on the device."
|
||||||
|
[session-name]
|
||||||
|
(when session-name
|
||||||
|
(let [result (shell/sh "tmux" "display-message" "-t" session-name "-p" "#{session_name}")]
|
||||||
|
(when (zero? (:exit result))
|
||||||
|
(str/trim (:out result))))))
|
||||||
|
|
||||||
|
(defn send-keys-raw
|
||||||
|
"Send raw input to a tmux session without waiting for output.
|
||||||
|
Used for stdin-style input to running processes."
|
||||||
|
[session-name input]
|
||||||
|
(when session-name
|
||||||
|
(log/debug "[Tmux] send-keys-raw to" session-name "input:" (pr-str input))
|
||||||
|
(cond
|
||||||
|
;; Carriage return should be sent as Enter key name
|
||||||
|
(= input "\r")
|
||||||
|
(run-tmux "send-keys" "-t" session-name "Enter")
|
||||||
|
;; Literal newline character
|
||||||
|
(= input "\n")
|
||||||
|
(run-tmux "send-keys" "-t" session-name "-l" "\n")
|
||||||
|
;; Control characters (like Ctrl+C = \u0003) - send without -l
|
||||||
|
(and (= 1 (count input))
|
||||||
|
(< (int (first input)) 32))
|
||||||
|
(run-tmux "send-keys" "-t" session-name input)
|
||||||
|
;; Regular text - send literal
|
||||||
|
:else
|
||||||
|
(run-tmux "send-keys" "-t" session-name "-l" input))
|
||||||
|
true))
|
||||||
|
|
||||||
|
(defn rename-session
|
||||||
|
"Rename a tmux session. Returns the new session name on success, nil on failure."
|
||||||
|
[old-name new-name]
|
||||||
|
(when (and old-name new-name)
|
||||||
|
(let [result (shell/sh "tmux" "rename-session" "-t" old-name new-name)]
|
||||||
|
(when (zero? (:exit result))
|
||||||
|
new-name))))
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
[ring.util.response :as response]
|
[ring.util.response :as response]
|
||||||
[spiceflow.db.protocol :as db]
|
[spiceflow.db.protocol :as db]
|
||||||
[spiceflow.session.manager :as manager]
|
[spiceflow.session.manager :as manager]
|
||||||
|
[spiceflow.adapters.protocol :as adapter]
|
||||||
|
[spiceflow.adapters.tmux :as tmux]
|
||||||
|
[spiceflow.push.protocol :as push-proto]
|
||||||
[clojure.tools.logging :as log]))
|
[clojure.tools.logging :as log]))
|
||||||
|
|
||||||
(defn- json-response
|
(defn- json-response
|
||||||
@@ -23,42 +26,101 @@
|
|||||||
(response/content-type "application/json")))
|
(response/content-type "application/json")))
|
||||||
|
|
||||||
;; Session handlers
|
;; Session handlers
|
||||||
|
(defn- now-iso
|
||||||
|
"Get current time as ISO string for JSON serialization"
|
||||||
|
[]
|
||||||
|
(str (java.time.Instant/now)))
|
||||||
|
|
||||||
(defn list-sessions-handler
|
(defn list-sessions-handler
|
||||||
|
"List all sessions - merges DB sessions with live tmux sessions"
|
||||||
[store]
|
[store]
|
||||||
(fn [_request]
|
(fn [_request]
|
||||||
(json-response (db/get-sessions store))))
|
;; Get DB sessions (claude, opencode)
|
||||||
|
(let [db-sessions (db/get-sessions store)
|
||||||
|
;; Get live tmux sessions
|
||||||
|
tmux-adapter (manager/get-adapter :tmux)
|
||||||
|
tmux-sessions (adapter/discover-sessions tmux-adapter)
|
||||||
|
;; Convert tmux sessions to session format with id = external-id
|
||||||
|
tmux-formatted (map (fn [s]
|
||||||
|
{:id (:external-id s)
|
||||||
|
:provider "tmux"
|
||||||
|
:title (:title s)
|
||||||
|
:working-dir (:working-dir s)
|
||||||
|
:created-at (now-iso)
|
||||||
|
:updated-at (now-iso)})
|
||||||
|
tmux-sessions)]
|
||||||
|
(json-response (concat db-sessions tmux-formatted)))))
|
||||||
|
|
||||||
|
(defn- tmux-session-id?
|
||||||
|
"Check if a session ID is a tmux session (starts with spiceflow-)"
|
||||||
|
[id]
|
||||||
|
(and id (clojure.string/starts-with? id "spiceflow-")))
|
||||||
|
|
||||||
(defn get-session-handler
|
(defn get-session-handler
|
||||||
[store]
|
[store]
|
||||||
(fn [request]
|
(fn [request]
|
||||||
(let [id (get-in request [:path-params :id])]
|
(let [id (get-in request [:path-params :id])]
|
||||||
(if-let [session (db/get-session store id)]
|
(if (tmux-session-id? id)
|
||||||
(let [messages (db/get-messages store id)]
|
;; Tmux session - check if it's alive
|
||||||
(json-response (assoc session :messages messages)))
|
(if (tmux/session-alive? id)
|
||||||
(error-response 404 "Session not found")))))
|
(json-response {:id id
|
||||||
|
:provider "tmux"
|
||||||
|
:title id
|
||||||
|
:messages []})
|
||||||
|
(error-response 404 "Session not found"))
|
||||||
|
;; Regular DB session
|
||||||
|
(if-let [session (db/get-session store id)]
|
||||||
|
(let [messages (db/get-messages store id)]
|
||||||
|
(json-response (assoc session :messages messages)))
|
||||||
|
(error-response 404 "Session not found"))))))
|
||||||
|
|
||||||
(defn create-session-handler
|
(defn create-session-handler
|
||||||
[store]
|
[store]
|
||||||
(fn [request]
|
(fn [request]
|
||||||
(let [body (:body request)]
|
(let [body (:body request)
|
||||||
|
provider (keyword (:provider body))]
|
||||||
(log/debug "API request: create-session" {:body body})
|
(log/debug "API request: create-session" {:body body})
|
||||||
(if (db/valid-session? body)
|
(if (= :tmux provider)
|
||||||
(let [session (db/save-session store body)]
|
;; Tmux session - spawn directly without DB persistence
|
||||||
(-> (json-response session)
|
(let [tmux-adapter (manager/get-adapter :tmux)
|
||||||
|
handle (adapter/spawn-session tmux-adapter nil {})
|
||||||
|
session-name (:session-name handle)]
|
||||||
|
(-> (json-response {:id session-name
|
||||||
|
:provider "tmux"
|
||||||
|
:title session-name
|
||||||
|
:created-at (now-iso)
|
||||||
|
:updated-at (now-iso)})
|
||||||
(response/status 201)))
|
(response/status 201)))
|
||||||
(error-response 400 "Invalid session data")))))
|
;; Regular session - save to DB
|
||||||
|
(if (db/valid-session? body)
|
||||||
|
(let [session (db/save-session store body)]
|
||||||
|
(-> (json-response session)
|
||||||
|
(response/status 201)))
|
||||||
|
(error-response 400 "Invalid session data"))))))
|
||||||
|
|
||||||
(defn delete-session-handler
|
(defn delete-session-handler
|
||||||
[store]
|
[store]
|
||||||
(fn [request]
|
(fn [request]
|
||||||
(let [id (get-in request [:path-params :id])]
|
(let [id (get-in request [:path-params :id])]
|
||||||
(log/debug "API request: delete-session" {:session-id id})
|
(log/debug "API request: delete-session" {:session-id id})
|
||||||
(if (db/get-session store id)
|
(if (tmux-session-id? id)
|
||||||
(do
|
;; Tmux session - just kill the tmux session
|
||||||
(manager/stop-session store id)
|
(if (tmux/session-alive? id)
|
||||||
(db/delete-session store id)
|
(do
|
||||||
(response/status (response/response nil) 204))
|
(log/debug "Killing tmux session:" id)
|
||||||
(error-response 404 "Session not found")))))
|
(let [tmux-adapter (manager/get-adapter :tmux)]
|
||||||
|
(adapter/kill-process tmux-adapter {:session-name id
|
||||||
|
:output-file (str "/tmp/spiceflow-tmux-" id ".log")}))
|
||||||
|
(response/status (response/response nil) 204))
|
||||||
|
(error-response 404 "Session not found"))
|
||||||
|
;; Regular DB session
|
||||||
|
(if-let [session (db/get-session store id)]
|
||||||
|
(do
|
||||||
|
;; Stop any active process
|
||||||
|
(manager/stop-session store id)
|
||||||
|
(db/delete-session store id)
|
||||||
|
(response/status (response/response nil) 204))
|
||||||
|
(error-response 404 "Session not found"))))))
|
||||||
|
|
||||||
(defn update-session-handler
|
(defn update-session-handler
|
||||||
[store]
|
[store]
|
||||||
@@ -66,17 +128,37 @@
|
|||||||
(let [id (get-in request [:path-params :id])
|
(let [id (get-in request [:path-params :id])
|
||||||
body (:body request)]
|
body (:body request)]
|
||||||
(log/debug "API request: update-session" {:session-id id :body body})
|
(log/debug "API request: update-session" {:session-id id :body body})
|
||||||
(if (db/get-session store id)
|
(if (tmux-session-id? id)
|
||||||
(let [updated (db/update-session store id (select-keys body [:title :auto-accept-edits]))]
|
;; Tmux session - rename via tmux CLI, no DB changes
|
||||||
(json-response updated))
|
(if (tmux/session-alive? id)
|
||||||
(error-response 404 "Session not found")))))
|
(if-let [new-title (:title body)]
|
||||||
|
;; Ensure new name has spiceflow- prefix so it's still discoverable
|
||||||
|
(let [new-name (if (clojure.string/starts-with? new-title "spiceflow-")
|
||||||
|
new-title
|
||||||
|
(str "spiceflow-" new-title))]
|
||||||
|
(if (tmux/rename-session id new-name)
|
||||||
|
(json-response {:id new-name
|
||||||
|
:provider "tmux"
|
||||||
|
:title new-name})
|
||||||
|
(error-response 500 "Failed to rename tmux session")))
|
||||||
|
;; No title change, just return current info
|
||||||
|
(json-response {:id id
|
||||||
|
:provider "tmux"
|
||||||
|
:title id}))
|
||||||
|
(error-response 404 "Session not found"))
|
||||||
|
;; Regular DB session
|
||||||
|
(if (db/get-session store id)
|
||||||
|
(let [updated (db/update-session store id (select-keys body [:title :auto-accept-edits]))]
|
||||||
|
(json-response updated))
|
||||||
|
(error-response 404 "Session not found"))))))
|
||||||
|
|
||||||
(defn send-message-handler
|
(defn send-message-handler
|
||||||
[store broadcast-fn]
|
[store broadcast-fn]
|
||||||
(fn [request]
|
(fn [request]
|
||||||
(let [id (get-in request [:path-params :id])
|
(let [id (get-in request [:path-params :id])
|
||||||
message (get-in request [:body :message])]
|
message (get-in request [:body :message])]
|
||||||
(log/debug "API request: send-message" {:session-id id :message message})
|
(log/debug "API request: send-message" {:session-id id})
|
||||||
|
(log/debug "API request message content:" message)
|
||||||
(if-let [session (db/get-session store id)]
|
(if-let [session (db/get-session store id)]
|
||||||
(try
|
(try
|
||||||
;; Send message and start streaming in a separate thread
|
;; Send message and start streaming in a separate thread
|
||||||
@@ -128,14 +210,89 @@
|
|||||||
(error-response 400 "Invalid response type. Must be: accept, deny, or steer"))
|
(error-response 400 "Invalid response type. Must be: accept, deny, or steer"))
|
||||||
(error-response 404 "Session not found")))))
|
(error-response 404 "Session not found")))))
|
||||||
|
|
||||||
|
;; Tmux terminal handlers
|
||||||
|
(defn terminal-capture-handler
|
||||||
|
"Get the current terminal content for a tmux session.
|
||||||
|
For ephemeral tmux sessions, the session ID IS the tmux session name."
|
||||||
|
[_store]
|
||||||
|
(fn [request]
|
||||||
|
(let [id (get-in request [:path-params :id])]
|
||||||
|
(if (tmux-session-id? id)
|
||||||
|
;; Ephemeral tmux session - ID is the session name
|
||||||
|
(if (tmux/session-alive? id)
|
||||||
|
(let [content (tmux/capture-pane id)]
|
||||||
|
(json-response {:content (or content "")
|
||||||
|
:alive true
|
||||||
|
:session-name id}))
|
||||||
|
(error-response 404 "Session not found"))
|
||||||
|
(error-response 400 "Not a tmux session")))))
|
||||||
|
|
||||||
|
(defn terminal-input-handler
|
||||||
|
"Send raw input to a tmux session (stdin-style)"
|
||||||
|
[_store broadcast-fn]
|
||||||
|
(fn [request]
|
||||||
|
(let [id (get-in request [:path-params :id])
|
||||||
|
input (get-in request [:body :input])]
|
||||||
|
(if (tmux-session-id? id)
|
||||||
|
;; Ephemeral tmux session - ID is the session name
|
||||||
|
(if (tmux/session-alive? id)
|
||||||
|
(do
|
||||||
|
(tmux/send-keys-raw id input)
|
||||||
|
;; Broadcast terminal update after input
|
||||||
|
(future
|
||||||
|
(Thread/sleep 100) ;; Small delay to let terminal update
|
||||||
|
(let [content (tmux/capture-pane id)]
|
||||||
|
(broadcast-fn id {:event :terminal-update
|
||||||
|
:content (or content "")})))
|
||||||
|
(json-response {:status "sent"}))
|
||||||
|
(error-response 400 "Tmux session not alive"))
|
||||||
|
(error-response 400 "Not a tmux session")))))
|
||||||
|
|
||||||
;; Health check
|
;; Health check
|
||||||
(defn health-handler
|
(defn health-handler
|
||||||
[_request]
|
[_request]
|
||||||
(json-response {:status "ok" :service "spiceflow"}))
|
(json-response {:status "ok" :service "spiceflow"}))
|
||||||
|
|
||||||
|
;; Push notification handlers
|
||||||
|
(defn vapid-key-handler
|
||||||
|
"Return the public VAPID key for push subscriptions"
|
||||||
|
[push-store]
|
||||||
|
(fn [_request]
|
||||||
|
(if-let [vapid-keys (push-proto/get-vapid-keys push-store)]
|
||||||
|
(json-response {:publicKey (:public-key vapid-keys)})
|
||||||
|
(error-response 500 "VAPID keys not configured"))))
|
||||||
|
|
||||||
|
(defn subscribe-handler
|
||||||
|
"Save a push subscription"
|
||||||
|
[push-store]
|
||||||
|
(fn [request]
|
||||||
|
(let [body (:body request)
|
||||||
|
subscription {:endpoint (:endpoint body)
|
||||||
|
:p256dh (get-in body [:keys :p256dh])
|
||||||
|
:auth (get-in body [:keys :auth])
|
||||||
|
:user-agent (get-in request [:headers "user-agent"])}]
|
||||||
|
(log/debug "Push subscribe request:" {:endpoint (:endpoint subscription)})
|
||||||
|
(if (push-proto/valid-subscription? subscription)
|
||||||
|
(let [saved (push-proto/save-subscription push-store subscription)]
|
||||||
|
(-> (json-response {:id (:id saved)})
|
||||||
|
(response/status 201)))
|
||||||
|
(error-response 400 "Invalid subscription: endpoint, p256dh, and auth are required")))))
|
||||||
|
|
||||||
|
(defn unsubscribe-handler
|
||||||
|
"Remove a push subscription"
|
||||||
|
[push-store]
|
||||||
|
(fn [request]
|
||||||
|
(let [endpoint (get-in request [:body :endpoint])]
|
||||||
|
(log/debug "Push unsubscribe request:" {:endpoint endpoint})
|
||||||
|
(if endpoint
|
||||||
|
(do
|
||||||
|
(push-proto/delete-subscription-by-endpoint push-store endpoint)
|
||||||
|
(response/status (response/response nil) 204))
|
||||||
|
(error-response 400 "Endpoint is required")))))
|
||||||
|
|
||||||
(defn create-routes
|
(defn create-routes
|
||||||
"Create API routes with the given store and broadcast function"
|
"Create API routes with the given store, broadcast function, and push store"
|
||||||
[store broadcast-fn]
|
[store broadcast-fn push-store]
|
||||||
[["/api"
|
[["/api"
|
||||||
["/health" {:get health-handler}]
|
["/health" {:get health-handler}]
|
||||||
["/sessions" {:get (list-sessions-handler store)
|
["/sessions" {:get (list-sessions-handler store)
|
||||||
@@ -144,13 +301,18 @@
|
|||||||
:patch (update-session-handler store)
|
:patch (update-session-handler store)
|
||||||
:delete (delete-session-handler store)}]
|
:delete (delete-session-handler store)}]
|
||||||
["/sessions/:id/send" {:post (send-message-handler store broadcast-fn)}]
|
["/sessions/:id/send" {:post (send-message-handler store broadcast-fn)}]
|
||||||
["/sessions/:id/permission" {:post (permission-response-handler store broadcast-fn)}]]])
|
["/sessions/:id/permission" {:post (permission-response-handler store broadcast-fn)}]
|
||||||
|
["/sessions/:id/terminal" {:get (terminal-capture-handler store)}]
|
||||||
|
["/sessions/:id/terminal/input" {:post (terminal-input-handler store broadcast-fn)}]
|
||||||
|
["/push/vapid-key" {:get (vapid-key-handler push-store)}]
|
||||||
|
["/push/subscribe" {:post (subscribe-handler push-store)}]
|
||||||
|
["/push/unsubscribe" {:post (unsubscribe-handler push-store)}]]])
|
||||||
|
|
||||||
(defn create-app
|
(defn create-app
|
||||||
"Create the Ring application"
|
"Create the Ring application"
|
||||||
[store broadcast-fn]
|
[store broadcast-fn push-store]
|
||||||
(-> (ring/ring-handler
|
(-> (ring/ring-handler
|
||||||
(ring/router (create-routes store broadcast-fn))
|
(ring/router (create-routes store broadcast-fn push-store))
|
||||||
(ring/create-default-handler))
|
(ring/create-default-handler))
|
||||||
(wrap-json-body {:keywords? true})
|
(wrap-json-body {:keywords? true})
|
||||||
wrap-json-response
|
wrap-json-response
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
[spiceflow.api.routes :as routes]
|
[spiceflow.api.routes :as routes]
|
||||||
[spiceflow.api.websocket :as ws]
|
[spiceflow.api.websocket :as ws]
|
||||||
[spiceflow.session.manager :as manager]
|
[spiceflow.session.manager :as manager]
|
||||||
|
[spiceflow.push.store :as push-store]
|
||||||
[mount.core :as mount :refer [defstate]]
|
[mount.core :as mount :refer [defstate]]
|
||||||
[clojure.tools.logging :as log])
|
[clojure.tools.logging :as log])
|
||||||
(:gen-class))
|
(:gen-class))
|
||||||
@@ -18,6 +19,13 @@
|
|||||||
(sqlite/create-store db-path)))
|
(sqlite/create-store db-path)))
|
||||||
:stop nil)
|
:stop nil)
|
||||||
|
|
||||||
|
;; Push notification store (shares datasource with main store)
|
||||||
|
(defstate push
|
||||||
|
:start (do
|
||||||
|
(log/info "Initializing push notification store...")
|
||||||
|
(push-store/create-push-store (:datasource store)))
|
||||||
|
:stop nil)
|
||||||
|
|
||||||
;; Atom to hold the Jetty server instance
|
;; Atom to hold the Jetty server instance
|
||||||
(defonce ^:private jetty-server (atom nil))
|
(defonce ^:private jetty-server (atom nil))
|
||||||
|
|
||||||
@@ -27,7 +35,9 @@
|
|||||||
host (get-in config/config [:server :host] "0.0.0.0")
|
host (get-in config/config [:server :host] "0.0.0.0")
|
||||||
;; Wire up pending permission function for WebSocket (partially apply store)
|
;; Wire up pending permission function for WebSocket (partially apply store)
|
||||||
_ (ws/set-pending-permission-fn! (partial manager/get-pending-permission store))
|
_ (ws/set-pending-permission-fn! (partial manager/get-pending-permission store))
|
||||||
api-app (routes/create-app store ws/broadcast-to-session)
|
;; Wire up push store for notifications (used by manager)
|
||||||
|
_ (manager/set-push-store! push)
|
||||||
|
api-app (routes/create-app store ws/broadcast-to-session push)
|
||||||
;; Wrap the app to handle WebSocket upgrades on /api/ws
|
;; Wrap the app to handle WebSocket upgrades on /api/ws
|
||||||
app (fn [request]
|
app (fn [request]
|
||||||
(if (and (jetty/ws-upgrade-request? request)
|
(if (and (jetty/ws-upgrade-request? request)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"Validate session data has required fields"
|
"Validate session data has required fields"
|
||||||
[{:keys [provider]}]
|
[{:keys [provider]}]
|
||||||
(and provider
|
(and provider
|
||||||
(contains? #{:claude :opencode "claude" "opencode"} provider)))
|
(contains? #{:claude :opencode :tmux "claude" "opencode" "tmux"} provider)))
|
||||||
|
|
||||||
(defn valid-message?
|
(defn valid-message?
|
||||||
"Validate message data has required fields"
|
"Validate message data has required fields"
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
(ns spiceflow.push.protocol
|
||||||
|
"Protocol for push notification subscription storage")
|
||||||
|
|
||||||
|
(defprotocol PushStore
|
||||||
|
"Protocol for managing push subscriptions and VAPID keys"
|
||||||
|
|
||||||
|
;; Subscription operations
|
||||||
|
(get-subscriptions [this]
|
||||||
|
"Get all push subscriptions")
|
||||||
|
(get-subscription [this id]
|
||||||
|
"Get a subscription by ID")
|
||||||
|
(get-subscription-by-endpoint [this endpoint]
|
||||||
|
"Get a subscription by endpoint URL")
|
||||||
|
(save-subscription [this subscription]
|
||||||
|
"Save a new push subscription. Returns the saved subscription with ID.")
|
||||||
|
(delete-subscription [this id]
|
||||||
|
"Delete a subscription by ID")
|
||||||
|
(delete-subscription-by-endpoint [this endpoint]
|
||||||
|
"Delete a subscription by endpoint URL")
|
||||||
|
|
||||||
|
;; VAPID key operations
|
||||||
|
(get-vapid-keys [this]
|
||||||
|
"Get the VAPID key pair (returns {:public-key :private-key} or nil)")
|
||||||
|
(save-vapid-keys [this keys]
|
||||||
|
"Save VAPID key pair. Only called once on first use."))
|
||||||
|
|
||||||
|
(defn valid-subscription?
|
||||||
|
"Validate subscription data has required fields"
|
||||||
|
[{:keys [endpoint p256dh auth]}]
|
||||||
|
(and endpoint p256dh auth
|
||||||
|
(string? endpoint)
|
||||||
|
(string? p256dh)
|
||||||
|
(string? auth)))
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
(ns spiceflow.push.sender
|
||||||
|
"Web Push message encryption and delivery.
|
||||||
|
Implements RFC 8291 (Message Encryption for Web Push) and RFC 8292 (VAPID)."
|
||||||
|
(:require [clj-http.client :as http]
|
||||||
|
[jsonista.core :as json]
|
||||||
|
[spiceflow.push.protocol :as proto]
|
||||||
|
[spiceflow.push.vapid :as vapid]
|
||||||
|
[clojure.tools.logging :as log])
|
||||||
|
(:import [java.security KeyPairGenerator SecureRandom KeyFactory]
|
||||||
|
[java.security.spec ECGenParameterSpec ECPublicKeySpec ECPoint]
|
||||||
|
[javax.crypto Cipher KeyAgreement Mac]
|
||||||
|
[javax.crypto.spec SecretKeySpec GCMParameterSpec]
|
||||||
|
[java.util Base64 Arrays]
|
||||||
|
[java.nio ByteBuffer]
|
||||||
|
[java.math BigInteger]
|
||||||
|
[org.bouncycastle.jce ECNamedCurveTable]
|
||||||
|
[org.bouncycastle.jce.spec ECNamedCurveSpec]))
|
||||||
|
|
||||||
|
;; Note: We implement Web Push encryption using Java crypto primitives
|
||||||
|
;; to avoid additional dependencies beyond buddy-core
|
||||||
|
|
||||||
|
(defn- base64url-decode
|
||||||
|
"Decode URL-safe base64 string to bytes"
|
||||||
|
[^String s]
|
||||||
|
(.decode (Base64/getUrlDecoder) s))
|
||||||
|
|
||||||
|
(defn- base64url-encode
|
||||||
|
"Encode bytes to URL-safe base64 without padding"
|
||||||
|
[^bytes b]
|
||||||
|
(-> (Base64/getUrlEncoder)
|
||||||
|
(.withoutPadding)
|
||||||
|
(.encodeToString b)))
|
||||||
|
|
||||||
|
(defn- generate-salt
|
||||||
|
"Generate 16 random bytes for encryption salt"
|
||||||
|
[]
|
||||||
|
(let [salt (byte-array 16)
|
||||||
|
random (SecureRandom.)]
|
||||||
|
(.nextBytes random salt)
|
||||||
|
salt))
|
||||||
|
|
||||||
|
(defn- generate-ephemeral-keypair
|
||||||
|
"Generate an ephemeral ECDH key pair for message encryption"
|
||||||
|
[]
|
||||||
|
(let [kpg (KeyPairGenerator/getInstance "EC")
|
||||||
|
_ (.initialize kpg (ECGenParameterSpec. "secp256r1") (SecureRandom.))]
|
||||||
|
(.generateKeyPair kpg)))
|
||||||
|
|
||||||
|
(defn- public-key->uncompressed-bytes
|
||||||
|
"Convert EC public key to uncompressed point format (0x04 || x || y)"
|
||||||
|
[public-key]
|
||||||
|
(let [point (.getW public-key)
|
||||||
|
x-bytes (.toByteArray (.getAffineX point))
|
||||||
|
y-bytes (.toByteArray (.getAffineY point))
|
||||||
|
x-padded (byte-array 32)
|
||||||
|
y-padded (byte-array 32)]
|
||||||
|
(let [x-len (min 32 (alength x-bytes))
|
||||||
|
x-offset (max 0 (- (alength x-bytes) 32))
|
||||||
|
y-len (min 32 (alength y-bytes))
|
||||||
|
y-offset (max 0 (- (alength y-bytes) 32))]
|
||||||
|
(System/arraycopy x-bytes x-offset x-padded (- 32 x-len) x-len)
|
||||||
|
(System/arraycopy y-bytes y-offset y-padded (- 32 y-len) y-len))
|
||||||
|
(let [result (byte-array 65)]
|
||||||
|
(aset-byte result 0 (unchecked-byte 0x04))
|
||||||
|
(System/arraycopy x-padded 0 result 1 32)
|
||||||
|
(System/arraycopy y-padded 0 result 33 32)
|
||||||
|
result)))
|
||||||
|
|
||||||
|
(defn- uncompressed-bytes->public-key
|
||||||
|
"Convert uncompressed point bytes (0x04 || x || y) to EC public key"
|
||||||
|
[^bytes point-bytes]
|
||||||
|
(when (and (= 65 (alength point-bytes))
|
||||||
|
(= 0x04 (aget point-bytes 0)))
|
||||||
|
(let [x-bytes (byte-array 32)
|
||||||
|
y-bytes (byte-array 32)
|
||||||
|
_ (System/arraycopy point-bytes 1 x-bytes 0 32)
|
||||||
|
_ (System/arraycopy point-bytes 33 y-bytes 0 32)
|
||||||
|
x (BigInteger. 1 x-bytes)
|
||||||
|
y (BigInteger. 1 y-bytes)
|
||||||
|
;; Get EC parameters for P-256
|
||||||
|
kpg (doto (KeyPairGenerator/getInstance "EC")
|
||||||
|
(.initialize (ECGenParameterSpec. "secp256r1")))
|
||||||
|
temp-pair (.generateKeyPair kpg)
|
||||||
|
params (.getParams (.getPublic temp-pair))
|
||||||
|
point (ECPoint. x y)
|
||||||
|
spec (ECPublicKeySpec. point params)]
|
||||||
|
(-> (KeyFactory/getInstance "EC")
|
||||||
|
(.generatePublic spec)))))
|
||||||
|
|
||||||
|
(defn- ecdh-derive-secret
|
||||||
|
"Perform ECDH to derive shared secret"
|
||||||
|
[private-key public-key]
|
||||||
|
(let [ka (KeyAgreement/getInstance "ECDH")]
|
||||||
|
(.init ka private-key)
|
||||||
|
(.doPhase ka public-key true)
|
||||||
|
(.generateSecret ka)))
|
||||||
|
|
||||||
|
(defn- hmac-sha256
|
||||||
|
"Compute HMAC-SHA256"
|
||||||
|
[^bytes key ^bytes data]
|
||||||
|
(let [mac (Mac/getInstance "HmacSHA256")
|
||||||
|
secret-key (SecretKeySpec. key "HmacSHA256")]
|
||||||
|
(.init mac secret-key)
|
||||||
|
(.doFinal mac data)))
|
||||||
|
|
||||||
|
(defn- hkdf-extract
|
||||||
|
"HKDF extract step"
|
||||||
|
[salt ikm]
|
||||||
|
(let [salt (if (and salt (pos? (alength salt))) salt (byte-array 32))]
|
||||||
|
(hmac-sha256 salt ikm)))
|
||||||
|
|
||||||
|
(defn- hkdf-expand
|
||||||
|
"HKDF expand step"
|
||||||
|
[prk info length]
|
||||||
|
(let [hash-len 32
|
||||||
|
n (int (Math/ceil (/ length hash-len)))
|
||||||
|
okm (byte-array (* n hash-len))
|
||||||
|
prev (byte-array 0)]
|
||||||
|
(loop [i 1
|
||||||
|
prev prev]
|
||||||
|
(when (<= i n)
|
||||||
|
(let [input (byte-array (+ (alength prev) (alength info) 1))
|
||||||
|
_ (System/arraycopy prev 0 input 0 (alength prev))
|
||||||
|
_ (System/arraycopy info 0 input (alength prev) (alength info))
|
||||||
|
_ (aset-byte input (dec (alength input)) (unchecked-byte i))
|
||||||
|
output (hmac-sha256 prk input)]
|
||||||
|
(System/arraycopy output 0 okm (* (dec i) hash-len) hash-len)
|
||||||
|
(recur (inc i) output))))
|
||||||
|
(Arrays/copyOf okm length)))
|
||||||
|
|
||||||
|
(defn- hkdf
|
||||||
|
"Full HKDF key derivation"
|
||||||
|
[salt ikm info length]
|
||||||
|
(let [prk (hkdf-extract salt ikm)]
|
||||||
|
(hkdf-expand prk info length)))
|
||||||
|
|
||||||
|
(defn- build-info
|
||||||
|
"Build the info parameter for HKDF according to RFC 8291"
|
||||||
|
[^String type ^bytes client-public ^bytes server-public]
|
||||||
|
(let [type-bytes (.getBytes type "UTF-8")
|
||||||
|
;; Info structure: "Content-Encoding: <type>" || 0x00 || "P-256" || 0x00
|
||||||
|
;; || client public key length (2 bytes) || client public key
|
||||||
|
;; || server public key length (2 bytes) || server public key
|
||||||
|
info-len (+ (count "Content-Encoding: ") (alength type-bytes) 1
|
||||||
|
5 1 ;; "P-256" + null
|
||||||
|
2 65 ;; client key length + key
|
||||||
|
2 65) ;; server key length + key
|
||||||
|
info (byte-array info-len)
|
||||||
|
buf (ByteBuffer/wrap info)]
|
||||||
|
(.put buf (.getBytes (str "Content-Encoding: " type) "UTF-8"))
|
||||||
|
(.put buf (byte 0))
|
||||||
|
(.put buf (.getBytes "P-256" "UTF-8"))
|
||||||
|
(.put buf (byte 0))
|
||||||
|
(.putShort buf (short 65))
|
||||||
|
(.put buf client-public)
|
||||||
|
(.putShort buf (short 65))
|
||||||
|
(.put buf server-public)
|
||||||
|
info))
|
||||||
|
|
||||||
|
(defn- aes-128-gcm-encrypt
|
||||||
|
"Encrypt data using AES-128-GCM"
|
||||||
|
[^bytes key ^bytes nonce ^bytes plaintext]
|
||||||
|
(let [cipher (Cipher/getInstance "AES/GCM/NoPadding")
|
||||||
|
secret-key (SecretKeySpec. key "AES")
|
||||||
|
gcm-spec (GCMParameterSpec. 128 nonce)]
|
||||||
|
(.init cipher Cipher/ENCRYPT_MODE secret-key gcm-spec)
|
||||||
|
(.doFinal cipher plaintext)))
|
||||||
|
|
||||||
|
(defn- pad-plaintext
|
||||||
|
"Add padding to plaintext according to RFC 8291.
|
||||||
|
Padding: 2 bytes length prefix (big-endian) + padding bytes"
|
||||||
|
[^bytes plaintext]
|
||||||
|
;; For simplicity, use minimal padding (just the required 2-byte header)
|
||||||
|
;; The format is: padding_length (2 bytes, big-endian) || zeros || plaintext
|
||||||
|
(let [plaintext-len (alength plaintext)
|
||||||
|
;; Use 0 bytes of actual padding
|
||||||
|
padding-len 0
|
||||||
|
result (byte-array (+ 2 padding-len plaintext-len))]
|
||||||
|
;; Write padding length as big-endian 16-bit integer
|
||||||
|
(aset-byte result 0 (unchecked-byte (bit-shift-right padding-len 8)))
|
||||||
|
(aset-byte result 1 (unchecked-byte (bit-and padding-len 0xFF)))
|
||||||
|
;; Copy plaintext after padding header
|
||||||
|
(System/arraycopy plaintext 0 result (+ 2 padding-len) plaintext-len)
|
||||||
|
result))
|
||||||
|
|
||||||
|
(defn encrypt-payload
|
||||||
|
"Encrypt a push message payload using Web Push encryption (RFC 8291).
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- p256dh: Client's ECDH public key (base64url encoded)
|
||||||
|
- auth: Client's auth secret (base64url encoded)
|
||||||
|
- plaintext: The message to encrypt (bytes)
|
||||||
|
|
||||||
|
Returns a map with:
|
||||||
|
- :ciphertext - The encrypted payload
|
||||||
|
- :salt - The encryption salt (for Content-Encoding header)
|
||||||
|
- :public-key - Server's ephemeral public key"
|
||||||
|
[p256dh auth plaintext]
|
||||||
|
(let [;; Decode client keys
|
||||||
|
client-public-bytes (base64url-decode p256dh)
|
||||||
|
auth-secret (base64url-decode auth)
|
||||||
|
client-public-key (uncompressed-bytes->public-key client-public-bytes)
|
||||||
|
|
||||||
|
;; Generate ephemeral server key pair
|
||||||
|
server-keypair (generate-ephemeral-keypair)
|
||||||
|
server-private-key (.getPrivate server-keypair)
|
||||||
|
server-public-key (.getPublic server-keypair)
|
||||||
|
server-public-bytes (public-key->uncompressed-bytes server-public-key)
|
||||||
|
|
||||||
|
;; Generate salt
|
||||||
|
salt (generate-salt)
|
||||||
|
|
||||||
|
;; ECDH to derive shared secret
|
||||||
|
ecdh-secret (ecdh-derive-secret server-private-key client-public-key)
|
||||||
|
|
||||||
|
;; Derive PRK using auth secret
|
||||||
|
;; PRK = HKDF-Extract(auth_secret, ecdh_secret)
|
||||||
|
auth-info (.getBytes "Content-Encoding: auth\u0000" "UTF-8")
|
||||||
|
prk-key (hkdf auth-secret ecdh-secret auth-info 32)
|
||||||
|
|
||||||
|
;; Derive content encryption key (CEK)
|
||||||
|
cek-info (build-info "aes128gcm" client-public-bytes server-public-bytes)
|
||||||
|
cek (hkdf salt prk-key cek-info 16)
|
||||||
|
|
||||||
|
;; Derive nonce
|
||||||
|
nonce-info (build-info "nonce" client-public-bytes server-public-bytes)
|
||||||
|
nonce (hkdf salt prk-key nonce-info 12)
|
||||||
|
|
||||||
|
;; Pad and encrypt
|
||||||
|
padded-plaintext (pad-plaintext plaintext)
|
||||||
|
ciphertext (aes-128-gcm-encrypt cek nonce padded-plaintext)]
|
||||||
|
{:ciphertext ciphertext
|
||||||
|
:salt salt
|
||||||
|
:public-key server-public-bytes}))
|
||||||
|
|
||||||
|
(defn build-encrypted-body
|
||||||
|
"Build the full encrypted body with header for aes128gcm Content-Encoding.
|
||||||
|
|
||||||
|
Format: salt (16 bytes) || rs (4 bytes) || idlen (1 byte) || keyid || ciphertext"
|
||||||
|
[^bytes salt ^bytes server-public ^bytes ciphertext]
|
||||||
|
(let [rs 4096 ;; Record size
|
||||||
|
idlen (alength server-public)
|
||||||
|
body (byte-array (+ 16 4 1 idlen (alength ciphertext)))
|
||||||
|
buf (ByteBuffer/wrap body)]
|
||||||
|
(.put buf salt)
|
||||||
|
(.putInt buf rs)
|
||||||
|
(.put buf (unchecked-byte idlen))
|
||||||
|
(.put buf server-public)
|
||||||
|
(.put buf ciphertext)
|
||||||
|
body))
|
||||||
|
|
||||||
|
(defn send-notification
|
||||||
|
"Send a push notification to a subscription.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- subscription: Map with :endpoint, :p256dh, :auth
|
||||||
|
- payload: Map to be JSON-encoded as the notification payload
|
||||||
|
- vapid-keys: Map with :public-key, :private-key (base64url encoded)
|
||||||
|
- options: Optional map with :ttl (seconds), :urgency, :topic
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- {:success true} on success
|
||||||
|
- {:success false :status <code> :body <body>} on failure
|
||||||
|
- {:success false :error <message>} on exception"
|
||||||
|
[subscription payload vapid-keys & [{:keys [ttl urgency topic subject]
|
||||||
|
:or {ttl 86400
|
||||||
|
subject "mailto:spiceflow@localhost"}}]]
|
||||||
|
(try
|
||||||
|
(let [endpoint (:endpoint subscription)
|
||||||
|
plaintext (.getBytes (json/write-value-as-string payload) "UTF-8")
|
||||||
|
|
||||||
|
;; Encrypt payload
|
||||||
|
{:keys [ciphertext salt public-key]} (encrypt-payload
|
||||||
|
(:p256dh subscription)
|
||||||
|
(:auth subscription)
|
||||||
|
plaintext)
|
||||||
|
|
||||||
|
;; Build encrypted body
|
||||||
|
body (build-encrypted-body salt public-key ciphertext)
|
||||||
|
|
||||||
|
;; Build VAPID authorization header
|
||||||
|
auth-header (vapid/vapid-authorization-header endpoint subject vapid-keys)
|
||||||
|
|
||||||
|
;; Build request headers
|
||||||
|
headers {"Authorization" auth-header
|
||||||
|
"Content-Type" "application/octet-stream"
|
||||||
|
"Content-Encoding" "aes128gcm"
|
||||||
|
"TTL" (str ttl)}
|
||||||
|
headers (cond-> headers
|
||||||
|
urgency (assoc "Urgency" urgency)
|
||||||
|
topic (assoc "Topic" topic))
|
||||||
|
|
||||||
|
;; Send request
|
||||||
|
response (http/post endpoint
|
||||||
|
{:headers headers
|
||||||
|
:body body
|
||||||
|
:throw-exceptions false})]
|
||||||
|
(if (<= 200 (:status response) 299)
|
||||||
|
{:success true}
|
||||||
|
{:success false
|
||||||
|
:status (:status response)
|
||||||
|
:body (:body response)}))
|
||||||
|
(catch Exception e
|
||||||
|
(log/error e "Failed to send push notification")
|
||||||
|
{:success false
|
||||||
|
:error (.getMessage e)})))
|
||||||
|
|
||||||
|
(defn send-to-all-subscriptions
|
||||||
|
"Send a notification to all subscriptions in the push store.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- push-store: PushStore instance
|
||||||
|
- payload: Notification payload map
|
||||||
|
|
||||||
|
Returns a sequence of results for each subscription."
|
||||||
|
[push-store payload]
|
||||||
|
(let [subscriptions (proto/get-subscriptions push-store)
|
||||||
|
vapid-keys (proto/get-vapid-keys push-store)]
|
||||||
|
(when (and (seq subscriptions) vapid-keys)
|
||||||
|
(log/info "Sending push notification to" (count subscriptions) "subscription(s)")
|
||||||
|
(doall
|
||||||
|
(for [sub subscriptions]
|
||||||
|
(let [result (send-notification sub payload vapid-keys)]
|
||||||
|
;; If subscription is gone (410) or invalid (404), remove it
|
||||||
|
(when (#{404 410} (:status result))
|
||||||
|
(log/info "Removing invalid/expired subscription:" (:endpoint sub))
|
||||||
|
(proto/delete-subscription push-store (:id sub)))
|
||||||
|
(assoc result :subscription-id (:id sub))))))))
|
||||||
|
|
||||||
|
(comment
|
||||||
|
;; Test encryption
|
||||||
|
(def test-p256dh "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM")
|
||||||
|
(def test-auth "tBHItJI5svbpez7KI4CCXg")
|
||||||
|
(def test-payload {:title "Test" :body "Hello"})
|
||||||
|
|
||||||
|
(encrypt-payload test-p256dh test-auth
|
||||||
|
(.getBytes (json/write-value-as-string test-payload) "UTF-8")))
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
(ns spiceflow.push.store
|
||||||
|
"SQLite implementation of PushStore protocol for managing push subscriptions and VAPID keys"
|
||||||
|
(:require [next.jdbc :as jdbc]
|
||||||
|
[next.jdbc.result-set :as rs]
|
||||||
|
[next.jdbc.sql :as sql]
|
||||||
|
[spiceflow.push.protocol :as proto]
|
||||||
|
[spiceflow.push.vapid :as vapid]
|
||||||
|
[clojure.tools.logging :as log])
|
||||||
|
(:import [java.util UUID]
|
||||||
|
[java.time Instant]))
|
||||||
|
|
||||||
|
(defn- generate-id []
|
||||||
|
(str (UUID/randomUUID)))
|
||||||
|
|
||||||
|
(defn- now-iso []
|
||||||
|
(.toString (Instant/now)))
|
||||||
|
|
||||||
|
(defn- row->subscription
|
||||||
|
"Convert a database row to a subscription map"
|
||||||
|
[row]
|
||||||
|
(when row
|
||||||
|
{:id (:id row)
|
||||||
|
:endpoint (:endpoint row)
|
||||||
|
:p256dh (:p256dh row)
|
||||||
|
:auth (:auth row)
|
||||||
|
:user-agent (:user-agent row)
|
||||||
|
:created-at (:created-at row)}))
|
||||||
|
|
||||||
|
(defn- row->vapid-keys
|
||||||
|
"Convert a database row to VAPID keys map"
|
||||||
|
[row]
|
||||||
|
(when row
|
||||||
|
{:public-key (:public-key row)
|
||||||
|
:private-key (:private-key row)
|
||||||
|
:created-at (:created-at row)}))
|
||||||
|
|
||||||
|
(defrecord SQLitePushStore [datasource]
|
||||||
|
proto/PushStore
|
||||||
|
|
||||||
|
(get-subscriptions [_]
|
||||||
|
(let [rows (jdbc/execute! datasource
|
||||||
|
["SELECT * FROM push_subscriptions ORDER BY created_at DESC"]
|
||||||
|
{:builder-fn rs/as-unqualified-kebab-maps})]
|
||||||
|
(mapv row->subscription rows)))
|
||||||
|
|
||||||
|
(get-subscription [_ id]
|
||||||
|
(let [row (jdbc/execute-one! datasource
|
||||||
|
["SELECT * FROM push_subscriptions WHERE id = ?" id]
|
||||||
|
{:builder-fn rs/as-unqualified-kebab-maps})]
|
||||||
|
(row->subscription row)))
|
||||||
|
|
||||||
|
(get-subscription-by-endpoint [_ endpoint]
|
||||||
|
(let [row (jdbc/execute-one! datasource
|
||||||
|
["SELECT * FROM push_subscriptions WHERE endpoint = ?" endpoint]
|
||||||
|
{:builder-fn rs/as-unqualified-kebab-maps})]
|
||||||
|
(row->subscription row)))
|
||||||
|
|
||||||
|
(save-subscription [this subscription]
|
||||||
|
(let [id (or (:id subscription) (generate-id))
|
||||||
|
now (now-iso)]
|
||||||
|
;; Check if subscription with this endpoint already exists
|
||||||
|
(if-let [existing (proto/get-subscription-by-endpoint this (:endpoint subscription))]
|
||||||
|
;; Update existing subscription
|
||||||
|
(do
|
||||||
|
(sql/update! datasource :push_subscriptions
|
||||||
|
{:p256dh (:p256dh subscription)
|
||||||
|
:auth (:auth subscription)
|
||||||
|
:user_agent (:user-agent subscription)}
|
||||||
|
{:endpoint (:endpoint subscription)})
|
||||||
|
(proto/get-subscription-by-endpoint this (:endpoint subscription)))
|
||||||
|
;; Insert new subscription
|
||||||
|
(do
|
||||||
|
(sql/insert! datasource :push_subscriptions
|
||||||
|
{:id id
|
||||||
|
:endpoint (:endpoint subscription)
|
||||||
|
:p256dh (:p256dh subscription)
|
||||||
|
:auth (:auth subscription)
|
||||||
|
:user_agent (:user-agent subscription)
|
||||||
|
:created_at now})
|
||||||
|
(proto/get-subscription this id)))))
|
||||||
|
|
||||||
|
(delete-subscription [_ id]
|
||||||
|
(jdbc/execute! datasource ["DELETE FROM push_subscriptions WHERE id = ?" id])
|
||||||
|
nil)
|
||||||
|
|
||||||
|
(delete-subscription-by-endpoint [_ endpoint]
|
||||||
|
(jdbc/execute! datasource ["DELETE FROM push_subscriptions WHERE endpoint = ?" endpoint])
|
||||||
|
nil)
|
||||||
|
|
||||||
|
(get-vapid-keys [_]
|
||||||
|
(let [row (jdbc/execute-one! datasource
|
||||||
|
["SELECT * FROM vapid_keys WHERE id = 1"]
|
||||||
|
{:builder-fn rs/as-unqualified-kebab-maps})]
|
||||||
|
(row->vapid-keys row)))
|
||||||
|
|
||||||
|
(save-vapid-keys [this keys]
|
||||||
|
(let [now (now-iso)]
|
||||||
|
;; Only save if no keys exist (singleton)
|
||||||
|
(when-not (proto/get-vapid-keys this)
|
||||||
|
(sql/insert! datasource :vapid_keys
|
||||||
|
{:id 1
|
||||||
|
:public_key (:public-key keys)
|
||||||
|
:private_key (:private-key keys)
|
||||||
|
:created_at now}))
|
||||||
|
(proto/get-vapid-keys this))))
|
||||||
|
|
||||||
|
(def push-schema
|
||||||
|
"SQLite schema for push notifications"
|
||||||
|
["CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
endpoint TEXT NOT NULL UNIQUE,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)"
|
||||||
|
"CREATE TABLE IF NOT EXISTS vapid_keys (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
private_key TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)"
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_push_subscriptions_endpoint ON push_subscriptions(endpoint)"])
|
||||||
|
|
||||||
|
(defn init-push-schema!
|
||||||
|
"Initialize push notification tables"
|
||||||
|
[datasource]
|
||||||
|
(doseq [stmt push-schema]
|
||||||
|
(jdbc/execute! datasource [stmt])))
|
||||||
|
|
||||||
|
(defn ensure-vapid-keys!
|
||||||
|
"Ensure VAPID keys exist, generating them if needed"
|
||||||
|
[push-store]
|
||||||
|
(if-let [existing (proto/get-vapid-keys push-store)]
|
||||||
|
(do
|
||||||
|
(log/info "Using existing VAPID keys")
|
||||||
|
existing)
|
||||||
|
(let [keys (vapid/generate-keypair)]
|
||||||
|
(log/info "Generated new VAPID keys")
|
||||||
|
(proto/save-vapid-keys push-store keys))))
|
||||||
|
|
||||||
|
(defn create-push-store
|
||||||
|
"Create a SQLite push store using the given datasource"
|
||||||
|
[datasource]
|
||||||
|
(init-push-schema! datasource)
|
||||||
|
(let [store (->SQLitePushStore datasource)]
|
||||||
|
(ensure-vapid-keys! store)
|
||||||
|
store))
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
(ns spiceflow.push.vapid
|
||||||
|
"VAPID (Voluntary Application Server Identification) authentication for Web Push.
|
||||||
|
Generates ECDSA P-256 key pairs and creates JWT tokens for push service authentication."
|
||||||
|
(:require [buddy.core.keys :as keys]
|
||||||
|
[buddy.sign.jwt :as jwt]
|
||||||
|
[clojure.tools.logging :as log])
|
||||||
|
(:import [java.security KeyPairGenerator SecureRandom]
|
||||||
|
[java.security.spec ECGenParameterSpec]
|
||||||
|
[java.util Base64]))
|
||||||
|
|
||||||
|
(defn- bytes->base64url
|
||||||
|
"Convert bytes to URL-safe base64 without padding"
|
||||||
|
[^bytes b]
|
||||||
|
(-> (Base64/getUrlEncoder)
|
||||||
|
(.withoutPadding)
|
||||||
|
(.encodeToString b)))
|
||||||
|
|
||||||
|
(defn- base64url->bytes
|
||||||
|
"Convert URL-safe base64 string to bytes"
|
||||||
|
[^String s]
|
||||||
|
(.decode (Base64/getUrlDecoder) s))
|
||||||
|
|
||||||
|
(defn- ec-public-key->uncompressed-bytes
|
||||||
|
"Convert EC public key to uncompressed point format (0x04 || x || y).
|
||||||
|
This is the format expected by the Push API for applicationServerKey."
|
||||||
|
[public-key]
|
||||||
|
(let [point (.getW public-key)
|
||||||
|
x-bytes (.toByteArray (.getAffineX point))
|
||||||
|
y-bytes (.toByteArray (.getAffineY point))
|
||||||
|
;; Ensure exactly 32 bytes for each coordinate
|
||||||
|
x-padded (byte-array 32)
|
||||||
|
y-padded (byte-array 32)]
|
||||||
|
;; Handle BigInteger byte arrays (may have leading zero or be shorter)
|
||||||
|
(let [x-len (min 32 (alength x-bytes))
|
||||||
|
x-offset (max 0 (- (alength x-bytes) 32))
|
||||||
|
y-len (min 32 (alength y-bytes))
|
||||||
|
y-offset (max 0 (- (alength y-bytes) 32))]
|
||||||
|
(System/arraycopy x-bytes x-offset x-padded (- 32 x-len) x-len)
|
||||||
|
(System/arraycopy y-bytes y-offset y-padded (- 32 y-len) y-len))
|
||||||
|
;; Create uncompressed point: 0x04 || x || y
|
||||||
|
(let [result (byte-array 65)]
|
||||||
|
(aset-byte result 0 (unchecked-byte 0x04))
|
||||||
|
(System/arraycopy x-padded 0 result 1 32)
|
||||||
|
(System/arraycopy y-padded 0 result 33 32)
|
||||||
|
result)))
|
||||||
|
|
||||||
|
(defn generate-keypair
|
||||||
|
"Generate a new ECDSA P-256 key pair for VAPID.
|
||||||
|
Returns {:public-key <base64url> :private-key <base64url>}"
|
||||||
|
[]
|
||||||
|
(let [kpg (KeyPairGenerator/getInstance "EC")
|
||||||
|
_ (.initialize kpg (ECGenParameterSpec. "secp256r1") (SecureRandom.))
|
||||||
|
keypair (.generateKeyPair kpg)
|
||||||
|
public-key (.getPublic keypair)
|
||||||
|
private-key (.getPrivate keypair)
|
||||||
|
;; Public key in uncompressed format for Push API
|
||||||
|
public-bytes (ec-public-key->uncompressed-bytes public-key)
|
||||||
|
;; Private key as raw 32-byte scalar
|
||||||
|
private-bytes (.getS private-key)]
|
||||||
|
{:public-key (bytes->base64url public-bytes)
|
||||||
|
:private-key (bytes->base64url (.toByteArray private-bytes))}))
|
||||||
|
|
||||||
|
(defn- reconstruct-private-key
|
||||||
|
"Reconstruct an EC private key from raw bytes"
|
||||||
|
[^bytes private-bytes]
|
||||||
|
(let [s (java.math.BigInteger. 1 private-bytes)
|
||||||
|
curve-params (-> (java.security.KeyFactory/getInstance "EC")
|
||||||
|
(.generatePrivate
|
||||||
|
(java.security.spec.ECPrivateKeySpec.
|
||||||
|
s
|
||||||
|
(.getParams
|
||||||
|
(-> (KeyPairGenerator/getInstance "EC")
|
||||||
|
(doto (.initialize (ECGenParameterSpec. "secp256r1")))
|
||||||
|
(.generateKeyPair)
|
||||||
|
(.getPrivate))))))
|
||||||
|
spec (java.security.spec.ECPrivateKeySpec. s (.getParams curve-params))]
|
||||||
|
(-> (java.security.KeyFactory/getInstance "EC")
|
||||||
|
(.generatePrivate spec))))
|
||||||
|
|
||||||
|
(defn create-vapid-jwt
|
||||||
|
"Create a VAPID JWT for authenticating to a push service.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- audience: The origin of the push service (e.g., https://fcm.googleapis.com)
|
||||||
|
- subject: Contact info (mailto: or https: URL)
|
||||||
|
- private-key-b64: Base64url-encoded private key
|
||||||
|
|
||||||
|
Returns a signed JWT valid for 12 hours."
|
||||||
|
[audience subject private-key-b64]
|
||||||
|
(let [now (quot (System/currentTimeMillis) 1000)
|
||||||
|
exp (+ now (* 12 60 60)) ;; 12 hours
|
||||||
|
claims {:aud audience
|
||||||
|
:exp exp
|
||||||
|
:sub subject}
|
||||||
|
private-bytes (base64url->bytes private-key-b64)
|
||||||
|
private-key (reconstruct-private-key private-bytes)]
|
||||||
|
(jwt/sign claims private-key {:alg :es256})))
|
||||||
|
|
||||||
|
(defn vapid-authorization-header
|
||||||
|
"Create the Authorization header value for VAPID authentication.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- endpoint: The push subscription endpoint URL
|
||||||
|
- subject: Contact info (mailto: or https: URL)
|
||||||
|
- vapid-keys: Map with :public-key and :private-key (base64url encoded)
|
||||||
|
|
||||||
|
Returns the value for the Authorization header."
|
||||||
|
[endpoint subject vapid-keys]
|
||||||
|
(let [url (java.net.URL. endpoint)
|
||||||
|
audience (str (.getProtocol url) "://" (.getHost url))
|
||||||
|
jwt (create-vapid-jwt audience subject (:private-key vapid-keys))]
|
||||||
|
(str "vapid t=" jwt ",k=" (:public-key vapid-keys))))
|
||||||
|
|
||||||
|
(comment
|
||||||
|
;; Test key generation
|
||||||
|
(def kp (generate-keypair))
|
||||||
|
kp
|
||||||
|
|
||||||
|
;; Test JWT creation
|
||||||
|
(create-vapid-jwt
|
||||||
|
"https://fcm.googleapis.com"
|
||||||
|
"mailto:test@example.com"
|
||||||
|
(:private-key kp))
|
||||||
|
|
||||||
|
;; Test authorization header
|
||||||
|
(vapid-authorization-header
|
||||||
|
"https://fcm.googleapis.com/fcm/send/abc123"
|
||||||
|
"mailto:test@example.com"
|
||||||
|
kp))
|
||||||
@@ -4,18 +4,54 @@
|
|||||||
[spiceflow.adapters.protocol :as adapter]
|
[spiceflow.adapters.protocol :as adapter]
|
||||||
[spiceflow.adapters.claude :as claude]
|
[spiceflow.adapters.claude :as claude]
|
||||||
[spiceflow.adapters.opencode :as opencode]
|
[spiceflow.adapters.opencode :as opencode]
|
||||||
|
[spiceflow.adapters.tmux :as tmux]
|
||||||
|
[spiceflow.push.sender :as push-sender]
|
||||||
[clojure.tools.logging :as log])
|
[clojure.tools.logging :as log])
|
||||||
(:import [java.util.concurrent ConcurrentHashMap]))
|
(:import [java.util.concurrent ConcurrentHashMap]))
|
||||||
|
|
||||||
;; Active process handles for running sessions
|
;; Active process handles for running sessions
|
||||||
(defonce ^:private active-processes (ConcurrentHashMap.))
|
(defonce ^:private active-processes (ConcurrentHashMap.))
|
||||||
|
|
||||||
|
;; Push store for sending notifications (set by core.clj)
|
||||||
|
(defonce ^:private push-store (atom nil))
|
||||||
|
|
||||||
|
;; Forward declaration for use in send-permission-notification-delayed!
|
||||||
|
(declare get-pending-permission)
|
||||||
|
|
||||||
|
(defn set-push-store!
|
||||||
|
"Set the push store for sending notifications"
|
||||||
|
[store]
|
||||||
|
(reset! push-store store))
|
||||||
|
|
||||||
|
(defn- send-permission-notification-delayed!
|
||||||
|
"Send push notification for a permission request after a delay, only if still pending"
|
||||||
|
[store session-id session perm-req delay-ms]
|
||||||
|
(when-let [pstore @push-store]
|
||||||
|
(future
|
||||||
|
(try
|
||||||
|
(Thread/sleep delay-ms)
|
||||||
|
;; Check if permission is still pending
|
||||||
|
(when-let [pending (get-pending-permission store session-id)]
|
||||||
|
;; Verify same permission request (by message-id) to avoid race conditions
|
||||||
|
(when (= (:message-id pending) (:message-id perm-req))
|
||||||
|
(let [tools (:tools perm-req)
|
||||||
|
payload {:title "Permission Required"
|
||||||
|
:body (str "Claude wants to use: " (clojure.string/join ", " tools))
|
||||||
|
:sessionId (:id session)
|
||||||
|
:sessionTitle (or (:title session) "Untitled Session")
|
||||||
|
:tools tools}]
|
||||||
|
(log/debug "Sending push notification for permission request:" (:tools perm-req))
|
||||||
|
(push-sender/send-to-all-subscriptions pstore payload))))
|
||||||
|
(catch Exception e
|
||||||
|
(log/error e "Failed to send push notification"))))))
|
||||||
|
|
||||||
(defn get-adapter
|
(defn get-adapter
|
||||||
"Get the appropriate adapter for a provider"
|
"Get the appropriate adapter for a provider"
|
||||||
[provider]
|
[provider]
|
||||||
(case (keyword provider)
|
(case (keyword provider)
|
||||||
:claude (claude/create-adapter)
|
:claude (claude/create-adapter)
|
||||||
:opencode (opencode/create-adapter)
|
:opencode (opencode/create-adapter)
|
||||||
|
:tmux (tmux/create-adapter)
|
||||||
(throw (ex-info "Unknown provider" {:provider provider}))))
|
(throw (ex-info "Unknown provider" {:provider provider}))))
|
||||||
|
|
||||||
(defn get-active-process
|
(defn get-active-process
|
||||||
@@ -42,15 +78,10 @@
|
|||||||
;; Fall back to working-dir for existing sessions that don't have spawn-dir yet
|
;; Fall back to working-dir for existing sessions that don't have spawn-dir yet
|
||||||
(let [adapter (get-adapter (:provider session))
|
(let [adapter (get-adapter (:provider session))
|
||||||
spawn-dir (or (:spawn-dir session) (:working-dir session))
|
spawn-dir (or (:spawn-dir session) (:working-dir session))
|
||||||
;; Pre-grant Write/Edit tools if auto-accept-edits is enabled
|
_ (log/debug "Starting session with spawn-dir:" spawn-dir "external-id:" (:external-id session))
|
||||||
allowed-tools (when (:auto-accept-edits session)
|
|
||||||
["Write" "Edit"])
|
|
||||||
_ (log/debug "Starting session with spawn-dir:" spawn-dir "external-id:" (:external-id session) "allowed-tools:" allowed-tools)
|
|
||||||
handle (adapter/spawn-session adapter
|
handle (adapter/spawn-session adapter
|
||||||
(:external-id session)
|
(:external-id session)
|
||||||
(cond-> {:working-dir spawn-dir}
|
{:working-dir spawn-dir})]
|
||||||
(seq allowed-tools)
|
|
||||||
(assoc :allowed-tools allowed-tools)))]
|
|
||||||
(.put active-processes session-id handle)
|
(.put active-processes session-id handle)
|
||||||
(db/update-session store session-id {:status :processing})
|
(db/update-session store session-id {:status :processing})
|
||||||
handle)))
|
handle)))
|
||||||
@@ -68,7 +99,8 @@
|
|||||||
(defn send-message-to-session
|
(defn send-message-to-session
|
||||||
"Send a message to a running session"
|
"Send a message to a running session"
|
||||||
[store session-id message]
|
[store session-id message]
|
||||||
(log/debug "User action: send-message" {:session-id session-id :message message})
|
(log/debug "User action: send-message" {:session-id session-id})
|
||||||
|
(log/debug "User message content:" message)
|
||||||
(let [session (db/get-session store session-id)
|
(let [session (db/get-session store session-id)
|
||||||
_ (when-not session
|
_ (when-not session
|
||||||
(throw (ex-info "Session not found" {:session-id session-id})))
|
(throw (ex-info "Session not found" {:session-id session-id})))
|
||||||
@@ -82,11 +114,11 @@
|
|||||||
:content message})
|
:content message})
|
||||||
;; Send to CLI - for OpenCode, this returns an updated handle with the process
|
;; Send to CLI - for OpenCode, this returns an updated handle with the process
|
||||||
(let [result (adapter/send-message adapter handle message)]
|
(let [result (adapter/send-message adapter handle message)]
|
||||||
(log/info "send-message result type:" (type result) "has-process:" (boolean (:process result)))
|
(log/debug "send-message result type:" (type result) "has-process:" (boolean (:process result)))
|
||||||
;; If result is a map with :process, it's an updated handle (OpenCode)
|
;; If result is a map with :process, it's an updated handle (OpenCode)
|
||||||
;; Store it so stream-session-response can use it
|
;; Store it so stream-session-response can use it
|
||||||
(when (and (map? result) (:process result))
|
(when (and (map? result) (:process result))
|
||||||
(log/info "Storing updated handle with process for session:" session-id)
|
(log/debug "Storing updated handle with process for session:" session-id)
|
||||||
(.put active-processes session-id result))
|
(.put active-processes session-id result))
|
||||||
result)))
|
result)))
|
||||||
|
|
||||||
@@ -142,26 +174,36 @@
|
|||||||
(sort-by count)
|
(sort-by count)
|
||||||
last)))
|
last)))
|
||||||
|
|
||||||
|
(defn- should-auto-accept?
|
||||||
|
"Check if a permission request should be auto-accepted.
|
||||||
|
Returns true if auto-accept-edits is enabled and all requested tools are Write/Edit."
|
||||||
|
[session perm-req]
|
||||||
|
(and (:auto-accept-edits session)
|
||||||
|
(every? #{"Write" "Edit"} (:tools perm-req))))
|
||||||
|
|
||||||
|
(declare stream-session-response respond-to-permission)
|
||||||
|
|
||||||
(defn stream-session-response
|
(defn stream-session-response
|
||||||
"Stream response from a running session, calling callback for each event"
|
"Stream response from a running session, calling callback for each event"
|
||||||
[store session-id callback]
|
[store session-id callback]
|
||||||
(log/info "stream-session-response starting for session:" session-id)
|
(log/debug "stream-session-response starting for session:" session-id)
|
||||||
(let [session (db/get-session store session-id)
|
(let [session (db/get-session store session-id)
|
||||||
_ (when-not session
|
_ (when-not session
|
||||||
(throw (ex-info "Session not found" {:session-id session-id})))
|
(throw (ex-info "Session not found" {:session-id session-id})))
|
||||||
handle (get-active-process session-id)
|
handle (get-active-process session-id)
|
||||||
_ (log/info "Got handle for session:" session-id "has-process:" (boolean (:process handle)) "has-stdout:" (boolean (:stdout handle)))
|
_ (log/debug "Got handle for session:" session-id "has-process:" (boolean (:process handle)) "has-stdout:" (boolean (:stdout handle)))
|
||||||
_ (when-not handle
|
_ (when-not handle
|
||||||
(throw (ex-info "Session not running" {:session-id session-id})))
|
(throw (ex-info "Session not running" {:session-id session-id})))
|
||||||
adapter (get-adapter (:provider session))
|
adapter (get-adapter (:provider session))
|
||||||
content-buffer (StringBuilder.)
|
content-buffer (StringBuilder.)
|
||||||
last-working-dir (atom nil)]
|
last-working-dir (atom nil)
|
||||||
|
auto-accepted (atom false)]
|
||||||
;; Read stream and accumulate content
|
;; Read stream and accumulate content
|
||||||
(log/info "Starting to read stream for session:" session-id)
|
(log/debug "Starting to read stream for session:" session-id)
|
||||||
(adapter/read-stream adapter handle
|
(adapter/read-stream adapter handle
|
||||||
(fn [event]
|
(fn [event]
|
||||||
(log/info "Received event:" (:event event) "text:" (when (:text event) (subs (str (:text event)) 0 (min 50 (count (str (:text event)))))))
|
(log/debug "LLM event:" (:event event) (when (:text event) (str "text-length:" (count (str (:text event))))))
|
||||||
(log/debug "Agent response full event:" (pr-str event))
|
(log/debug "LLM response full event:" (pr-str event))
|
||||||
(callback event)
|
(callback event)
|
||||||
;; Accumulate text content
|
;; Accumulate text content
|
||||||
(when-let [text (:text event)]
|
(when-let [text (:text event)]
|
||||||
@@ -185,7 +227,7 @@
|
|||||||
(when (and (= :user (:role event))
|
(when (and (= :user (:role event))
|
||||||
(vector? (:content event)))
|
(vector? (:content event)))
|
||||||
(when-let [dir (extract-working-dir-from-tool-result (:content event))]
|
(when-let [dir (extract-working-dir-from-tool-result (:content event))]
|
||||||
(log/info "Detected working directory from tool result:" dir)
|
(log/debug "Detected working directory from tool result:" dir)
|
||||||
(reset! last-working-dir dir)
|
(reset! last-working-dir dir)
|
||||||
;; Emit working-dir-update event so UI can update in real-time
|
;; Emit working-dir-update event so UI can update in real-time
|
||||||
(callback {:event :working-dir-update
|
(callback {:event :working-dir-update
|
||||||
@@ -210,44 +252,80 @@
|
|||||||
(callback {:event :content-delta :text result-content}))
|
(callback {:event :content-delta :text result-content}))
|
||||||
;; Save message if any content
|
;; Save message if any content
|
||||||
(when (seq content)
|
(when (seq content)
|
||||||
|
(log/debug "LLM response complete - saving assistant message")
|
||||||
|
(log/debug "LLM response content:" content)
|
||||||
(db/save-message store {:session-id session-id
|
(db/save-message store {:session-id session-id
|
||||||
:role :assistant
|
:role :assistant
|
||||||
:content content}))
|
:content content}))
|
||||||
;; If there's a permission request, save it as a message and emit event
|
;; If there's a permission request, save it as a message and emit event
|
||||||
(when-let [perm-req (:permission-request event)]
|
(when-let [perm-req (:permission-request event)]
|
||||||
(log/info "Permission request detected:" perm-req)
|
(log/debug "LLM permission request detected:" (pr-str perm-req))
|
||||||
;; Build description for the permission message content
|
;; Build description for the permission message content
|
||||||
(let [description (->> (:denials perm-req)
|
(let [description (->> (:denials perm-req)
|
||||||
(map (fn [{:keys [tool description]}]
|
(map (fn [{:keys [tool description]}]
|
||||||
(str tool ": " description)))
|
(str tool ": " description)))
|
||||||
(clojure.string/join "\n"))
|
(clojure.string/join "\n"))
|
||||||
|
;; Check if we should auto-accept this permission
|
||||||
|
auto-accept? (should-auto-accept? session perm-req)
|
||||||
;; Save permission request as a system message
|
;; Save permission request as a system message
|
||||||
|
;; If auto-accepting, mark as "accept" status immediately
|
||||||
perm-msg (db/save-message store
|
perm-msg (db/save-message store
|
||||||
{:session-id session-id
|
{:session-id session-id
|
||||||
:role :system
|
:role :system
|
||||||
:content description
|
:content description
|
||||||
:metadata {:type "permission-request"
|
:metadata (cond-> {:type "permission-request"
|
||||||
:denials (:denials perm-req)
|
:denials (:denials perm-req)
|
||||||
:tools (:tools perm-req)}})
|
:tools (:tools perm-req)}
|
||||||
|
auto-accept?
|
||||||
|
(assoc :status "accept"))})
|
||||||
msg-id (:id perm-msg)]
|
msg-id (:id perm-msg)]
|
||||||
;; Store pending permission with message ID for later update
|
(if auto-accept?
|
||||||
(set-pending-permission store session-id
|
;; Auto-accept: set up for continuation after stream ends
|
||||||
(assoc perm-req :message-id msg-id))
|
(do
|
||||||
(callback {:event :permission-request
|
(log/debug "Auto-accepting permission request for tools:" (:tools perm-req))
|
||||||
:permission-request perm-req
|
;; Store pending permission so respond-to-permission can use it
|
||||||
:message-id msg-id
|
(set-pending-permission store session-id
|
||||||
:message perm-msg})))))))
|
(assoc perm-req :message-id msg-id))
|
||||||
|
;; Emit permission event with auto-accept flag so UI knows it was auto-accepted
|
||||||
|
(callback {:event :permission-request
|
||||||
|
:permission-request perm-req
|
||||||
|
:message-id msg-id
|
||||||
|
:message perm-msg
|
||||||
|
:auto-accepted true})
|
||||||
|
;; Mark that we should auto-accept after stream ends
|
||||||
|
(reset! auto-accepted true))
|
||||||
|
;; Normal flow: store pending and wait for user response
|
||||||
|
(let [perm-req-with-id (assoc perm-req :message-id msg-id)]
|
||||||
|
(set-pending-permission store session-id perm-req-with-id)
|
||||||
|
;; Send push notification for permission request after 15s delay
|
||||||
|
(send-permission-notification-delayed! store session-id session perm-req-with-id 15000)
|
||||||
|
(callback {:event :permission-request
|
||||||
|
:permission-request perm-req
|
||||||
|
:message-id msg-id
|
||||||
|
:message perm-msg})))))))))
|
||||||
;; Update session with last known working directory
|
;; Update session with last known working directory
|
||||||
(when @last-working-dir
|
(when @last-working-dir
|
||||||
(log/info "Updating session working directory to:" @last-working-dir)
|
(log/debug "Updating session working directory to:" @last-working-dir)
|
||||||
(db/update-session store session-id {:working-dir @last-working-dir}))
|
(db/update-session store session-id {:working-dir @last-working-dir}))
|
||||||
;; Update session status when stream ends
|
;; Handle auto-accept continuation
|
||||||
;; If there's a pending permission, set status to awaiting-permission
|
(if @auto-accepted
|
||||||
(let [new-status (if (get-pending-permission store session-id)
|
(do
|
||||||
:awaiting-permission
|
(log/debug "Processing auto-accept for session:" session-id)
|
||||||
:idle)]
|
;; Remove the old process handle (stream has ended)
|
||||||
(db/update-session store session-id {:status new-status}))
|
(.remove active-processes session-id)
|
||||||
(.remove active-processes session-id)))
|
;; Respond to permission (spawns new process)
|
||||||
|
(respond-to-permission store session-id :accept nil)
|
||||||
|
;; Continue streaming the new response
|
||||||
|
(stream-session-response store session-id callback))
|
||||||
|
;; Normal flow: update status and cleanup
|
||||||
|
(do
|
||||||
|
;; Update session status when stream ends
|
||||||
|
;; If there's a pending permission, set status to awaiting-permission
|
||||||
|
(let [new-status (if (get-pending-permission store session-id)
|
||||||
|
:awaiting-permission
|
||||||
|
:idle)]
|
||||||
|
(db/update-session store session-id {:status new-status}))
|
||||||
|
(.remove active-processes session-id)))))
|
||||||
|
|
||||||
(defn cleanup-all
|
(defn cleanup-all
|
||||||
"Stop all running sessions"
|
"Stop all running sessions"
|
||||||
@@ -263,7 +341,8 @@
|
|||||||
response-type: :accept, :deny, or :steer
|
response-type: :accept, :deny, or :steer
|
||||||
message: optional message for :deny or :steer responses"
|
message: optional message for :deny or :steer responses"
|
||||||
[store session-id response-type message]
|
[store session-id response-type message]
|
||||||
(log/debug "User action: permission-response" {:session-id session-id :response-type response-type :message message})
|
(log/debug "User action: permission-response" {:session-id session-id :response-type response-type})
|
||||||
|
(when message (log/debug "User steer message:" message))
|
||||||
(let [session (db/get-session store session-id)
|
(let [session (db/get-session store session-id)
|
||||||
_ (when-not session
|
_ (when-not session
|
||||||
(throw (ex-info "Session not found" {:session-id session-id})))
|
(throw (ex-info "Session not found" {:session-id session-id})))
|
||||||
@@ -273,17 +352,11 @@
|
|||||||
adapter (get-adapter (:provider session))
|
adapter (get-adapter (:provider session))
|
||||||
;; Use spawn-dir for spawning, fall back to working-dir for existing sessions
|
;; Use spawn-dir for spawning, fall back to working-dir for existing sessions
|
||||||
spawn-dir (or (:spawn-dir session) (:working-dir session))
|
spawn-dir (or (:spawn-dir session) (:working-dir session))
|
||||||
;; Auto-accept tools from session setting (always included if enabled)
|
;; Build spawn options based on response type
|
||||||
auto-accept-tools (when (:auto-accept-edits session)
|
|
||||||
["Write" "Edit"])
|
|
||||||
;; Tools granted from accepting the permission request
|
|
||||||
granted-tools (when (= response-type :accept) (:tools pending))
|
|
||||||
;; Combine both sets of allowed tools
|
|
||||||
all-allowed-tools (seq (distinct (concat auto-accept-tools granted-tools)))
|
|
||||||
;; Build spawn options
|
|
||||||
opts (cond-> {:working-dir spawn-dir}
|
opts (cond-> {:working-dir spawn-dir}
|
||||||
all-allowed-tools
|
;; For :accept, grant the requested tools
|
||||||
(assoc :allowed-tools (vec all-allowed-tools)))
|
(= response-type :accept)
|
||||||
|
(assoc :allowed-tools (:tools pending)))
|
||||||
;; Determine the message to send
|
;; Determine the message to send
|
||||||
send-msg (case response-type
|
send-msg (case response-type
|
||||||
:accept "continue"
|
:accept "continue"
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Hello from OpenCode test
|
|
||||||
Reference in New Issue
Block a user