commit f43833ebf8f931fffbf8c1029f94d4268de6a692 Author: Agent Date: Tue Oct 13 11:10:52 2026 +0000 feat: add custom queue and steering controls diff --git a/README.md b/README.md new file mode 100644 index 0000000..41a5975 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# @local/dsh-queue-steer + +Custom queue and steering controls for DeepSeek Harness. + +## Features + +- `/queue [message]` appends a message to the current agent's next-turn queue. +- `/steer [message]` sends a message to the active step (or follows up when idle). +- **Queue & Steering** settings page controls the default busy-message mode, default list, and the batch template. +- Replaces the built-in queue dock with two lists: + - **List 1 — all at once:** entries are presented as a batch. + - **List 2 — one at a time:** entries remain individual messages. +- Queue rows can be reordered by dragging, moved between lists, edited inline, removed, or steered. +- Escape exits editing and stores the in-progress value as a local draft; reopening the row restores it. + +## Development + +The plugin is loaded from the `link:` entry in the web profile. Host changes require a DSH restart; client changes require a browser refresh. diff --git a/client.js b/client.js new file mode 100644 index 0000000..132b1eb --- /dev/null +++ b/client.js @@ -0,0 +1,73 @@ +window.__ModuleLoader__.load({ + id: '@local/dsh-queue-steer', + factory(require) { + const React = require('react'); + const h = React.createElement; + const NS = 'dshQueueSteer'; + const P = require('@deepseek-ai/dsh-client-ui-primitives') || {}; + const css = ` + .dqs-dock{width:calc(100% - 32px);max-width:760px;margin:0 auto -3px;padding:0 8px;font:13px var(--dsw-font-family,Inter,sans-serif)} + .dqs-panel{background:var(--dsw-specific-menu);border:1px solid var(--dsw-alias-border-l1);border-bottom:0;border-radius:12px 12px 0 0;padding:4px 8px;overflow:hidden} + .dqs-header{width:100%;display:flex;align-items:center;gap:8px;border:0;background:none;color:var(--dsw-alias-label-primary);padding:7px 5px;cursor:pointer;text-align:left} + .dqs-count{flex:1;font-weight:600}.dqs-tabs{display:flex;gap:4px;border-bottom:1px solid var(--dsw-alias-border-l2);padding:2px 0 5px} + .dqs-tab{border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);padding:5px 8px;cursor:pointer}.dqs-tab.active{background:var(--dsw-alias-bg-l2);color:var(--dsw-alias-label-primary);font-weight:600} + .dqs-list{list-style:none;padding:3px 0;margin:0;display:grid;gap:2px}.dqs-row{display:flex;align-items:center;gap:7px;padding:6px;border-radius:7px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-l1)} + .dqs-row.editing{outline:2px solid var(--dsw-alias-state-business-primary);background:var(--dsw-alias-bg-l2)}.dqs-row.over{border-top:2px solid var(--dsw-alias-state-business-primary)}.dqs-grip{cursor:grab;opacity:.6}.dqs-preview{white-space:pre-wrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0;max-height:3.2em}.dqs-editor{flex:1;min-width:0;resize:none;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;background:var(--dsw-alias-bg-base);color:inherit;padding:5px;font:inherit}.dqs-actions{display:flex;gap:2px}.dqs-action{border:0;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:4px;border-radius:5px}.dqs-action:hover{background:var(--dsw-alias-bg-l2);color:var(--dsw-alias-label-primary)}.dqs-empty{padding:12px;color:var(--dsw-alias-label-tertiary);text-align:center}.dqs-draft{font-size:10px;color:var(--dsw-alias-state-business-primary)} + .dqs-settings{padding:4px 0}.dqs-settings h2{font-size:18px;margin:0 0 6px}.dqs-settings p{color:var(--dsw-alias-label-secondary);line-height:1.45}.dqs-field{display:grid;gap:5px;margin:14px 0}.dqs-field select,.dqs-field textarea{font:inherit;color:inherit;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:7px;padding:8px}.dqs-field textarea{min-height:80px}.dqs-save{border:0;border-radius:7px;background:var(--dsw-alias-state-business-primary);color:white;padding:8px 14px;cursor:pointer}.dqs-status{margin-left:10px;color:var(--dsw-alias-state-positive)} + `; + let style; + const installCss = () => { if (!style) { style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); } }; + const icon = (name, fallback) => P[name] ? h(P[name], { size: 14 }) : h('span', null, fallback); + const key = (id) => `dqs:${id}`; + const read = (id, fallback) => { try { const v = localStorage.getItem(key(id)); return v == null ? fallback : JSON.parse(v); } catch { return fallback; } }; + const write = (id, v) => { try { localStorage.setItem(key(id), JSON.stringify(v)); } catch {} }; + const rowText = (row) => (row.content || []).filter((b) => b.type === 'text').map((b) => b.text).join(''); + + function SettingsPanel() { + const [value, setValue] = React.useState({ defaultMode: 'queue', defaultQueueList: 'batch', batchHeaderTemplate: 'Queued messages batch ({count}):\\n\\n{messages}' }); + const [status, setStatus] = React.useState(''); + React.useEffect(() => { fetch('dsh-queue-steer/settings', { credentials: 'same-origin' }).then((r) => r.json()).then(setValue).catch(() => {}); }, []); + const save = () => { setStatus('Saving…'); fetch('dsh-queue-steer/settings', { method: 'POST', credentials: 'same-origin', headers: {'content-type':'application/json'}, body: JSON.stringify(value) }).then((r) => r.json()).then((v) => { setValue(v); setStatus('Saved'); }).catch(() => setStatus('Save failed')); }; + return h('section', { className: 'dqs-settings', 'data-dsh-plugin': 'dsh-queue-steer' }, + h('h2', null, 'Queue & Steering'), h('p', null, 'Choose how busy-composer messages are delivered and which queue list receives new entries.'), + h('label', { className: 'dqs-field' }, 'Default busy message action', h('select', { value: value.defaultMode, onChange: (e) => setValue({...value, defaultMode:e.target.value}) }, h('option', {value:'queue'}, 'Queue'), h('option', {value:'steer'}, 'Steer'))), + h('label', { className: 'dqs-field' }, 'Default queue list', h('select', { value: value.defaultQueueList, onChange: (e) => setValue({...value, defaultQueueList:e.target.value}) }, h('option', {value:'batch'}, 'List 1 — send all entries at once'), h('option', {value:'sequential'}, 'List 2 — send one entry at a time'))), + h('label', { className: 'dqs-field' }, 'Batch message template', h('textarea', { value: value.batchHeaderTemplate, onChange: (e) => setValue({...value, batchHeaderTemplate:e.target.value}) }), h('small', null, 'Placeholders: {count} and {messages}')), + h('button', { className: 'dqs-save', onClick: save }, 'Save settings'), status && h('span', {className:'dqs-status'}, status) + ); + } + + function QueueDock({ useSession, useProjection, updateQueue, notify }) { + const inbox = useProjection('inbox'); + const rows = inbox?.['next-turn'] || []; + const sessionId = useSession((s) => s.sessionId || s.id || 'current'); + const running = useSession((s) => !!s.running); + const [tab, setTab] = React.useState(() => read(`${sessionId}:tab`, 'batch')); + const [open, setOpen] = React.useState(true); + const [editing, setEditing] = React.useState(null); + const [busy, setBusy] = React.useState(null); + const [drag, setDrag] = React.useState(null); + const [assign, setAssign] = React.useState(() => read(`${sessionId}:assign`, {})); + const [order, setOrder] = React.useState(() => read(`${sessionId}:order`, [])); + const [drafts, setDrafts] = React.useState(() => read(`${sessionId}:drafts`, {})); + React.useEffect(installCss, []); + React.useEffect(() => { write(`${sessionId}:assign`, assign); }, [sessionId, assign]); + React.useEffect(() => { write(`${sessionId}:order`, order); }, [sessionId, order]); + React.useEffect(() => { write(`${sessionId}:drafts`, drafts); }, [sessionId, drafts]); + const sorted = React.useMemo(() => { const rank = new Map(order.map((id, i) => [id, i])); return [...rows].sort((a,b) => (rank.get(a.id) ?? 1e9) - (rank.get(b.id) ?? 1e9)); }, [rows, order]); + const listOf = (row) => assign[row.id] || 'batch'; + const visible = sorted.filter((r) => listOf(r) === tab); + const move = (id, target) => { setAssign((v) => ({...v, [id]: target})); setTab(target); }; + const startEdit = (row) => setEditing({ id: row.id, value: drafts[row.id] ?? rowText(row) }); + const cancelEdit = () => { if (editing) setDrafts((v) => ({...v, [editing.id]: editing.value})); setEditing(null); }; + const saveEdit = async () => { if (!editing || !editing.value.trim()) return; setBusy(editing.id); try { await updateQueue(editing.id, {kind:'edit', content:[{type:'text', text:editing.value}]}); setDrafts((v) => { const n={...v}; delete n[editing.id]; return n; }); setEditing(null); } catch { notify('error', 'Queue edit failed'); } finally { setBusy(null); } }; + const action = async (id, value) => { setBusy(id); try { await updateQueue(id, value); setDrafts((v) => { const n={...v}; delete n[id]; return n; }); } catch { notify('error', 'Queue action failed'); } finally { setBusy(null); } }; + const drop = (targetId) => { if (!drag || drag.id === targetId) return; const ids = sorted.map((r) => r.id).filter((id) => id !== drag.id); const at = ids.indexOf(targetId); ids.splice(Math.max(0, at), 0, drag.id); setOrder(ids); if (drag.list !== tab) move(drag.id, tab); setDrag(null); }; + if (!rows.length) return null; + const Item = ({ row }) => { const isEdit = editing?.id === row.id; const draft = drafts[row.id]; return h('li', { className: `dqs-row${isEdit?' editing':''}${drag?.over===row.id?' over':''}`, draggable: !isEdit, onDragStart: () => setDrag({id:row.id,list:tab}), onDragOver: (e) => { e.preventDefault(); setDrag((v) => v && ({...v, over:row.id})); }, onDrop: () => drop(row.id), onDragEnd: () => setDrag(null) }, h('span',{className:'dqs-grip','aria-hidden':true},'⋮⋮'), isEdit ? h('textarea',{className:'dqs-editor',autoFocus:true,value:editing.value,onChange:(e)=>setEditing({...editing,value:e.target.value}),onKeyDown:(e)=>{if(e.key==='Escape')cancelEdit();if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();saveEdit();}}}) : h('span',{className:'dqs-preview'}, draft || rowText(row)), draft && !isEdit && h('small',{className:'dqs-draft'},'draft'), h('span',{className:'dqs-actions'}, isEdit ? [h('button',{key:'save',className:'dqs-action',disabled:!!busy,onClick:saveEdit},icon('IconCheckOutlineRegular','✓')),h('button',{key:'cancel',className:'dqs-action',disabled:!!busy,onClick:cancelEdit},icon('IconCloseOutlineRegular','×'))] : [h('button',{key:'edit',className:'dqs-action',disabled:!!busy,onClick:()=>startEdit(row)},icon('IconEditOutlineRegular','✎')),h('button',{key:'move',className:'dqs-action',disabled:!!busy,onClick:()=>move(row.id,tab==='batch'?'sequential':'batch')},tab==='batch'?'⇢':'⇠'),h('button',{key:'steer',className:'dqs-action',disabled:!!busy||!running,onClick:()=>action(row.id,{kind:'steer'})},icon('IconSendOutlineRegular','➤')),h('button',{key:'remove',className:'dqs-action',disabled:!!busy,onClick:()=>action(row.id,{kind:'remove'})},icon('IconTrashOutlineRegular','×'))])); }; + return h('div',{className:'dqs-dock','data-queue-dock':'dsh-queue-steer'},h('div',{className:'dqs-panel'},h('button',{className:'dqs-header',onClick:()=>setOpen(!open)},icon('IconQueueOutlineRegular','☷'),h('span',{className:'dqs-count'},`Queue & Steering (${rows.length})`),h('span',null,open?'⌃':'⌄')),open && h(React.Fragment,null,h('div',{className:'dqs-tabs'},h('button',{className:`dqs-tab ${tab==='batch'?'active':''}`,onClick:()=>{setTab('batch');write(`${sessionId}:tab`,'batch')}},`List 1 · all at once (${sorted.filter((r)=>listOf(r)==='batch').length})`),h('button',{className:`dqs-tab ${tab==='sequential'?'active':''}`,onClick:()=>{setTab('sequential');write(`${sessionId}:tab`,'sequential')}},`List 2 · one at a time (${sorted.filter((r)=>listOf(r)==='sequential').length})`)),visible.length?h('ul',{className:'dqs-list'},visible.map((r)=>h(Item,{key:r.id,row:r}))):h('div',{className:'dqs-empty'},'No entries in this list')))); + } + + return { inject: ['slots', 'conversation'], apply(ctx) { installCss(); ctx.slots.inject('settings.section', () => ctx.slots.register({name:'settings.section', id:'dsh-queue-steer', order:170, label:'Queue & Steering'}, SettingsPanel)); ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({name:'conversation.input.dock', id:'queue', priority:-1, order:20, inject:(sessionId)=>({updateQueue:(id,a)=>ctx.conversation.updateQueue(id,a), notify:(level,msg)=>ctx.conversation.input.for(ctx.sessions.scope(sessionId)).notify(level,msg)})}, QueueDock)); } }; + } +}); diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..86f4982 --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,4 @@ +# cordis.patch.yml for dsh-queue-steer +bundles: + - id: dsh-queue-steer + name: '@local/dsh-queue-steer' diff --git a/index.js b/index.js new file mode 100644 index 0000000..e86e9c6 --- /dev/null +++ b/index.js @@ -0,0 +1,44 @@ +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import crypto from 'node:crypto' +import { CommandDefinitionId } from '@deepseek-ai/dsh-commands' + +export const name = 'dsh-queue-steer' +export const inject = { commands: { required: true }, webServer: { required: true }, connection: { required: true } } +const file = path.join(process.env.DSH_PROFILE_DIR || path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'profiles', 'web'), 'dsh-queue-steer', 'settings.json') +const defaults = { defaultMode: 'queue', defaultQueueList: 'batch', batchHeaderTemplate: 'Queued messages batch ({count}):\n\n{messages}' } +let settings = { ...defaults } +try { settings = { ...defaults, ...JSON.parse(fs.readFileSync(file, 'utf8')) } } catch {} +const save = (next) => { settings = { ...settings, ...next }; fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(settings, null, 2)) } +const message = (value, via) => ({ id: crypto.randomUUID(), role: 'user', content: [{ type: 'text', text: value }], source: { kind: 'user', via } }) + +export function apply(ctx) { + const add = (name, mode) => ctx.commands.register({ + definitionId: CommandDefinitionId(`@local/dsh-queue-steer#${name}`), name, + description: mode === 'steer' ? 'Steer the running agent with a message' : 'Queue a message for the agent', + input: { hint: '[message]' }, + handler: ({ rawInput, agent }) => { + const value = rawInput.trim() + if (!value) return { kind: 'error', text: `Usage: /${name} ` } + const m = message(value, `command-${name}`) + if (mode === 'steer' && agent.status === 'running') agent.steer(m) + else if (mode === 'steer') agent.followup(m) + else agent.inbox.append('next-turn', m) + return { kind: 'success', text: `${mode === 'steer' ? 'Sent' : 'Queued'}: ${value.slice(0, 120)}` } + }, + }) + add('queue', 'queue'); add('steer', 'steer') + ctx.inject(['webServer', 'connection'], (wctx) => wctx.effect(() => wctx.webServer.register({ + kind: 'exact', path: '/dsh-queue-steer/settings', + handler: async (req, res) => { + const denied = wctx.connection.requestRejection(req) + res.setHeader('content-type', 'application/json; charset=utf-8'); res.setHeader('cache-control', 'no-store') + if (denied !== undefined) { res.statusCode = denied; return res.end(JSON.stringify({ error: 'rejected' })) } + if (req.method === 'GET') { res.statusCode = 200; return res.end(JSON.stringify(settings)) } + if (req.method !== 'POST') { res.statusCode = 405; return res.end() } + let body = ''; for await (const chunk of req) body += chunk + try { save(JSON.parse(body || '{}')); res.statusCode = 200; res.end(JSON.stringify(settings)) } catch (e) { res.statusCode = 400; res.end(JSON.stringify({ error: String(e.message || e) })) } + }, + }), 'dsh-queue-steer: settings')) +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9109935 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "@local/dsh-queue-steer", + "version": "0.2.0", + "description": "Custom queue and steering logic, settings page, /queue and /steer commands, and two-list queue widget for DeepSeek Harness.", + "type": "module", + "main": "index.js", + "dsh": { + "bundle": { "patch": "./cordis.patch.yml" }, + "client": { + "platform": "web", + "immediately": true, + "inject": [ + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-primitives" + ] + } + }, + "exports": { ".": "./index.js", "./client": "./client.js", "./package.json": "./package.json" }, + "files": ["index.js", "client.js", "cordis.patch.yml", "README.md"], + "scripts": { "test": "node --test test/*.test.mjs" }, + "dependencies": {} +}