script-mode task execution — database-backed tasks with direct script runner

Tasks now live in the database (mode: script or agentic). Script-mode tasks
bypass the agent entirely — the implementation is materialized to a temp file
and executed directly, with stdout/stderr streamed to the UI via WebSocket.

Includes convert-to-mp3 as the first native script task.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 16:07:59 +00:00
co-authored by Claude Opus 4.6
parent 1159978187
commit f32f427972
15 changed files with 3238 additions and 209 deletions
@@ -8,6 +8,7 @@ import { usePiChat, MessageBubble, StreamingBubble, ModelSelector } from '../../
import { useSettings } from 'state/useSettings';
import { useUserVisibleModels } from 'state/useModels';
import type { TaskSummary } from '../../useTasks';
import { useTaskRunner } from './useTaskRunner';
const playDing = () => {
const ctx = new AudioContext();
@@ -190,6 +191,97 @@ const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: P
);
};
// ── Script-mode runner ──
type ScriptRunnerProps = {
taskDirName: string;
inputs: Record<string, string>;
cwd?: string;
};
const ScriptRunner = ({ taskDirName, inputs, cwd }: ScriptRunnerProps) => {
const runner = useTaskRunner();
const bottomRef = useRef<HTMLDivElement | null>(null);
// Auto-scroll
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [runner.output]);
// Ding on completion
const prevPhaseRef = useRef(runner.phase);
useEffect(() => {
if (prevPhaseRef.current === 'running' && runner.phase === 'done') {
playDing();
}
prevPhaseRef.current = runner.phase;
}, [runner.phase]);
const handleRun = () => {
runner.run(taskDirName, inputs, cwd);
};
if (runner.phase === 'ready') {
return (
<div className="flex-1 flex items-center justify-center">
<button
onClick={handleRun}
disabled={!runner.isConnected}
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<Play className="h-4 w-4" />
Run
</button>
</div>
);
}
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
{runner.output.map((line, i) => (
<span
key={i}
className={
line.stream === 'stderr'
? 'text-red-400'
: line.stream === 'system'
? 'text-duck-teal/70'
: 'text-foreground'
}
>
{line.text}
</span>
))}
</pre>
<div ref={bottomRef} />
</div>
<div className="shrink-0 flex justify-center py-3 border-t border-duck-dark/10">
{runner.phase === 'running' ? (
<button
onClick={runner.stop}
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-red-500/10 text-red-600 text-sm font-medium hover:bg-red-500/20 transition-colors cursor-pointer"
>
<Square className="h-3.5 w-3.5" />
Stop
</button>
) : runner.exitCode !== 0 ? (
<span className="flex items-center gap-2 text-sm text-red-500 font-medium">
<CircleX className="h-4 w-4" />
Task failed (exit {runner.exitCode})
</span>
) : (
<span className="flex items-center gap-2 text-sm text-green-500 font-medium">
<CircleCheck className="h-4 w-4" />
Task complete
</span>
)}
</div>
</div>
);
};
type TaskRunnerModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -207,12 +299,19 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
const { settings } = useSettings();
const taskSettings = settings.tasks;
const entryRef = entryFullPath ?? entryName;
const isScript = task.mode === 'script';
// Agentic mode prompt
const defaultInput = promptOverride
?? (entryRef && entryType
? `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryRef}`
: `Read the task instructions at ${task.filePath} and execute them`);
? `Execute the task "${task.name}" (${task.dirName}) on the ${entryType}: ${entryRef}`
: `Execute the task "${task.name}" (${task.dirName})`);
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' };
// Script mode inputs — for now, map the entry path to file_path
const scriptInputs: Record<string, string> = {};
if (entryFullPath) scriptInputs.file_path = entryFullPath;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogPortal>
@@ -238,15 +337,24 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
)}
</div>
{/* Task Runner */}
<PiMonoInner
key="pi"
defaultInput={defaultInput}
cwd={cwd}
initialModel={taskSettings.defaultProvider === 'pi' ? taskSettings.defaultModel : null}
taskInfo={taskInfo}
sandboxed={sandboxed}
/>
{/* Task Runner — branch on mode */}
{isScript ? (
<ScriptRunner
key="script"
taskDirName={task.dirName}
inputs={scriptInputs}
cwd={cwd.path || undefined}
/>
) : (
<PiMonoInner
key="pi"
defaultInput={defaultInput}
cwd={cwd}
initialModel={taskSettings.defaultProvider === 'pi' ? taskSettings.defaultModel : null}
taskInfo={taskInfo}
sandboxed={sandboxed}
/>
)}
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
@@ -0,0 +1,82 @@
import { useState, useEffect, useRef, useCallback } from 'react';
type Phase = 'ready' | 'running' | 'done';
type ServerMessage =
| { type: 'started'; taskName: string }
| { type: 'stdout'; data: string }
| { type: 'stderr'; data: string }
| { type: 'exit'; code: number }
| { type: 'error'; message: string };
export function useTaskRunner() {
const [phase, setPhase] = useState<Phase>('ready');
const [output, setOutput] = useState<Array<{ stream: 'stdout' | 'stderr' | 'system'; text: string }>>([]);
const [exitCode, setExitCode] = useState<number | null>(null);
const [isConnected, setIsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
const token = localStorage.getItem('BEARER_TOKEN');
if (!token) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${window.location.host}/api/tasks/run/ws?token=${token}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.addEventListener('open', () => setIsConnected(true));
ws.addEventListener('close', () => setIsConnected(false));
ws.addEventListener('message', (ev) => {
try {
const msg = JSON.parse(ev.data) as ServerMessage;
switch (msg.type) {
case 'started':
setOutput((prev) => [...prev, { stream: 'system', text: `Running: ${msg.taskName}\n` }]);
break;
case 'stdout':
setOutput((prev) => [...prev, { stream: 'stdout', text: msg.data }]);
break;
case 'stderr':
setOutput((prev) => [...prev, { stream: 'stderr', text: msg.data }]);
break;
case 'exit':
setExitCode(msg.code);
setPhase('done');
break;
case 'error':
setOutput((prev) => [...prev, { stream: 'stderr', text: `Error: ${msg.message}\n` }]);
setPhase('done');
setExitCode(-1);
break;
}
} catch {
// ignore
}
});
return () => {
ws.close();
wsRef.current = null;
};
}, []);
const run = useCallback((taskDirName: string, inputs: Record<string, string>, cwd?: string) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
setPhase('running');
setOutput([]);
setExitCode(null);
wsRef.current.send(JSON.stringify({ type: 'run', taskDirName, inputs, cwd }));
}, []);
const stop = useCallback(() => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
wsRef.current.send(JSON.stringify({ type: 'stop' }));
}, []);
return { phase, output, exitCode, isConnected, run, stop };
}
@@ -4,12 +4,14 @@ import { useClient } from 'hooks/useClient';
type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
export type TaskSummary = {
id: number;
dirName: string;
name: string;
description: string;
scope: 'user' | 'global';
scope: string;
triggers: TriggerConfig[];
filePath: string;
mode: 'script' | 'agentic';
userId: number | null;
};
export const useTasks = () => {