To-do sidebar next to the chat: queue tasks, even while Claude is working. When Claude is clearly done, the to-do is checked off and the next one starts…

A to-do list as a sidebar next to the chat. You queue tasks, even while Claude is working. When Claude is certainly done, the running to-do is struck through, moves to the project-wide history, and the next one starts on its own. When that isn't certain (a question, an interruption, an error, an open plan), the list stops, reports the reason in a toast, and shows Continue, Mark as done and Skip in the sidebar.
The list runs by default: worklist never pauses on its own (only you do), nothing is skipped unless you press Skip, and the order you queued stays the order things run in.
Texts are English by default; set language to de for German.
Tested with Claude Code v2.1.295 · Plugin version 0.7.2
Cost: when the rules can't decide whether Claude is done, worklist asks Haiku 5.5 (claude-haiku-5-5, effort high; measured at about 0.01 US cents per case). These calls run through your session and count toward your usage. You can switch this off with the haiku option (see Configuration); the list then stops in those cases instead.
| Input | Effect |
|---|---|
/todo <task> | queue a task (everything after /todo is the task); confirmed by a toast only, so Claude doesn't read it |
/todo · /todos | open the sidebar |
/todos pause · /todos resume | pause the list, or continue it (with an open notice, resume acts like Continue) |
/todos done | mark the running or stopped to-do as done by hand |
/todos skip | skip the stopped to-do: it moves to the end, marked "skipped", and the next one starts |
/todos retry | send the stopped to-do again, in full |
/todos clear | delete open to-dos; the history stays |
/todos history | show the history (last 30) |
/todos status | check state, last decision with reason, Haiku cost |
/todos close | close the sidebar |
/todos help · /todos ? | all commands, the sidebar's buttons, each feature with its current state and how to change it, and your settings, drawn as a table (terminal and desktop app; Markdown elsewhere). The state is the one at the time you run it. /todo help queues a to-do "help". |
What Claude receives is exactly your to-do text, nothing added to the message. With the doneLine option on (default), worklist also passes Claude a hidden closing hint beside it (see below).
In the chat transcript a sent to-do appears as an orange line · worklist: to-do 2/5 sent with an orange box ("sent:" and the text); Claude's answer follows below. worklist recognizes its to-dos by their exact text, remembered per chat, so the box stays after a restart or --resume.
Commands as to-dos: a to-do that starts with /name (for example /review or /my-plugin:skill some arguments) runs as that slash command instead of being sent as text. worklist first checks that the command exists in this session; if it doesn't, the list stops with a notice and nothing is sent. A command that starts a turn for Claude (a skill, a prompt command) counts as done when that turn passes the usual check; a command that runs locally without a turn (such as /cost) counts as done once it has run. worklist's own /todo and /todos can't be queued.
In the sidebar:
● running · to-do 2/5 · 1:24, ◐ waiting for your answer in the chat, ◌ waiting for background work (2 helpers), ◇ free · next one starts shortly, ◇ free · list empty, ⏸ paused. Below it a bar with the progress of the list (done/total in this chat).to-do n/m · continued. After an interruption or error, the same to-do is sent again in full, first in line. Never pauses the list.Runs in: the desktop app (sidebar on the right, main surface) and the terminal (pane above the prompt). On mobile there is no input field; use /todo <task> there. In claude -p the commands work; the list keeps working as long as the session is open. /todo <task> prints nothing there (its confirmation is a toast); check with /todos status.
While Claude is working:
/todo <task> normally with Enter. The desktop app holds it in its queue while Claude works; Claude keeps working, and when the turn ends the to-do is queued (toast) and checked like everything else (tested with desktop app 2.1.288, also across 30 single steps). Don't press "Send now" on it: that interrupts Claude's turn. The sidebar's input field works as well and queues immediately./todo <task> is registered with immediate, so per the docs it runs while Claude is working (not tested in the terminal). The input field in the pane works as well.Queueing while Claude is free counts as your answer: if Claude's last answer ended with a question and you queue a to-do (input field, /todo, or a mod such as sidekick splitting a long message into to-dos), it starts after the settle time. If you queued it while Claude was still working (a /todo that the desktop app delivers right at the end of the turn counts as that), the normal check of that turn applies: after a question, it waits.
Answers through other mods count as yours: a message another mod sends on your click as your own words (for example sidekick's rewritten version or a quick-replies suggestion) counts like a message you typed: it answers a stopped to-do's question, and that to-do continues.
After every turn end (including after your own chat messages) these stages run; the first decision wins. The detection always understands English and German, whatever language is set to.
classic.Stop) → wait (see Waiting, with a limit)$.agent.list()) → wait? at the end, "Shall I", "Do you want", "Soll ich" …, options with a question; code doesn't count; a standalone "Done." below doesn't hide it) → askclaude-haiku-5-5, effort high, at most 1500 tokens and 10 s): only "done" with confidence ≥ 0.85 → continue, anything else (including no answer or a timeout) → askWhen in doubt, nothing is sent. After your own chat message, the list stops quietly on "ask" (no toast); queueing a to-do or Start now continues it. Loop guard: if the same to-do stops twice in a row without you replying in the chat, Continue disappears; your answer in the chat, Mark as done or Skip continue it. Continue counts toward the loop guard. After maxAutoRun to-dos in a row without your intervention, the list also stops.
After a restart, --resume or a chat switch the list is not paused (unless you paused it yourself). A to-do that was running goes back to its place at the front. Nothing is sent right away: the list starts after your next message ends cleanly, when you press Start now, or when you queue something.
$.agent.list()). Once none is busy, it checks the last turn end again; no new turn is needed.With every sent to-do (not with commands) and with Continue, Claude gets this hint, invisibly, as hook context beside the text (in German with "Fertig." when language is de):
This message is a task from the user's to-do list (worklist). If you need a decision from the user and the AskUserQuestion tool is available, ask with it and carry on after the answer; otherwise end your answer with a clear question and don't write "Done.". Write "Done." on a line of its own only when this to-do is completely finished.
When a to-do stopped with a question and you answer in the chat (or through another mod, see above), your answer gets one hint as well:
This message answers your question about the to-do "…" (worklist). If this to-do is then completely finished, end with "Done." on a line of its own; if something is still open or unclear, ask.
Neither is part of your message or shown in the chat; /todos status shows whether the hint was attached to the last to-do. doneLine switches both off; detection then relies on the rules and Haiku alone. A standalone "Done." (or "Fertig.") as the last line of an answer is hidden in the transcript. All your other messages get nothing.
/config → worklist → language: en (default) or de. It switches the sidebar, buttons, toasts, the line in the chat, the output of /todo and /todos, date and time formats, the hidden closing hint sent with each to-do, and the language of Haiku's short reason. Commands and their arguments are English in both languages.
$.store under queue:<session-id>. Another chat in the same project doesn't see or start it. After /clear the list moves to the cleared chat (it is the same workplace, e.g. after a handoff); /resume and /branch keep separate lists.history:<project-folder>, at most 300 entries.cost:<project-folder>.sent:<session-id>: the last 50, only a checksum of the text plus the number, so the orange box in the transcript stays after a restart./clear: open to-dos and the pause move along, and the list starts after your next message (usually the start prompt). A to-do that was running stops the list with a notice (Continue / Mark as done / Skip): your start prompt often carries that work on already, so worklist doesn't resend it on its own. The old chat's key is deleted once the new one is saved. After a restart or --resume that notice is gone, like every notice: the to-do is open again and starts after your next message.--resume: a running to-do goes back to its place; the list stays as it was (paused only if you paused it) and starts after your next message. After /reload-plugins it keeps running (run state in $.state)./config → worklist's plugin options:
| Key | Default | Meaning |
|---|---|---|
language | en | Language of the texts: en or de. |
haiku | on | Ask Haiku 5.5 in unclear cases (measured at about 0.01 US cents per case). Off: the list stops in those cases. |
doneLine | on | Hidden hints with every sent to-do and with your answer to a to-do's question (not shown in the chat); makes detection more reliable. |
settleSeconds | 3 | Settle time before sending the next to-do (1–30 s), after which worklist checks again that Claude is free. |
maxAutoRun | 15 | Maximum to-dos in a row without your intervention (1–100); then the list stops. |
claude plugin validate shows:
hooks: session.start, classic.SessionStart{source=clear}, command.run{command=todo}, command.run{command=todos}, prompt.submit, classic.UserPromptSubmit, turn.start, tool.call, tool.call{tool=TaskCreate}, tool.call{tool=TaskUpdate}, tool.call{tool=TodoWrite}, classic.Stop, classic.StopFailure, turn.complete, ui.render{component=UserMessage}, ui.render{component=AssistantMessage}, ui.render{component=Pane}, ui.render{component=CommandOutput, props has {command=todos}}
calls: $.agent.list, $.clock.every, $.clock.now, $.command.list, $.command.register, $.command.run, $.model.complete, $.prompt.submit, $.session.id, $.session.root, $.state.get, $.state.set, $.store.delete, $.store.get, $.store.set, $.ui.close, $.ui.focus, $.ui.log, $.ui.open, $.ui.resolve, $.ui.toast
state writes: worklist.paint, worklist.rt
state reads: worklist.paint, worklist.rt
In plain language:
$.prompt.submit (as your message): sends the next to-do or the continuation of a stopped one, only after a passed check or when you press a button.$.command.list, $.command.run: a to-do starting with /name runs as that command, after checking it exists.prompt.submit: only observes where a message comes from (you, another mod on your behalf, or something else); it never changes, drops or extends a message.classic.SessionStart (only source: clear): observes /clear and passes it on unchanged; afterwards the list moves to the cleared chat.classic.UserPromptSubmit: attaches the hidden hints, only to the to-do worklist just sent (or its continuation) and to your answer to a stopped to-do's question (switch off with doneLine); every other message passes unchanged.$.model.complete: Haiku 5.5 (fixed model id claude-haiku-5-5, effort high), only in stage 9, and it can be switched off.$.agent.list: reads whether helpers are still running (stage 4, and every 10 s while waiting).$.session.id, $.session.root: list per chat, history per project.$.store, $.state: list, history, cost, run state, and a redraw counter for the sidebar. $.store.delete only deletes worklist's own lists: this chat's list once it has become empty, and after /clear the old chat's list once it has been saved under the cleared chat.$.ui.*, $.clock.*, $.command.register: sidebar, toast, clock, /todo and /todos.ui.render{component=AssistantMessage}: hides a standalone "Done." or "Fertig." at the end of Claude's answers (display only; the stored answer stays unchanged). This applies in every session where worklist is loaded, not only for to-dos.ui.render{component=CommandOutput, props has {command=todos}}: draws the output of /todos help as a table (display only; every other /todos output stays as the engine draws it).ui.render{component=UserMessage}: shows a sent to-do in the transcript as an orange line with a box instead of the speech bubble (display only; your own messages stay unchanged).Add the marketplace once, then install the mod:
claude plugin marketplace add FynnXland/fynn-mods
claude plugin install worklist@fynn-mods
Inside a session the same works with /plugin marketplace add FynnXland/fynn-mods and /plugin install worklist@fynn-mods. The mod loads in the next session, or after /reload-plugins.
Check: /plugin shows … mod active · worklist.
Update: claude plugin update worklist@fynn-mods, or turn on auto-update for fynn-mods under Marketplaces in /plugin.
Remove: disable it under Installed in /plugin, or run claude plugin uninstall worklist@fynn-mods.
Try it for one session without installing (from a clone of the repo):
claude --plugin-dir <path-to-clone>/mods/worklist
/todos help keeps its drawn table only while the session runs: after a restart, --resume or /reload-plugins, older /todos help lines in the transcript show the Markdown version. Run it again for the table./todo or /todos puts focus into the field, but the desktop app (2.1.286) doesn't show the cursor there. Click into the field once./todo … during a turn in the desktop app is confirmed only when Claude's turn ends (the app holds it until then). "Send now" on it interrupts the turn. With the older desktop app 2.1.286, even a normally queued /todo ended the turn after the current step; use the input field there./reload-plugins while waiting for background work, worklist no longer knows the facts of the last turn end; the list then waits for you (Start now) instead of checking on its own./loop, CronCreate) or a background shell keeps the list waiting until you answer "Don't wait" (asked after 2 minutes) or a new turn starts./cost is missing but runs), so whether a command exists is decided by running it.[claude-code:unrecognized_model] (visible in claude -p output); the call works and effort is applied. worklist uses the fixed id on purpose: Haiku 5.5 thinks before answering, which counts against the token limit, so a future switch of the haiku alias won't silently break stage 9.claude-haiku-5-5, not the haiku alias, so ANTHROPIC_DEFAULT_HAIKU_MODEL doesn't apply. If your organization's model allowlist (availableModels) excludes that id, or a provider (Bedrock, Vertex, Foundry) doesn't resolve it, the call is refused and every unclear case stops the list (never an unchecked continue). Allow the id, or switch haiku off./cd in the same session, the history still goes to the old project./clear relies on the SessionStart hook with source clear; tested in the CLI and the desktop app./resume or /branch worklist detects the other chat on the next redraw, command, button press or turn (via the session ID), not immediately.language affects new texts; reasons already shown in an open notice stay in the old language until the next decision. The descriptions of /todo and /todos in the command menu follow the language set when the session started.Ideas and workflows are modeled on arbeitsliste from nikisge/niklas-mods (no license; no code taken).
hooks/register.ts 1825 lines1// worklist: To-do-Seitenleiste neben dem Chat (SPEC.md). Fynn reiht To-dos ein, auch während Claude arbeitet. Ist Claude nach
2// der Prüfung „sicher frei“ (check.ts, Stufen 1–9, Stufe 10 hier) fertig, wird das laufende To-do abgehakt, wandert in den
3// projektweiten Verlauf, und das nächste startet über den einmaligen Timer. Sonst hält die Liste an: Toast und Hinweisblock.
4// Ab 0.4.0 läuft die Liste standardmäßig: worklist pausiert nie von sich aus, Einreihen bei freiem Claude startet, und nichts
5// wird übersprungen („Fortsetzen“ behält die Reihenfolge, Antworten über andere Mods zählen als Fynns Antwort).
6// Im Zweifel wird nie gesendet. Alle beobachtenden Hooks geben das Ergebnis von next(e) unverändert weiter. Kontext hängt
7// worklist nur an seine eigenen To-dos und an Fynns Antwort auf die Rückfrage eines To-dos (unsichtbarer Hinweis). Beim Modell
8// kommt genau der To-do-Text an; ein To-do, das mit `/name` beginnt, läuft als Befehl. Texte auf Englisch oder Deutsch (i18n.ts).
9// Ab 0.6.0 zieht die Liste bei /clear in den geleerten Chat um (carryOver); /resume und /branch behalten getrennte Listen.
10// Vorbild für Ideen und Abläufe: arbeitsliste (nikisge/niklas-mods, ohne Lizenz, kein Code übernommen).
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, On, RenderNode, Timer } from 'claude-code'
13import { AGENT_TASKS, BUSY_STATUS, HAIKU, decideHaiku, decideRules, filterStop, haikuCost, haikuPrompt, haikuSystem, otherBackground, parseHaiku } from './check.ts'
14import type { Decision, StopFacts } from './check.ts'
15import { helpMarkdown, helpTree } from './help.ts'
16import type { HelpData } from './help.ts'
17import { HELP_WORDS, TODOS_WORDS, worklistHelp } from './helpdata.ts'
18import { ANSWER_HINTS, CONTINUE_TEXTS, DONE_HINTS, T, cents, hhmm, shortDate } from './i18n.ts'
19import type { Strings } from './i18n.ts'
20import {
21 QUEUE_MAX,
22 add,
23 cleanCost,
24 cleanHistory,
25 cleanQueue,
26 cleanSent,
27 cleanSettings,
28 cleanText,
29 commandOfTurn,
30 findSent,
31 firstSentence,
32 freshRuntime,
33 hideDoneMarker,
34 isTodoPrompt,
35 move,
36 nextOpen,
37 openItems,
38 parseCommand,
39 parseSent,
40 pushHistory,
41 pushSent,
42 remove,
43 reopen,
44 running,
45 setStatus,
46 skipToEnd,
47 textHash,
48 toFront,
49} from './model.ts'
50import type { Cost, HistoryEntry, Queue, Runtime, Settings, Todo } from './model.ts'
51import { ORANGE, clamp, clock, duration, renderPane } from './view.ts'
52import type { Actions, Control, NoticeKind, NowView, StatusLine, View } from './view.ts'
53
54const PANE = 'worklist'
55// Akzent der Hilfe-Tabelle (/todos help): Theme-Key statt ORANGE, passt sich hell und dunkel an; #D77757 lag auf Weiß bei
56// etwa 3,2:1 (docs/HELP-SPEC.md §4, Fynn 2026-10-09; ThemeKey types@2.1.295:12590)
57const HELP_ACCENT = 'claude'
58const TITLE = 'To-dos'
59// Fynns eigene Nachrichten: im Desktop `composer`, in -p/SDK `sdk`, vom Handy `bridge` (types: PromptOrigin)
60const FYNN = ['composer', 'sdk', 'bridge']
61// Herkünfte, bei denen eine To-do-Zeile im Chat am Text erkannt wird (der Desktop meldet worklists To-dos als `sdk`). Ohne
62// `composer`: Das ist im Desktop Fynns eigene Eingabe; tippt er den Text eines To-dos nach, bleibt es seine Nachricht
63const TEXT_ORIGINS = ['sdk', 'bridge', 'unclassified']
64// Nach einem Knopfdruck kurz warten statt der vollen Beruhigungszeit: Fynn hat selbst entschieden
65const PRESS_SETTLE_MS = 300
66const EDIT_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'MultiEdit']
67// Nach der Rückkehr von $.command.run: so lange auf den Turn warten, den ein Skill-Befehl auslöst. Im Prototyp (CLI 2.1.290)
68// kam dessen prompt.submit 6 ms, sein turn.start 100 ms nach der Rückkehr. Lokale Befehle (/cost) lösen keinen aus.
69const CMD_TURN_WAIT_MS = 1500
70// Befehle von worklist selbst: $.command.run überspringt den eigenen Hook (Debug-Log: „command.run skipped: re-entry“)
71const OWN_COMMANDS = ['todo', 'todos']
72// Ein /todo, das so kurz nach einem Turn-Ende kommt, hat Fynn während dieses Turns getippt: Der Desktop hält es bis zum
73// Turn-Ende in seiner Warteschlange und führt es 20 ms danach aus (SPEC 0.4.3, Phase 0, Runde 3)
74const LATE_MS = 3000
75// Warten auf Hintergrundarbeit (SPEC 0.4.2): Helfer alle 10 s nachprüfen, bei Shells, Monitoren, Weckaufträgen nach 2 Minuten fragen
76const WAIT_CHECK_MS = 10_000
77const WAIT_LIMIT_MS = 120_000
78
79// $.state: Laufzustand über einen Hot Reload hinweg (types/index.d.ts)
80const rtAtom = atom({ plugin: 'worklist', key: 'rt' }, freshRuntime(''))
81// Neuzeichnen nur der Seitenleiste: Sie liest diesen Zähler beim Zeichnen und abonniert ihn so; ein Schreiben zeichnet nur sie neu
82// (docs/raw/en/interface.md:716). $.ui.invalidate('ui.render') zeichnete dagegen auch jede Nachricht im Chat neu, weil die
83// UserMessage- und AssistantMessage-Matcher keine requestId nennen (types: $.ui.invalidate); im Desktop flackerte so die
84// Hover-Leiste der Nachrichten (Kopieren, Neu senden, Forken), solange Claude arbeitete.
85const paintAtom = atom({ plugin: 'worklist', key: 'paint' }, 0)
86
87// ---------- Zustand (Modul) ----------
88
89let settings: Settings = cleanSettings({})
90let root = ''
91let q: Queue = { items: [], paused: false }
92let history: HistoryEntry[] = []
93let cost: Cost = { usd: 0, calls: 0 }
94let rt: Runtime = freshRuntime('')
95
96// Pro Turn, flüchtig (nicht in $.state: reine Anzeige bzw. bis zum Turn-Ende)
97let activity = ''
98let stop: StopFacts | null = null
99let stopFailure = false
100let lastToolError = false
101let filesChanged = false
102// Kommt die nächste Nachricht von Fynn? null: noch keine gesehen (worklists eigene sieht prompt.submit nicht)
103let pendingFynn: boolean | null = null
104let settle: Timer | null = null
105let waitTimer: Timer | null = null
106let lastDecision = '–'
107let lastHaiku = '–'
108// Fakten des letzten Turn-Endes (nur im Speicher): für die Prüfung, wenn danach ein To-do eingereiht wird
109let lastSnap: Snapshot | null = null
110// Unsichtbarer Schluss-Hinweis: Prüfsumme des gerade gesendeten To-dos, bis classic.UserPromptSubmit ihn anhängt
111let hintFor: string | null = null
112let lastHint: '' | 'attached' | 'missing' = ''
113// Unsichtbarer Hinweis an Fynns Antwort auf die Rückfrage eines To-dos (0.4.0): Prüfsumme der Antwort und To-do-Text
114let answerHint: { h: string; todo: string } | null = null
115let answerInFlight = false
116// Befehls-To-do: dessen prompt.submit (Skill-Befehl, Herkunft worklist) ist schon durch, der Turn folgt gleich
117let cmdPrompt = false
118let storeWarned = false
119// Letzter Session-Wechsel, den syncSession bemerkt hat, mit der Liste des alten Chats: Kommt classic.SessionStart (clear) erst
120// danach, zieht carryOver sie trotzdem um (0.6.0; die Reihenfolge von Neuzeichnen und Hook ist nicht belegt)
121let switched: { from: string; to: string; q: Queue } | null = null
122let draft = ''
123// /todos help (0.7.0): Schnappschüsse der gezeichneten Hilfe unter ihrer Kennung, höchstens 10 (docs/HELP-SPEC.md §3)
124const drawnHelp = new Map<string, HelpData>()
125let helpNo = 0
126let historyOpen = false
127let historyAll = false
128let inputGen = 0
129const inputKey = () => (inputGen === 0 ? 'new-todo' : `new-todo-${inputGen}`)
130
131// Zeichnen
132const cache = new Map<string, { sig: string; node: RenderNode }>()
133let ticker: Timer | null = null
134let rendered = false
135let nowMs = 0
136
137/** Texte in der eingestellten Sprache. */
138function tx(): Strings {
139 return T[settings.lang]
140}
141
142/**
143 * Von Fynn: seine eigene Eingabe (`composer`, `sdk`, `bridge`) oder eine Nachricht, die ein anderer Mod auf seinen Klick als
144 * seine eigene sendet (`plugin` mit `asUser`, z. B. sidekicks Fassung, quick-replies; types: PromptOrigin, PromptSubmitArgs.asUser).
145 * Belegt im Desktop: quick-replies kommt als `plugin:quick-replies+asUser` (SPEC 0.4.3, Runde 2). Peers, Kanäle,
146 * Benachrichtigungen und worklist selbst zählen nicht.
147 */
148function isFynn(o: { kind: string }): boolean {
149 if (FYNN.includes(o.kind)) return true
150 const p = o as { kind: string; name?: string; asUser?: boolean }
151 return p.kind === 'plugin' && p.asUser === true && p.name !== 'worklist'
152}
153
154// ---------- Hilfen ----------
155
156/** Einmaliger Timer: erster Tick, dann cancel. Nie aus einem Hook heraus senden (SPEC → Lehren, Punkt 5). */
157function once($: EngineInterface, ms: number, fn: () => void): Timer {
158 const t = $.clock.every(ms, () => {
159 t.cancel()
160 fn()
161 })
162 return t
163}
164
165function redraw($: EngineInterface) {
166 update($, paintAtom, (n) => n + 1).catch((err: unknown) => $.ui.log(`worklist: redraw: ${String(err)}`, { to: 'debug' }))
167}
168
169async function persist($: EngineInterface) {
170 try {
171 await update($, rtAtom, () => rt)
172 } catch (err) {
173 $.ui.log(`worklist: state not saved: ${String(err)}`, { to: 'debug' })
174 }
175}
176
177/** Gespeicherte Liste lesen; ohne Store (Fehler) eine leere, die nur im Speicher lebt. */
178async function loadQueue($: EngineInterface, sid: string): Promise<Queue> {
179 return cleanQueue(await loadSafe($, `queue:${sid}`))
180}
181
182async function loadSafe($: EngineInterface, key: string): Promise<unknown> {
183 try {
184 return await $.store.get(key)
185 } catch (err) {
186 $.ui.log(`worklist: ${key} not read: ${String(err)}`, { to: 'debug' })
187 return undefined
188 }
189}
190
191async function saveQueue($: EngineInterface) {
192 try {
193 // Leere Liste: Schlüssel löschen statt liegen lassen; der Store hat 4 MiB für alles
194 if (q.items.length === 0) await $.store.delete(`queue:${rt.sid}`)
195 else await $.store.set(`queue:${rt.sid}`, q)
196 } catch (err) {
197 $.ui.log(`worklist: list not saved: ${String(err)}`, { to: 'debug' })
198 warnStore($)
199 }
200}
201
202/** Gesendete To-dos dieses Chats sichern: Daran erkennt die Zeile im Chat sie auch nach Resume wieder. */
203async function saveSent($: EngineInterface) {
204 try {
205 await $.store.set(`sent:${rt.sid}`, rt.sent)
206 } catch (err) {
207 $.ui.log(`worklist: sent list not saved: ${String(err)}`, { to: 'debug' })
208 warnStore($)
209 }
210}
211
212/** Speichern scheitert (z. B. 4 MiB voll): einmal pro Prozess Bescheid geben, die Liste lebt dann nur im Speicher. */
213function warnStore($: EngineInterface) {
214 if (storeWarned) return
215 storeWarned = true
216 $.ui.toast(tx().storeWarn, { timeoutMs: 8000 })
217}
218
219/** Nach jeder Änderung: speichern, Laufzustand sichern, neu zeichnen. */
220async function commit($: EngineInterface) {
221 // Hinweis „lief beim /clear“, dessen To-do entfernt oder gelöscht wurde: Es gibt nichts mehr zu entscheiden
222 if (rt.notice?.kind === 'cleared' && !q.items.some((t) => t.id === rt.notice?.todoId)) {
223 rt.notice = null
224 rt.state = openItems(q).length > 0 ? 'fresh' : 'idle'
225 rt.stateReason = ''
226 }
227 redraw($)
228 await saveQueue($)
229 await persist($)
230}
231
232async function now($: EngineInterface): Promise<number> {
233 nowMs = await $.clock.now()
234 return nowMs
235}
236
237/** Fynn hat gehandelt (Nachricht, Knopf, Befehl, Eingabe): Zähler für maxAutoRun zurück. */
238function fynnActed() {
239 rt.autoRun = 0
240}
241
242function cancelSettle() {
243 settle?.cancel()
244 settle = null
245 if (rt.state === 'checking') rt.state = 'idle'
246}
247
248async function busyAgents($: EngineInterface): Promise<string[]> {
249 const list = await $.agent.list()
250 return list.filter((a) => BUSY_STATUS.includes(a.status)).map((a) => a.description || a.type || a.id)
251}
252
253/** Nach dem Neustart bzw. Wechsel des Chats: Laufzustand neu; offene To-dos warten im Prüfstand `fresh` (0.4.0, nicht pausiert). */
254function freshStart(sid: string) {
255 rt = freshRuntime(sid)
256 // Ein laufendes To-do zurück auf offen, an seinem Platz. Pausiert bleibt die Liste nur, wenn Fynn sie pausiert hatte
257 const r = running(q)
258 if (r) q = reopen(q, r.id)
259 if (openItems(q).length > 0) rt.state = 'fresh'
260}
261
262/** Nach /clear gibt es eine neue Session-ID ohne session.start. Liefert true, wenn gewechselt wurde. */
263async function syncSession($: EngineInterface): Promise<boolean> {
264 const id = await $.session.id()
265 if (id === rt.sid) return false
266 switched = { from: rt.sid, to: id, q }
267 const next = await loadQueue($, id)
268 // Inzwischen hat ein anderer Pfad umgestellt (carryOver nach /clear): dessen Liste nicht mit der leeren überschreiben (Review 0.6.0 B1)
269 if (rt.sid === id) return true
270 await switchTo($, id, next)
271 return true
272}
273
274/**
275 * Auf einen anderen Chat umstellen: Laufzustand neu wie nach einem Neustart, mit der Liste `next`. Bis zum ersten await synchron,
276 * damit ein gleichzeitiger Abgleich `rt.sid` schon auf der neuen ID sieht. `cleared`: To-do, das beim /clear lief (Hinweis).
277 */
278async function switchTo($: EngineInterface, id: string, next: Queue, cleared?: string) {
279 cancelSettle()
280 stopWaitTimer()
281 hintFor = null
282 answerHint = null
283 q = next
284 freshStart(id)
285 if (cleared) {
286 rt.notice = { reason: tx().clearedRunning, todoId: cleared, kind: 'cleared' }
287 rt.state = 'ask'
288 rt.stateReason = rt.notice.reason
289 }
290 rt.sent = cleanSent(await loadSafe($, `sent:${id}`))
291 activity = ''
292 stop = null
293 await commit($)
294}
295
296/**
297 * Nach /clear (classic.SessionStart, source clear; 0.6.0, Entscheidung Fynn 2026-10-08): Die Liste zieht in den geleerten Chat
298 * um, statt beim alten zu bleiben. Ein To-do, das gerade lief, wird wieder offen, und ein Hinweis hält die Liste an, bis Fynn
299 * entscheidet: Sein Start-Prompt führt die Arbeit oft schon weiter. Der Schlüssel des alten Chats wird erst gelöscht, wenn der
300 * neue geschrieben ist.
301 */
302async function carryOver($: EngineInterface) {
303 const to = await $.session.id()
304 let from = rt.sid
305 let moved = q
306 if (from === to) {
307 // Der Abgleich war schneller (Seitenleiste, Befehl, Turn): die gemerkte Liste des alten Chats nehmen
308 if (!switched || switched.to !== to) return
309 from = switched.from
310 moved = switched.q
311 }
312 switched = null
313 if (!from || moved.items.length === 0) {
314 if (rt.sid !== to) await syncSession($)
315 return
316 }
317 const r = running(moved)
318 // Was Fynn im neuen Chat schon eingereiht hat, kommt hinten dran, als offen (Review 0.6.0 K2). IDs sind nur je Chat eindeutig
319 // (Zeit + Platz): umbenennen
320 const taken = new Set(moved.items.map((t) => t.id))
321 const here = (rt.sid === to ? q.items : []).map((t, i) => ({ ...t, ...(t.status === 'running' ? { status: 'open' as const } : {}), ...(taken.has(t.id) ? { id: `${t.id}c${i}` } : {}) }))
322 const paused = moved.paused || (rt.sid === to && q.paused)
323 await switchTo($, to, { items: [...moved.items, ...here].slice(0, QUEUE_MAX), paused }, r?.id)
324 // commit hat schon gespeichert, schluckt aber Fehler: hier noch einmal. Der alte Schlüssel fällt nur, wenn geschrieben ist und
325 // die Liste alles aus dem alten Chat enthält (Review 0.6.0 B1)
326 const complete = moved.items.slice(0, QUEUE_MAX).every((t) => q.items.some((x) => x.id === t.id))
327 let saved = false
328 try {
329 await $.store.set(`queue:${to}`, q)
330 saved = true
331 } catch (err) {
332 $.ui.log(`worklist: list not saved after /clear: ${String(err)}`, { to: 'debug' })
333 warnStore($)
334 }
335 if (saved && complete) {
336 try {
337 await $.store.delete(`queue:${from}`)
338 } catch (err) {
339 $.ui.log(`worklist: old list not removed after /clear: ${String(err)}`, { to: 'debug' })
340 }
341 } else if (saved) $.ui.log('worklist: old list kept after /clear: the moved list is incomplete', { to: 'debug' })
342 const n = openItems(q).length
343 // Hält die Liste an, sagt es der Toast; die Seitenleiste erscheint nicht überall (Review 0.6.0 S1)
344 $.ui.toast(r ? tx().carriedStopped(n) : tx().carried(n), { timeoutMs: r ? 8000 : 5000 })
345 if (r) $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
346}
347
348/** Hinweisblock setzen, Toast, Seitenleiste öffnen (SPEC → Benachrichtigung). */
349function raise($: EngineInterface, reason: string, todoId: string | null, kind?: 'background') {
350 rt.notice = { reason, todoId, ...(kind ? { kind } : {}) }
351 rt.state = kind ? 'waiting' : 'ask'
352 if (!kind) rt.stateReason = reason
353 $.ui.toast(kind ? reason : tx().stopped(reason), { timeoutMs: 8000 })
354 $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
355}
356
357// ---------- Verlauf ----------
358
359async function addHistory($: EngineInterface, todo: Todo, how: 'auto' | 'manual', result: string) {
360 const t = await now($)
361 const entry: HistoryEntry = {
362 // Gekürzt: 300 Einträge × langer Text passen sonst kaum in die 4 MiB des Stores
363 text: clamp(todo.text, 200, 1),
364 doneAt: t,
365 durationMs: Math.max(0, t - (todo.startedAt ?? t)),
366 sessionId: rt.sid,
367 result,
368 how,
369 }
370 try {
371 // Vor dem Schreiben neu lesen: alle Chats des Projekts teilen den Verlauf (docs/raw/en/interface.md:818-823)
372 history = pushHistory(cleanHistory(await $.store.get(`history:${root}`)), entry)
373 await $.store.set(`history:${root}`, history)
374 } catch (err) {
375 history = pushHistory(history, entry)
376 $.ui.log(`worklist: history not saved: ${String(err)}`, { to: 'debug' })
377 warnStore($)
378 }
379}
380
381/** To-do abhaken: aus der Liste in den Verlauf. Das Kurz-Ergebnis gehört nur dazu, wenn die letzte Antwort diesem To-do galt. */
382async function finish($: EngineInterface, todo: Todo, how: 'auto' | 'manual') {
383 q = remove(q, todo.id)
384 rt.sessionDone += 1
385 if (rt.strikes.id === todo.id) rt.strikes = { id: '', n: 0 }
386 await addHistory($, todo, how, !rt.busy && rt.turn.todoId === todo.id ? rt.lastResult : '')
387}
388
389// ---------- Senden (Stufe 10 und danach) ----------
390
391/** Startet das nächste To-do, wenn nichts dagegen spricht: über die Beruhigungszeit (Stufe 10). */
392function tryStartNext($: EngineInterface, why: 'auto' | 'manual', settleMs: number) {
393 if (q.paused || rt.notice || rt.hold || rt.busy) return
394 if (!nextOpen(q)) {
395 rt.state = 'idle'
396 return
397 }
398 if (why === 'auto' && rt.autoRun >= settings.maxAutoRun) {
399 raise($, tx().maxAutoRun(settings.maxAutoRun), null)
400 return
401 }
402 cancelSettle()
403 rt.state = 'checking'
404 rt.stateReason = tx().nextStarting
405 const seq = rt.turnSeq
406 const sid = rt.sid
407 const t = once($, settleMs, () => {
408 if (settle !== t) return
409 settle = null
410 void afterSettle($, why, seq, sid, null)
411 })
412 settle = t
413}
414
415/**
416 * Stufe 10: nach der Beruhigungszeit erneut prüfen, erst dann senden. Mit `cont` die Fortsetzung genau dieses laufenden
417 * To-dos (Knopf „Fortsetzen“) statt des nächsten offenen.
418 */
419async function afterSettle($: EngineInterface, why: 'auto' | 'manual', seq: number, sid: string, cont: string | null) {
420 try {
421 // Neuer Turn, Pause oder Hinweis während der Wartezeit: nicht senden
422 if (rt.turnSeq !== seq || rt.busy || q.paused || rt.notice || rt.hold) {
423 if (rt.state === 'checking') rt.state = 'idle'
424 return
425 }
426 // Session gewechselt (/clear): nie das To-do des alten Chats senden
427 if ((await $.session.id()) !== sid || rt.sid !== sid) {
428 await syncSession($)
429 return
430 }
431 // Neue Hintergrundarbeit seit der Prüfung
432 const agents = await busyAgents($)
433 if (rt.turnSeq !== seq || rt.busy) return
434 if (agents.length > 0) {
435 startWaiting($, { outcome: 'WARTEN', stage: 4, reason: tx().waitingHelpers(agents), short: tx().helpers(agents.length) })
436 return
437 }
438 if (cont) {
439 const todo = running(q)
440 if (todo && todo.id === cont) await sendContinue($, todo)
441 else rt.state = 'idle'
442 return
443 }
444 const todo = nextOpen(q)
445 if (!todo) {
446 rt.state = 'idle'
447 return
448 }
449 await send($, todo, why)
450 } catch (err) {
451 rt.state = 'idle'
452 $.ui.log(`worklist: sending stopped: ${String(err)}`, { to: 'debug' })
453 } finally {
454 redraw($)
455 await persist($)
456 }
457}
458
459async function send($: EngineInterface, todo: Todo, why: 'auto' | 'manual') {
460 const command = parseCommand(todo.text)
461 let name = ''
462 let listed = false
463 if (command) {
464 // Befehl: erst nachschlagen. worklists eigene Befehle → Hinweis und Halt; ein unbekannter scheitert unten an $.command.run
465 const found = await findCommand($, command.name)
466 if (found.problem) {
467 raise($, found.problem, todo.id)
468 await commit($)
469 return
470 }
471 name = found.name ?? command.name
472 listed = found.listed ?? false
473 }
474 const t = await now($)
475 const n = rt.sessionDone + 1
476 const m = rt.sessionDone + q.items.length
477 q = setStatus(q, todo.id, 'running', t)
478 rt.expectOwn = todo.id
479 rt.plan = []
480 rt.state = 'idle'
481 rt.stateReason = ''
482 if (why === 'auto') rt.autoRun += 1
483 // Merken, was gesendet wurde: turn.start und die Zeile im Chat erkennen das To-do am genauen Text (ohne Präfix)
484 rt.sent = pushSent(rt.sent, { id: todo.id, h: textHash(command ? `/${name}${command.args ? ` ${command.args}` : ''}` : todo.text), n, m })
485 await saveSent($)
486 await commit($)
487 if (command) {
488 await runCommand($, todo, name, command.args, listed)
489 return
490 }
491 await submit($, todo.id, todo.text)
492}
493
494/**
495 * „Fortsetzen“ bei einem To-do, das mit einer Rückfrage anhält (SPEC 0.4.2): eine kurze Fortsetzung für **dasselbe** To-do,
496 * mit dem unsichtbaren Schluss-Hinweis. Die Reihenfolge bleibt; der Schleifenschutz zählt weiter.
497 */
498async function sendContinue($: EngineInterface, todo: Todo) {
499 const text = CONTINUE_TEXTS[settings.lang]
500 const prev = rt.sent.filter((r) => r.id === todo.id).at(-1)
501 const n = prev?.n ?? rt.sessionDone + 1
502 const m = prev?.m ?? rt.sessionDone + q.items.length
503 rt.expectOwn = todo.id
504 rt.state = 'idle'
505 rt.stateReason = ''
506 rt.sent = pushSent(rt.sent, { id: todo.id, h: textHash(text), n, m, c: true })
507 await saveSent($)
508 await commit($)
509 await submit($, todo.id, text)
510}
511
512/** Text als Fynns Nachricht senden; der Schluss-Hinweis geht unsichtbar mit (classic.UserPromptSubmit unten). */
513async function submit($: EngineInterface, id: string, text: string) {
514 hintFor = settings.doneLine ? textHash(text) : null
515 lastHint = settings.doneLine ? 'missing' : ''
516 try {
517 // Mit asUser liest Claude das To-do als Fynns Nachricht (types: PromptSubmitArgs). worklists eigener prompt.submit-Hook
518 // sieht es nicht: Das Debug-Log meldet „prompt.submit skipped: re-entry (the plugin's own code raised it)“
519 const r = await $.prompt.submit({ text, asUser: true })
520 if ('drop' in r && r.drop !== undefined) await sendFailed($, id, tx().sendRejected(r.drop))
521 } catch (err) {
522 await sendFailed($, id, tx().sendFailed(String(err)))
523 }
524}
525
526async function sendFailed($: EngineInterface, id: string, reason: string) {
527 q = reopen(q, id)
528 rt.expectOwn = null
529 rt.expectCmd = null
530 hintFor = null
531 raise($, reason, id)
532 await commit($)
533}
534
535// ---------- Befehle in der Warteschlange ----------
536
537/**
538 * Den Befehl in dieser Session nachschlagen ($.command.list, types: CommandInfo): Liefert den Namen, wie die Liste ihn führt.
539 * Die Liste ist nicht vollständig: `/cost` fehlte darin, lief aber über $.command.run (Smoke 0.3.0, CLI 2.1.290). Fehlt ein
540 * Name, entscheidet deshalb $.command.run selbst; es lehnt unbekannte Namen ab („no command named /x“, types: $.command.run).
541 */
542async function findCommand(
543 $: EngineInterface,
544 name: string,
545): Promise<{ name: string; listed: boolean; problem?: undefined } | { name?: undefined; listed?: undefined; problem: string }> {
546 if (OWN_COMMANDS.includes(name.toLowerCase())) return { problem: tx().cmdOwn(name) }
547 try {
548 const list = await $.command.list()
549 const hit = list.find((c) => c.name === name) ?? list.find((c) => c.name.toLowerCase() === name.toLowerCase())
550 if (hit) return { name: hit.name, listed: true }
551 } catch (err) {
552 $.ui.log(`worklist: command list: ${String(err)}`, { to: 'debug' })
553 }
554 return { name, listed: false }
555}
556
557/**
558 * Befehl ausführen statt ihn als Text zu senden (types: `$.command.run`, „queued and run once the session is idle“; ein
559 * unbekannter Name wirft). Wann er als erledigt gilt (Prototyp, CLI 2.1.290): Ein Skill-Befehl kehrt sofort zurück und
560 * löst danach einen Turn aus; dann entscheidet dessen Ende über die Prüfung. Ein lokaler Befehl (/cost) löst keinen aus;
561 * dann ist er mit der Rückkehr erledigt.
562 */
563async function runCommand($: EngineInterface, todo: Todo, name: string, args: string, listed: boolean) {
564 rt.expectCmd = name
565 cmdPrompt = false
566 const seq = rt.turnSeq
567 let text = ''
568 try {
569 const r = await $.command.run({ command: name, args })
570 text = r.text ?? ''
571 } catch (err) {
572 // Nicht in der Liste und abgelehnt, oder „no command named“: unbekannt. Sonst der Fehler selbst. Nie still
573 const msg = String(err)
574 await sendFailed($, todo.id, !listed || /no command named/i.test(msg) ? tx().cmdUnknown(name) : tx().cmdFailed(msg))
575 return
576 }
577 once($, CMD_TURN_WAIT_MS, () => void afterCommand($, todo.id, seq, text, false))
578}
579
580/** Nach der Rückkehr eines Befehls: Kam kein Turn, gilt er als erledigt (WEITER ohne Prüfung des Texts). */
581async function afterCommand($: EngineInterface, id: string, seq: number, text: string, again: boolean) {
582 try {
583 // Ein Turn hat begonnen: dessen Ende prüft
584 if (rt.turnSeq !== seq || rt.busy || rt.expectCmd === null) return
585 const todo = running(q)
586 if (!todo || todo.id !== id) return
587 // Sein prompt.submit ist schon durch, der Turn kommt gleich: noch einmal länger warten
588 if (cmdPrompt && !again) {
589 once($, CMD_TURN_WAIT_MS * 3, () => void afterCommand($, id, seq, text, true))
590 return
591 }
592 const L = tx()
593 rt.expectCmd = null
594 rt.expectOwn = null
595 rt.turn = { startedAt: todo.startedAt ?? 0, todoId: id, text: clamp(todo.text, 200, 1), fromFynn: false }
596 rt.lastResult = firstSentence(text)
597 const d: Decision = { outcome: 'WEITER', stage: 0, reason: L.cmdRan }
598 lastDecision = describeDecision(await now($), d, L.ofTodo(clamp(todo.text, 40, 1)))
599 await apply($, d, todo)
600 } catch (err) {
601 $.ui.log(`worklist: command: ${String(err)}`, { to: 'debug' })
602 } finally {
603 await commit($)
604 }
605}
606
607// ---------- Die Prüfung am Turn-Ende ----------
608
609type Snapshot = {
610 seq: number
611 reason: 'answer' | 'aborted' | 'error' | 'refusal'
612 answer: string
613 stop: StopFacts | null
614 stopFailure: boolean
615 lastToolError: boolean
616 filesChanged: boolean
617 todoId: string | null
618 // Prüfstand fresh und der Turn kam nicht von Fynn: Er gibt die Liste nicht frei (0.4.1, Review 0.4.0 S1)
619 holdFresh: boolean
620}
621
622/** Zeile „Letzte Entscheidung“ für /todos status. */
623function describeDecision(t: number, d: Decision | null, what: string): string {
624 const L = tx()
625 return `${hhmm(t)} · ${d ? `${L.step(d.stage)} · ${L.outcome[d.outcome]} · ${d.reason}` : L.rulesUnclear} · ${what}`
626}
627
628/**
629 * Stufen 1–8 mit der Hintergrundarbeit, auf die noch gewartet wird: Helfer fallen weg, sobald `$.agent.list()` keinen
630 * beschäftigten mehr kennt; Aufgaben nach „Nicht mehr warten“ ebenso (0.4.0).
631 */
632async function rulesFor($: EngineInterface, s: Snapshot): Promise<Decision | null> {
633 const agents = await busyAgents($)
634 return decideRules({ ...s, stop: filterStop(s.stop, agents.length === 0, rt.ignoreBg), busyAgents: agents, plan: rt.plan }, tx())
635}
636
637/** Läuft über den einmaligen Timer nach turn.complete, nie im Hook selbst (Haiku, Senden). */
638async function evaluate($: EngineInterface, s: Snapshot) {
639 const L = tx()
640 try {
641 if (await syncSession($)) return
642 // Inzwischen läuft ein neuer Turn: dessen Ende prüft neu
643 if (rt.turnSeq !== s.seq || rt.busy) return
644 // Hinweis offen: die Liste wartet auf Fynn (sein Chat-Turn hätte den Hinweis zurückgenommen)
645 if (rt.notice) return
646 const todo = s.todoId ? q.items.find((t) => t.id === s.todoId && t.status === 'running') : undefined
647 // Ohne laufendes To-do und ohne etwas, das starten dürfte: nur die Regeln, ohne Haiku. Gemerkt wird, ob Claude sicher frei
648 // ist; ein später eingereihtes To-do startet bei einer Rückfrage nur, wenn Fynn es nicht während dieses Turns getippt hat
649 if (!todo && (openItems(q).length === 0 || q.paused)) {
650 let d: Decision | null
651 try {
652 d = await rulesFor($, s)
653 } catch (err) {
654 d = { outcome: 'FRAGEN', stage: 0, reason: L.checkFailed(String(err)) }
655 }
656 if (rt.turnSeq !== s.seq || rt.busy || rt.notice) return
657 setFree($, d)
658 if (s.holdFresh && rt.state === 'idle') rt.state = 'fresh'
659 // Inzwischen eingereiht bzw. Start gedrückt: jetzt mit dem Ergebnis entscheiden (eingereiht im Turn bzw. kurz danach)
660 maybeStart($, true)
661 lastDecision = describeDecision(await now($), d, L.listIdle)
662 return
663 }
664 rt.state = 'checking'
665 rt.stateReason = L.checking
666 redraw($)
667 let d: Decision
668 try {
669 const rules = await rulesFor($, s)
670 // Ein Befehls-To-do heißt für Haiku „führe /x aus“: der Text allein wäre nur der Befehlsname
671 const task = todo ? (parseCommand(todo.text) ? `Run the slash command ${todo.text}` : todo.text) : ''
672 d = rules ?? (settings.haiku ? await askHaiku($, task, s) : { outcome: 'FRAGEN', stage: 9, reason: L.haikuOff })
673 } catch (err) {
674 // Im Zweifel nie senden (SPEC → Fehlerverhalten)
675 d = { outcome: 'FRAGEN', stage: 0, reason: L.checkFailed(String(err)) }
676 }
677 if (rt.turnSeq !== s.seq || rt.busy || rt.notice) return
678 lastDecision = describeDecision(await now($), d, todo ? L.ofTodo(clamp(todo.text, 40, 1)) : L.fromChatShort)
679 $.ui.log(`worklist: ${lastDecision}`, { to: 'debug' })
680 // Nach Neustart oder Chatwechsel startet nur ein Turn von Fynn die Liste, kein fremder (Benachrichtigung, Peer, Weckauftrag)
681 if (!todo && s.holdFresh && d.outcome === 'WEITER') {
682 rt.state = 'fresh'
683 rt.stateReason = ''
684 return
685 }
686 await apply($, d, todo)
687 } catch (err) {
688 $.ui.log(`worklist: check: ${String(err)}`, { to: 'debug' })
689 rt.state = 'blocked'
690 rt.stateReason = L.checkFailedShort
691 } finally {
692 await commit($)
693 }
694}
695
696/** Zustand nach einem Turn-Ende ohne To-do, das starten dürfte. */
697function setFree($: EngineInterface, d: Decision | null) {
698 if (d?.outcome !== 'WEITER') cancelSettle()
699 if (!d) {
700 rt.state = 'unclear'
701 rt.stateReason = ''
702 } else if (d.outcome === 'WEITER') {
703 rt.state = 'idle'
704 rt.stateReason = ''
705 } else if (d.outcome === 'WARTEN') startWaiting($, d)
706 else {
707 rt.state = 'blocked'
708 rt.stateReason = d.reason
709 }
710}
711
712/**
713 * Ein To-do ist neu startbar (eingereiht, Start). Einreihen bei freiem Claude zählt als Fynns Antwort (SPEC 0.4.2): Es startet
714 * auch nach einer Rückfrage (`blocked`), nach einem unklaren Turn-Ende (`unclear`, ohne Haiku) und nach dem Neustart (`fresh`).
715 * Sperren bleiben: Claude arbeitet, ein Hinweis ist offen, die Liste ist pausiert, Hintergrundarbeit läuft (`waiting`) oder die
716 * Prüfung des letzten Turns läuft noch (`checking`). `late`: Fynn hat während des letzten Turns eingereiht; dann gilt dessen
717 * Prüfung (nach einer Rückfrage startet nichts, nach einem unklaren Ende prüft erst Haiku).
718 */
719function maybeStart($: EngineInterface, late: boolean, settleMs = settings.settleSeconds * 1000) {
720 // Nichts offen: nichts zu starten. Ohne diese Sperre rief sich die Prüfung nach einem unklaren Turn-Ende bei leerer Liste
721 // alle 10 ms selbst wieder auf (unclear → evaluate → unclear …; gefunden mit den Tests zu 0.4.0)
722 if (rt.busy || rt.notice || q.paused || !nextOpen(q)) return
723 if (rt.state === 'idle' || rt.state === 'fresh') tryStartNext($, 'manual', settleMs)
724 else if (rt.state === 'blocked') {
725 if (!late) tryStartNext($, 'manual', settleMs)
726 } else if (rt.state === 'unclear') {
727 if (!late) {
728 tryStartNext($, 'manual', settleMs)
729 return
730 }
731 const s = lastSnap
732 if (s && s.seq === rt.turnSeq) {
733 // Ab jetzt „prüft“: ein zweites Einreihen stößt keine zweite Haiku-Prüfung an
734 rt.state = 'checking'
735 rt.stateReason = tx().checking
736 once($, 10, () => void evaluate($, s))
737 } else {
738 rt.state = 'blocked'
739 rt.stateReason = tx().unclearFree
740 }
741 }
742}
743
744async function apply($: EngineInterface, d: Decision, todo: Todo | undefined) {
745 if (d.outcome === 'WARTEN') {
746 startWaiting($, d)
747 return
748 }
749 if (d.outcome === 'WEITER') {
750 if (todo) {
751 await finish($, todo, 'auto')
752 if (rt.hold) {
753 // Schleifenschutz: nur Fynn setzt fort
754 raise($, tx().holdTwice, null)
755 return
756 }
757 }
758 rt.state = 'idle'
759 rt.stateReason = ''
760 tryStartNext($, 'auto', settings.settleSeconds * 1000)
761 return
762 }
763 // FRAGEN oder STOPP
764 cancelSettle()
765 if (!todo) {
766 // Fynns eigener Chat-Turn: nichts startet, still (kein Toast); Einreihen, „Jetzt starten“ oder ein sauberes Turn-Ende setzt fort
767 rt.state = 'blocked'
768 rt.stateReason = d.reason
769 return
770 }
771 if (d.outcome === 'STOPP') q = reopen(q, todo.id)
772 rt.strikes = rt.strikes.id === todo.id ? { id: todo.id, n: rt.strikes.n + 1 } : { id: todo.id, n: 1 }
773 let reason = d.reason
774 if (rt.strikes.n >= 2) {
775 rt.hold = true
776 reason = `${reason} ${tx().secondTime}`
777 }
778 raise($, reason, todo.id)
779}
780
781/**
782 * Stufe 9: Haiku liest nur das Ende der Antwort und das To-do. Fehler, Timeout, Unsinn → FRAGEN. Modell, effort und Grenzen
783 * aus `HAIKU` (check.ts, Nachtrag 0.5.0: Haiku 5.5, high, 1500 Tokens, 10 s).
784 */
785async function askHaiku($: EngineInterface, todoText: string, s: Snapshot): Promise<Decision> {
786 const L = tx()
787 const r = await $.model.complete({
788 ...HAIKU,
789 system: haikuSystem(L),
790 prompt: haikuPrompt(todoText, s.answer, { filesChanged: s.filesChanged, toolError: s.lastToolError }),
791 })
792 await addCost($, haikuCost(r.usage, HAIKU.model))
793 if (!r.isAnswered) {
794 lastHaiku = L.noAnswer(r.reason)
795 return { ...decideHaiku(null, L), reason: L.haikuFailed(r.reason) }
796 }
797 const v = parseHaiku(r.text)
798 lastHaiku = v ? `${v.verdict} ${v.confidence.toFixed(2)} · ${v.why}` : L.unreadable(clamp(r.text, 60, 1))
799 return decideHaiku(v, L)
800}
801
802async function addCost($: EngineInterface, usd: number) {
803 try {
804 const c = cleanCost(await $.store.get(`cost:${root}`))
805 cost = { usd: c.usd + usd, calls: c.calls + 1 }
806 await $.store.set(`cost:${root}`, cost)
807 } catch {
808 cost = { usd: cost.usd + usd, calls: cost.calls + 1 }
809 }
810}
811
812// ---------- Warten auf Hintergrundarbeit, mit Grenze (SPEC 0.4.2) ----------
813
814/** Prüfstand WARTEN: Grund merken, Wartephase beginnen (einmal), Nachprüfen alle 10 s. */
815function startWaiting($: EngineInterface, d: Decision) {
816 rt.state = 'waiting'
817 rt.stateReason = d.reason
818 rt.stateShort = d.short ?? ''
819 if (!rt.waitSince) rt.waitSince = nowMs
820 ensureWaitTimer($)
821}
822
823/** Nachprüfen beim Warten (alle 10 s), nur wenn die Fakten des Turn-Endes da sind und etwas auf das Ende wartet. */
824function ensureWaitTimer($: EngineInterface) {
825 if (waitTimer || rt.busy || rt.state !== 'waiting' || !lastSnap) return
826 if (!running(q) && openItems(q).length === 0) return
827 waitTimer = $.clock.every(WAIT_CHECK_MS, () => void waitTick($))
828}
829
830function stopWaitTimer() {
831 waitTimer?.cancel()
832 waitTimer = null
833}
834
835/**
836 * Nachprüfen, solange WARTEN gilt und kein Turn läuft: Sind keine Helfer mehr beschäftigt und ist sonst nichts mehr da,
837 * läuft die Prüfung erneut auf dem letzten Turn-Ende. Laufen nur noch Shells, Monitore oder Weckaufträge, kommt nach
838 * 2 Minuten einmal je Wartephase der Hinweis „Nicht mehr warten?“.
839 */
840async function waitTick($: EngineInterface) {
841 const s = lastSnap
842 if (rt.busy || rt.state !== 'waiting' || !s || s.seq !== rt.turnSeq) {
843 stopWaitTimer()
844 return
845 }
846 // Der Hinweis „Nicht mehr warten?“ steht: Fynn entscheidet
847 if (rt.notice) return
848 // Nichts wartet auf das Ende (Liste leer): nicht weiter nachprüfen; Einreihen startet es wieder (Review 0.4.0 K4)
849 if (!running(q) && openItems(q).length === 0) {
850 stopWaitTimer()
851 return
852 }
853 try {
854 if (await syncSession($)) return
855 const agents = await busyAgents($)
856 const t = await now($)
857 if (rt.busy || rt.state !== 'waiting' || rt.notice || rt.turnSeq !== s.seq) return
858 const left = filterStop(s.stop, agents.length === 0, rt.ignoreBg)
859 const helpers = agents.length > 0 || (left?.background.some((x) => AGENT_TASKS.includes(x.type)) ?? false)
860 const other = otherBackground(left)
861 if (!helpers && other.tasks.length === 0 && other.crons === 0) {
862 stopWaitTimer()
863 rt.state = 'checking'
864 await evaluate($, s)
865 return
866 }
867 if (helpers || rt.waitNoticed || t - rt.waitSince < WAIT_LIMIT_MS) return
868 // Nur fragen, wenn etwas auf das Ende wartet (laufendes oder offenes To-do, Liste nicht pausiert)
869 if (q.paused || (!running(q) && openItems(q).length === 0)) return
870 rt.waitNoticed = true
871 const what = [...other.tasks.map((x) => x.description || x.type), ...(other.crons > 0 ? [tx().crons(other.crons)] : [])].join(', ')
872 raise($, tx().bgNotice(clamp(what, 80, 1)), running(q)?.id ?? null, 'background')
873 await commit($)
874 } catch (err) {
875 $.ui.log(`worklist: wait: ${String(err)}`, { to: 'debug' })
876 }
877}
878
879/** „Nicht mehr warten“: Die Prüfung läuft erneut, ohne Stufe 3 für genau diese Aufgaben (sie bleiben für diesen Chat ausgenommen). */
880async function stopWaiting($: EngineInterface) {
881 if (rt.notice?.kind !== 'background') return
882 fynnActed()
883 const other = otherBackground(lastSnap?.stop ?? null)
884 rt.ignoreBg = [...new Set([...rt.ignoreBg, ...other.tasks.map((x) => x.id), ...(other.crons > 0 ? ['cron'] : [])])]
885 rt.notice = null
886 stopWaitTimer()
887 const s = lastSnap
888 if (s && s.seq === rt.turnSeq && !rt.busy) {
889 rt.state = 'checking'
890 rt.stateReason = tx().checking
891 once($, 10, () => void evaluate($, s))
892 } else rt.state = 'idle'
893 await commit($)
894}
895
896/** „Weiter warten“: Hinweis weg, das Warten geht weiter; in dieser Wartephase fragt worklist nicht noch einmal. */
897async function keepWaiting($: EngineInterface) {
898 if (rt.notice?.kind !== 'background') return
899 rt.notice = null
900 if (lastSnap) {
901 rt.state = 'waiting'
902 ensureWaitTimer($)
903 } else {
904 // Nach einem Reload fehlen die Fakten des Turn-Endes: Fynn entscheidet per „Jetzt starten“ (Review 0.4.0 S2)
905 rt.state = 'blocked'
906 rt.stateReason = tx().reloadWaiting
907 }
908 await commit($)
909}
910
911// ---------- Fynns Aktionen ----------
912
913async function addTodo($: EngineInterface, raw: string): Promise<Todo | null> {
914 const text = cleanText(raw)
915 if (!text) return null
916 await syncSession($)
917 fynnActed()
918 const t = await now($)
919 const todo: Todo = { id: `t${t.toString(36)}${q.items.length}`, text, status: 'open', createdAt: t }
920 q = add(q, todo)
921 // Bei freiem Claude startet es nach der Beruhigungszeit; kurz nach einem Turn-Ende gilt die Prüfung dieses Turns
922 maybeStart($, !rt.busy && rt.turnEndAt > 0 && t - rt.turnEndAt < LATE_MS)
923 ensureWaitTimer($)
924 await commit($)
925 return todo
926}
927
928function noticeTodo(): Todo | undefined {
929 const id = rt.notice?.todoId ?? null
930 return id ? q.items.find((t) => t.id === id) : running(q)
931}
932
933function release() {
934 fynnActed()
935 rt.notice = null
936 rt.hold = false
937 rt.state = 'idle'
938 rt.stateReason = ''
939 q = { ...q, paused: false }
940}
941
942type ResumeResult = 'continued' | 'resent' | 'released' | 'hold' | 'waiting' | 'none'
943
944/**
945 * „Fortsetzen“ bzw. /todos resume mit Hinweis (SPEC 0.4.2). Die Reihenfolge bleibt:
946 * - To-do läuft noch (Rückfrage): kurze Fortsetzung für dasselbe To-do
947 * - To-do wieder offen (STOPP): bleibt vorn und wird erneut gesendet
948 * - ohne To-do (Schleifenschutz, maxAutoRun): die Sperre fällt, das nächste startet
949 * Nach dem zweiten Halt am selben To-do helfen nur Fynns Antwort, Abhaken oder Überspringen. Pausiert nie.
950 */
951async function resume($: EngineInterface): Promise<ResumeResult> {
952 const n = rt.notice
953 if (!n) return 'none'
954 if (n.kind === 'background') {
955 await stopWaiting($)
956 return 'waiting'
957 }
958 const t = n.todoId ? q.items.find((x) => x.id === n.todoId) : undefined
959 if (rt.hold && t) {
960 $.ui.toast(tx().holdNoResume, { timeoutMs: 8000 })
961 return 'hold'
962 }
963 const strikes = rt.strikes
964 release()
965 // Fortsetzen zählt beim Schleifenschutz weiter (hebt nur maxAutoRun auf)
966 rt.strikes = strikes
967 let result: ResumeResult = 'released'
968 if (t && t.status === 'running') {
969 cancelSettle()
970 rt.state = 'checking'
971 rt.stateReason = tx().nextStarting
972 const seq = rt.turnSeq
973 const sid = rt.sid
974 const timer = once($, PRESS_SETTLE_MS, () => {
975 if (settle !== timer) return
976 settle = null
977 void afterSettle($, 'manual', seq, sid, t.id)
978 })
979 settle = timer
980 result = 'continued'
981 } else {
982 if (t) {
983 q = toFront(q, t.id)
984 result = 'resent'
985 }
986 tryStartNext($, 'manual', PRESS_SETTLE_MS)
987 }
988 await commit($)
989 return result
990}
991
992/** „Abhaken“ bzw. /todos done. Ohne To-do nichts tun: kein Aufheben der Pause, kein Start. */
993async function markDone($: EngineInterface): Promise<Todo | undefined> {
994 const t = noticeTodo()
995 if (!t) return undefined
996 await finish($, t, 'manual')
997 release()
998 tryStartNext($, 'manual', PRESS_SETTLE_MS)
999 await commit($)
1000 return t
1001}
1002
1003/**
1004 * „Überspringen“ bzw. /todos skip: ans Ende, mit der Marke „übersprungen“; das nächste startet, nichts pausiert. Ist es das
1005 * einzige offene, wird nichts gesendet; es wartet im Prüfstand `fresh` (startet nach Fynns nächster Nachricht oder „Start“).
1006 */
1007async function skip($: EngineInterface): Promise<Todo | undefined> {
1008 const t = noticeTodo()
1009 if (!t) return undefined
1010 q = skipToEnd(q, t.id)
1011 release()
1012 rt.strikes = { id: '', n: 0 }
1013 if (openItems(q).some((x) => x.id !== t.id)) tryStartNext($, 'manual', PRESS_SETTLE_MS)
1014 else rt.state = 'fresh'
1015 await commit($)
1016 return t
1017}
1018
1019/** /todos retry: das angehaltene To-do vorn noch einmal ganz senden. Zählt beim Schleifenschutz weiter. */
1020async function retry($: EngineInterface): Promise<Todo | undefined> {
1021 const t = noticeTodo()
1022 if (!t) return undefined
1023 const strikes = rt.strikes
1024 q = toFront(q, t.id)
1025 release()
1026 rt.strikes = strikes
1027 tryStartNext($, 'manual', PRESS_SETTLE_MS)
1028 await commit($)
1029 return t
1030}
1031
1032function control(): Control {
1033 if (q.paused) return 'start'
1034 const waits = rt.state === 'blocked' || rt.state === 'waiting' || rt.state === 'fresh' || rt.state === 'unclear'
1035 if (!rt.busy && !rt.notice && waits && openItems(q).length > 0) return 'go'
1036 return 'pause'
1037}
1038
1039async function pressControl($: EngineInterface) {
1040 const c = control()
1041 fynnActed()
1042 if (c === 'pause') {
1043 q = { ...q, paused: true }
1044 cancelSettle()
1045 } else {
1046 q = { ...q, paused: false }
1047 if (!rt.notice) {
1048 rt.hold = false
1049 if (c === 'go') {
1050 // „Jetzt starten“: Fynn setzt sich bewusst über die Rückfrage bzw. die Hintergrundarbeit hinweg
1051 stopWaitTimer()
1052 rt.state = 'idle'
1053 rt.stateReason = ''
1054 tryStartNext($, 'manual', PRESS_SETTLE_MS)
1055 } else maybeStart($, false, PRESS_SETTLE_MS) // „Start“ nach einer Pause: wie beim Einreihen, Fynn hat entschieden
1056 }
1057 }
1058 await commit($)
1059}
1060
1061/**
1062 * Knopf-Aktion erst nach dem Session-Abgleich: Nach /clear zeigt die Seitenleiste bis zum Neuzeichnen noch die alte Liste;
1063 * ein Druck darauf darf die Liste des alten Chats nicht ändern. Nach einem Wechsel wird nur neu gezeichnet.
1064 */
1065function guarded($: EngineInterface, fn: () => unknown): () => void {
1066 return () => {
1067 void syncSession($)
1068 .then(async (changed) => {
1069 if (changed) redraw($)
1070 else await fn()
1071 })
1072 .catch((err: unknown) => $.ui.log(`worklist: button: ${String(err)}`, { to: 'debug' }))
1073 }
1074}
1075
1076function actions($: EngineInterface): Actions {
1077 const edit = (change: () => void) =>
1078 guarded($, async () => {
1079 fynnActed()
1080 change()
1081 await commit($)
1082 })
1083 return {
1084 resume: guarded($, () => resume($)),
1085 markDone: guarded($, () => markDone($)),
1086 skip: guarded($, () => skip($)),
1087 stopWaiting: guarded($, () => stopWaiting($)),
1088 keepWaiting: guarded($, () => keepWaiting($)),
1089 up: (id) => edit(() => (q = move(q, id, -1)))(),
1090 down: (id) => edit(() => (q = move(q, id, 1)))(),
1091 remove: (id) => edit(() => (q = remove(q, id)))(),
1092 add: (value) => {
1093 draft = ''
1094 // Neuer Schlüssel = neues, leeres Feld; sonst blieb der Text nach „einreihen“ stehen
1095 if (value.trim()) inputGen += 1
1096 // addTodo gleicht die Session selbst ab: Eingetipptes landet im aktuellen Chat
1097 void addTodo($, value)
1098 .then((t) => {
1099 if (!t) redraw($)
1100 })
1101 .catch((err: unknown) => $.ui.log(`worklist: queue: ${String(err)}`, { to: 'debug' }))
1102 },
1103 draft: (value) => {
1104 draft = value
1105 },
1106 control: guarded($, () => pressControl($)),
1107 toggleHistory: () => {
1108 historyOpen = !historyOpen
1109 redraw($)
1110 },
1111 toggleAll: () => {
1112 historyAll = !historyAll
1113 redraw($)
1114 },
1115 }
1116}
1117
1118// ---------- Anzeige ----------
1119
1120function todayStart(t: number): number {
1121 const d = new Date(t)
1122 d.setHours(0, 0, 0, 0)
1123 return d.getTime()
1124}
1125
1126/** JETZT: nur, solange Claude arbeitet oder ein To-do läuft; sonst sagt die Statuszeile alles. */
1127function nowView(): NowView | null {
1128 const r = running(q)
1129 if (!rt.busy && !r) return null
1130 const isTodo = rt.busy ? rt.turn.todoId !== null : true
1131 const todo = isTodo ? (q.items.find((t) => t.id === (rt.turn.todoId ?? r?.id)) ?? r) : undefined
1132 const started = todo?.startedAt ?? rt.turn.startedAt
1133 return {
1134 kind: todo ? 'todo' : 'chat',
1135 index: rt.sessionDone + 1,
1136 total: Math.max(rt.sessionDone + q.items.length, 1),
1137 text: todo ? todo.text : rt.turn.text,
1138 // Sekunden genügen; so bleibt der Baum zwischen zwei Ticks gleich
1139 elapsedMs: rt.busy ? Math.floor(Math.max(0, nowMs - started) / 1000) * 1000 : 0,
1140 activity: rt.busy ? activity : '',
1141 plan: rt.plan,
1142 waiting: !rt.busy && rt.state === 'waiting' ? rt.stateReason : '',
1143 checking: !rt.busy && rt.state === 'checking',
1144 }
1145}
1146
1147/** Art des Hinweises: Hintergrund, ohne To-do (Schleifenschutz, maxAutoRun), To-do läuft noch (Rückfrage) oder offen (STOPP). */
1148function noticeKind(): NoticeKind {
1149 if (rt.notice?.kind === 'background') return 'background'
1150 const id = rt.notice?.todoId ?? null
1151 if (!id) return 'hold'
1152 return q.items.find((t) => t.id === id)?.status === 'running' ? 'question' : 'stop'
1153}
1154
1155/** Das To-do, dessen Rückfrage gerade offen ist (Hinweis mit laufendem To-do): Fynns Antwort gehört zu ihm. */
1156function questionTodo(): Todo | undefined {
1157 if (!rt.notice || rt.notice.kind === 'background' || !rt.notice.todoId) return undefined
1158 const t = q.items.find((x) => x.id === rt.notice?.todoId)
1159 return t?.status === 'running' ? t : undefined
1160}
1161
1162/** Statuszeile: ein Symbol und ein Satz dazu, was gilt und was als Nächstes passiert (SPEC 0.4.4). */
1163function statusLine(): StatusLine {
1164 const L = tx()
1165 if (q.paused) return { icon: '⏸', text: L.stPaused, tone: 'dim' }
1166 if (rt.busy) {
1167 const elapsed = clock(Math.floor(Math.max(0, nowMs - rt.turn.startedAt) / 1000) * 1000)
1168 if (rt.turn.todoId) return { icon: '●', text: L.stRunning(rt.sessionDone + 1, Math.max(rt.sessionDone + q.items.length, 1), elapsed), tone: 'accent' }
1169 return { icon: '●', text: L.stChat(elapsed), tone: 'accent' }
1170 }
1171 if (rt.notice) {
1172 const kind = noticeKind()
1173 if (kind === 'background') return { icon: '◐', text: L.stWaiting(rt.stateShort || '…'), tone: 'strong' }
1174 return { icon: '◐', text: kind === 'question' ? L.stAsk : L.stStopped, tone: 'strong' }
1175 }
1176 if (rt.state === 'waiting') return { icon: '◌', text: L.stWaiting(rt.stateShort || '…'), tone: 'dim' }
1177 if (rt.state === 'checking') return { icon: '◌', text: L.stChecking, tone: 'dim' }
1178 // Gesendet, sein Turn hat noch nicht begonnen
1179 if (running(q)) return { icon: '●', text: L.stRunning(rt.sessionDone + 1, Math.max(rt.sessionDone + q.items.length, 1), clock(0)), tone: 'accent' }
1180 if (openItems(q).length === 0) return { icon: '◇', text: L.stEmpty, tone: 'dim' }
1181 if (rt.state === 'blocked') return { icon: '◐', text: L.stAsk, tone: 'strong' }
1182 if (rt.state === 'fresh') return { icon: '◇', text: L.stFresh, tone: 'dim' }
1183 return { icon: '◇', text: L.stNext, tone: 'dim' }
1184}
1185
1186function view(): View {
1187 const total = rt.sessionDone + q.items.length
1188 return {
1189 lang: settings.lang,
1190 status: statusLine(),
1191 progress: total > 0 ? { done: rt.sessionDone, total } : null,
1192 now: nowView(),
1193 notice: rt.notice ? { kind: noticeKind(), reason: rt.notice.reason, noResume: rt.hold && rt.notice.todoId !== null } : null,
1194 queue: openItems(q).map((t) => ({ id: t.id, text: t.text, skipped: t.skipped === true })),
1195 control: control(),
1196 draft,
1197 history,
1198 historyOpen,
1199 historyAll,
1200 inputKey: inputKey(),hooks/check.ts 303 lines1// worklist: die Prüfung „sicher frei“ ohne `$` (SPEC → Verhalten, Stufen 1–9). Die Stufen laufen der Reihe nach, die erste
2// Entscheidung gilt. Im Zweifel wird nie gesendet: Alles, was keine Regel sicher als „fertig“ erkennt, geht an Haiku (Stufe 9)
3// oder wird zu FRAGEN. Stufe 10 (Beruhigungszeit) braucht die Uhr und liegt in register.ts.
4// Die Erkennung versteht immer Deutsch und Englisch, unabhängig von der Spracheinstellung; nur die Gründe folgen ihr.
5import type { Strings } from './i18n.ts'
6import type { PlanItem } from './model.ts'
7
8export type Outcome = 'WEITER' | 'WARTEN' | 'FRAGEN' | 'STOPP'
9// short: Kurzform bei WARTEN für die Statuszeile („2 Helfer“, „npm run dev“)
10export type Decision = { outcome: Outcome; stage: number; reason: string; short?: string }
11
12/** Hintergrundarbeit aus `classic.Stop` (types: StopHookInput.background_tasks, session_crons). */
13export type BgTask = { id: string; type: string; status: string; description: string }
14export type StopFacts = { background: readonly BgTask[]; crons: number }
15
16/** Hintergrundarbeit, die Helfer sind (types: BackgroundTaskSummary.type): `$.agent.list()` sagt, ob sie noch laufen. */
17export const AGENT_TASKS: readonly string[] = ['subagent', 'workflow']
18
19/**
20 * Stufe 3 ohne die Aufgaben, auf die nicht mehr gewartet wird (0.4.0): Helfer, wenn `$.agent.list()` keinen beschäftigten
21 * mehr kennt (`agentsIdle`), und Aufgaben, die Fynn per „Nicht mehr warten“ ausgenommen hat (`ignore`, IDs; 'cron').
22 */
23export function filterStop(stop: StopFacts | null, agentsIdle: boolean, ignore: readonly string[]): StopFacts | null {
24 if (!stop) return null
25 const background = stop.background.filter((t) => !(agentsIdle && AGENT_TASKS.includes(t.type)) && !ignore.includes(t.id))
26 return { background, crons: ignore.includes('cron') ? 0 : stop.crons }
27}
28
29/** Andere Hintergrundarbeit als Helfer (Shells, Monitore, Weckaufträge): Für sie gilt die Wartegrenze (0.4.0). */
30export function otherBackground(stop: StopFacts | null): { tasks: BgTask[]; crons: number } {
31 if (!stop) return { tasks: [], crons: 0 }
32 return { tasks: stop.background.filter((t) => !AGENT_TASKS.includes(t.type)), crons: stop.crons }
33}
34
35export type Facts = {
36 reason: 'answer' | 'aborted' | 'error' | 'refusal'
37 stopFailure: boolean
38 stop: StopFacts | null // null: kein classic.Stop zu diesem Turn (bei einem Abbruch feuert keins)
39 busyAgents: readonly string[] // Agenten mit pending/running/waiting (Stufe 4)
40 plan: readonly PlanItem[]
41 answer: string
42 lastToolError: boolean
43}
44
45/** Agenten-Status, der als „läuft noch“ gilt (types: AgentStatus). */
46export const BUSY_STATUS: readonly string[] = ['pending', 'running', 'waiting']
47
48// ---------- Text-Hilfen ----------
49
50/** Entfernt Code: Blöcke in ``` bzw. ~~~ (auch ein offener am Ende) und Inline-Code. Ein „?“ in Code ist keine Rückfrage. */
51function stripCode(text: string): string {
52 return text.replace(/(```|~~~)[\s\S]*?(\1|$)/g, '\n').replace(/`[^`\n]*`/g, ' ')
53}
54
55/** Absätze ohne Code, leere entfernt. */
56function paragraphs(text: string): string[] {
57 return stripCode(text)
58 .split(/\n\s*\n/)
59 .map((p) => p.trim())
60 .filter((p) => p.length > 0)
61}
62
63function lastParagraph(text: string): string {
64 const ps = paragraphs(text)
65 return ps[ps.length - 1] ?? ''
66}
67
68/** Die letzten beiden Absätze ohne Code (Stufen 6 und 7): Schlussfolgerungen stehen oft vor einer Abschlusszeile. */
69function tail(text: string): string {
70 return paragraphs(text).slice(-2).join('\n\n')
71}
72
73/** Markdown-Zierrat am Ende (`**`, `_`, Anführungszeichen, Klammern), damit „…?**“ und „Fertig.**“ erkannt werden. */
74function trimEnd(s: string): string {
75 return s.replace(/[\s*_~"'»«“”„)\]]+$/u, '')
76}
77
78/** Letzter Satz eines Absatzes, ohne Markdown-Zierrat am Anfang (`**`, `-`, `>`). */
79function lastSentence(paragraph: string): string {
80 const parts = paragraph
81 .split(/(?<=[.!?…])\s+|\n+/u)
82 .map((x) => x.replace(/^[\s*_>#-]+/u, '').trim())
83 .filter((x) => x.length > 0)
84 return parts[parts.length - 1] ?? ''
85}
86
87/** Ein Absatz, der nur die Fertig-Marke ist („Fertig.“, „**Done.**“). */
88function isMarkerOnly(paragraph: string): boolean {
89 return /^(fertig|erledigt|done)[.!]?$/iu.test(trimEnd(paragraph.replace(/^[\s*_>#-]+/u, '')))
90}
91
92// ---------- Stufe 6: Rückfrage ----------
93
94const QUESTION_PHRASES = [
95 'soll ich',
96 'sollen wir',
97 'möchtest du',
98 'willst du',
99 'bitte bestätige',
100 'welche variante',
101 'ich brauche',
102 'shall i',
103 'should i',
104 'do you want',
105 'would you like',
106]
107
108/** Nummerierte oder benannte Optionen: mindestens zwei Zeilen `1.`/`1)`/`- Option A` bzw. `**A**`. */
109function hasOptions(text: string): boolean {
110 const opts = text.split('\n').filter((l) => /^\s*(\d+[.)]|[-*]\s+(\*\*)?(option|variante)\b|[-*]?\s*\*\*[A-D][.):]?\*\*)/i.test(l))
111 return opts.length >= 2
112}
113
114/** Rückfrage im letzten Absatz (ohne Code). Eine Frage mitten im Text zählt nicht; ein alleinstehendes „Fertig.“ danach verdeckt sie nicht. */
115export function isQuestion(answer: string): boolean {
116 const ps = paragraphs(answer)
117 while (ps.length > 1 && isMarkerOnly(ps[ps.length - 1] ?? '')) ps.pop()
118 const last = ps[ps.length - 1] ?? ''
119 if (!last) return false
120 if (trimEnd(last).endsWith('?')) return true
121 const low = last.toLowerCase()
122 if (QUESTION_PHRASES.some((p) => low.includes(p))) return true
123 const end = tail(answer)
124 return hasOptions(end) && end.includes('?')
125}
126
127// ---------- Stufe 7: Problem ----------
128
129const PROBLEM_PHRASES = [
130 'konnte nicht',
131 'konnte ich nicht',
132 'fehlgeschlagen',
133 'schlägt fehl',
134 'schlug fehl',
135 'blockiert',
136 'failed',
137 "couldn't",
138 'could not',
139 'unable to',
140]
141
142/** Problem im Schluss der Antwort, Tool-Fehler zuletzt oder leere Antwort. */
143export function isProblem(answer: string, lastToolError: boolean): boolean {
144 if (lastToolError) return true
145 if (stripCode(answer).trim() === '') return true
146 const low = tail(answer).toLowerCase()
147 if (PROBLEM_PHRASES.some((p) => low.includes(p))) return true
148 // „konnte die Datei nicht finden“, „die Tests schlagen noch fehl“: Wörter dazwischen erlaubt
149 return (
150 /(^|[^\p{L}])konnte\s+(\p{L}+\s+){0,4}nicht([^\p{L}]|$)/u.test(low) ||
151 /(^|[^\p{L}])(schlagen|schlägt|schlug|schlugen)\s+(\p{L}+\s+){0,4}fehl([^\p{L}]|$)/u.test(low)
152 )
153}
154
155// ---------- Stufe 8: fertig ----------
156
157/**
158 * Nur wenn der **letzte Satz** selbst die Fertig-Marke ist: „Fertig.“ bzw. „Done.“ (Schlusszeile), „Erledigt.“,
159 * „Alles erledigt.“, „Done, all tests pass.“. Ein „erledigt“ mitten im Satz und eine Einschränkung hinter der Marke
160 * („Fertig, bis auf den Export.“, „Done, except …“) gehen an Stufe 9.
161 */
162export function isDone(answer: string): boolean {
163 const last = trimEnd(lastSentence(lastParagraph(answer))).toLowerCase()
164 if (!last) return false
165 if (!(/^(alles\s+|all\s+)?(fertig|erledigt|done)([.!:,;]|\s*$)/u.test(last) || /^all set([.!,]|$)/u.test(last))) return false
166 return !/(^|[^\p{L}])(bis auf|außer|aber|noch|fehlt|fehlen|offen|except|but|still|yet|remaining|todo)([^\p{L}]|$)/u.test(last)
167}
168
169// ---------- Die Stufen ----------
170
171/**
172 * Stufen 1–8. Liefert `null`, wenn keine Regel entscheidet: dann Stufe 9 (Haiku) bzw. FRAGEN.
173 * Wirft nie; ein Fehler beim Aufrufer führt dort zu FRAGEN.
174 */
175export function decideRules(f: Facts, tx: Strings): Decision | null {
176 if (f.reason === 'aborted') return { outcome: 'STOPP', stage: 1, reason: tx.aborted }
177 if (f.reason === 'error' || f.reason === 'refusal' || f.stopFailure) {
178 return { outcome: 'STOPP', stage: 2, reason: f.reason === 'refusal' ? tx.refused : tx.turnError }
179 }
180 if (f.stop && (f.stop.background.length > 0 || f.stop.crons > 0)) {
181 return { outcome: 'WARTEN', stage: 3, reason: waitingText(f.stop, tx), short: waitingShort(f.stop, tx) }
182 }
183 if (f.busyAgents.length > 0) return { outcome: 'WARTEN', stage: 4, reason: tx.waitingHelpers(f.busyAgents), short: tx.helpers(f.busyAgents.length) }
184 if (f.plan.length > 0) {
185 const done = f.plan.filter((s) => s.status === 'completed').length
186 if (done < f.plan.length) return { outcome: 'FRAGEN', stage: 5, reason: tx.planOpen(done, f.plan.length) }
187 }
188 if (isQuestion(f.answer)) return { outcome: 'FRAGEN', stage: 6, reason: tx.question }
189 if (isProblem(f.answer, f.lastToolError)) return { outcome: 'FRAGEN', stage: 7, reason: tx.problem }
190 // Nur mit classic.Stop zu diesem Turn: ohne ihn ist Stufe 3 (Hintergrundarbeit) ungeprüft, dann entscheidet Stufe 9
191 if (f.stop && isDone(f.answer)) return { outcome: 'WEITER', stage: 8, reason: tx.done }
192 return null
193}
194
195/** Anzeige bei WARTEN (Stufe 3). */
196function waitingText(stop: StopFacts, tx: Strings): string {
197 const parts: string[] = []
198 if (stop.background.length > 0) {
199 const kinds = [...new Set(stop.background.map((t) => t.type || tx.task))].join(', ')
200 parts.push(tx.tasks(stop.background.length, kinds))
201 }
202 if (stop.crons > 0) parts.push(tx.crons(stop.crons))
203 return tx.waitingBackground(parts.join(', '))
204}
205
206/** Kurzform für die Statuszeile: Helfer als Anzahl, sonst die erste Beschreibung (bzw. die Weckaufträge). */
207function waitingShort(stop: StopFacts, tx: Strings): string {
208 const agents = stop.background.filter((t) => AGENT_TASKS.includes(t.type)).length
209 const other = stop.background.filter((t) => !AGENT_TASKS.includes(t.type))
210 const parts: string[] = []
211 if (agents > 0) parts.push(tx.helpers(agents))
212 if (other.length > 0) parts.push(other.length === 1 ? (other[0]?.description || other[0]?.type || tx.task) : tx.tasks(other.length, [...new Set(other.map((t) => t.type))].join(', ')))
213 if (stop.crons > 0) parts.push(tx.crons(stop.crons))
214 return parts.join(', ')
215}
216
217// ---------- Stufe 9: Haiku ----------
218
219const HAIKU_MIN_CONFIDENCE = 0.85
220const HAIKU_TAIL = 1500
221
222/** System-Prompt (englisch); nur die kurze Begründung kommt in der eingestellten Sprache. */
223export function haikuSystem(tx: Strings): string {
224 return [
225 'You check whether an AI coding assistant has finished a task from a to-do list.',
226 "You get the task, the end of the assistant's last answer and a few facts.",
227 'Classify: "done" (the task is completed, nothing is asked of the user), "question" (the assistant asks the user something or waits for a decision),',
228 '"blocked" (something failed or could not be done), "unsure" (you cannot tell).',
229 'When in doubt, do not answer "done".',
230 `Reply with JSON only: {"verdict": "done|question|blocked|unsure", "confidence": 0..1, "why": "<at most 12 words, in ${tx.haikuLanguage}>"}`,
231 ].join(' ')
232}
233
234export function haikuPrompt(todo: string, answer: string, facts: { filesChanged: boolean; toolError: boolean }): string {
235 const end = answer.length > HAIKU_TAIL ? `…${answer.slice(-HAIKU_TAIL)}` : answer
236 return [
237 `Task: ${todo || '(a chat message, no to-do)'}`,
238 `Files changed in this task: ${facts.filesChanged ? 'yes' : 'no'}`,
239 `A tool failed: ${facts.toolError ? 'yes' : 'no'}`,
240 'End of the last answer:',
241 '"""',
242 end,
243 '"""',
244 ].join('\n')
245}
246
247export type HaikuVerdict = { verdict: 'done' | 'question' | 'blocked' | 'unsure'; confidence: number; why: string }
248
249/** Liest Haikus JSON (auch in Code-Zäunen oder mit Text drumherum). Alles Ungültige: null. */
250export function parseHaiku(text: string): HaikuVerdict | null {
251 const m = text.match(/\{[\s\S]*\}/)
252 if (!m) return null
253 let o: unknown
254 try {
255 o = JSON.parse(m[0])
256 } catch {
257 return null
258 }
259 if (!o || typeof o !== 'object') return null
260 const r = o as Record<string, unknown>
261 const verdict = r.verdict
262 if (verdict !== 'done' && verdict !== 'question' && verdict !== 'blocked' && verdict !== 'unsure') return null
263 const confidence = typeof r.confidence === 'number' ? r.confidence : Number(r.confidence)
264 if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) return null
265 const why = typeof r.why === 'string' ? r.why.replace(/\s+/g, ' ').trim().slice(0, 120) : ''
266 return { verdict, confidence, why }
267}
268
269/** Nur `done` mit genug Konfidenz ist WEITER, alles andere FRAGEN. */
270export function decideHaiku(v: HaikuVerdict | null, tx: Strings): Decision {
271 if (!v) return { outcome: 'FRAGEN', stage: 9, reason: tx.haikuNoResult }
272 if (v.verdict === 'done' && v.confidence >= HAIKU_MIN_CONFIDENCE) return { outcome: 'WEITER', stage: 9, reason: tx.haikuDone(v.why) }
273 const label = v.verdict === 'question' ? tx.haikuQuestion : v.verdict === 'blocked' ? tx.haikuBlocked : tx.haikuUnsure
274 return { outcome: 'FRAGEN', stage: 9, reason: `${label}${v.why ? ` Haiku: ${v.why}` : ''}` }
275}
276
277/**
278 * Stufe 9 auf Haiku 5.5 (SPEC Nachtrag 0.5.0). Feste ID statt Alias: Haiku 5.5 denkt, das zählt gegen `maxTokens`; stellte ein
279 * Update den Alias `haiku` um, reichten die alten 120 Tokens nicht mehr (kein Urteil → stilles FRAGEN). CC 2.1.291 kennt die ID
280 * noch nicht (Warnung `unrecognized_model`), ruft sie aber auf, und `effort` kommt an.
281 * Probe 2026-10-07 (CLI 2.1.291, 12 Fälle × 2, Texte de):
282 * - high: 24/24 richtig (halb erledigte nie WEITER), JSON 24/24; Ausgabe 57–439 Tokens (Ø 114); Dauer Ø 1,2 s, max 2,5 s;
283 * Ø 0,009 ct je Prüfung
284 * - medium: 24/24, Ø 74 Tokens, max 1,8 s · Haiku 4.5 (Alias, 120 Tokens): 24/24, Ø 51 Tokens, Ø 0,05 ct
285 * timeoutMs: höchste Dauer × 2 = 4,9 s, Untergrenze 10 s.
286 */
287export const HAIKU = { model: 'claude-haiku-5-5', effort: 'high', maxTokens: 1500, timeoutMs: 10_000 } as const
288
289/** US-Dollar je Million Tokens, Stand 2026-10-07 (wie mods/sidekick/hooks/cache.ts nach dessen Nachtrag 0.11.0). */
290const PRICES: Record<string, { input: number; output: number; cacheRead: number; cacheWrite: number }> = {
291 'claude-haiku-5-5': { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 },
292 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
293}
294
295/** Kosten eines Aufrufs mit den Preisen des Modells (Standard: das Modell von Stufe 9). */
296export function haikuCost(
297 u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number },
298 model: string = HAIKU.model,
299): number {
300 const p = PRICES[model] ?? PRICES['claude-haiku-4-5'] ?? { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }
301 return ((u.input_tokens || 0) * p.input + (u.output_tokens || 0) * p.output + (u.cache_read_input_tokens || 0) * p.cacheRead + (u.cache_creation_input_tokens || 0) * p.cacheWrite) / 1e6
302}
303hooks/help.ts 205 lines1// help.ts: die gezeichnete Hilfe-Tabelle für `/<befehl> help` (docs/HELP-SPEC.md §3-§4). Allgemein gehalten: Eine Mod
2// liefert nur `HelpData` und ihre Akzentfarbe; Aufbau, Spalten, Farben der Schalter und die Markdown-Fassung stehen hier.
3// Vorlage für alle Mods: templates/help/ (README dort). Ohne `$`, nur Daten → Baum bzw. Text, deshalb ohne Engine testbar.
4//
5// Baum aus reinen Daten {type, props, children} (types RenderElement), nur Box und Text mit erlaubten Props. Desktop:
6// Spaltenbreiten nur als ganzzahlige Prozent, sonst verwirft er den ganzen Baum (cost-ledger 0.3.1). Unter 60 Spalten
7// stehen die Spalten untereinander.
8import type { RenderElement, RenderNode } from 'claude-code'
9
10export type HelpLang = 'en' | 'de'
11export type HelpSurface = 'terminal' | 'desktop'
12
13/** Eine Zeile unter BEFEHLE bzw. BEDIENUNG: Befehl oder Bedienelement (Akzentfarbe) und seine Wirkung. */
14export type HelpCommand = { cmd: string; does: string }
15/**
16 * Zustand einer Funktion: Schalter (`on` → „● an“ in `success`, `off` → „○ aus“ in `inactive`) oder ein Wert als Text, mit
17 * „(Standard)“, wenn `isDefault`. `text` ersetzt „an“/„aus“; bei `on` steht er dann in der normalen Schriftfarbe (lange
18 * Texte bleiben lesbar), nur der Punkt ist grün.
19 */
20export type HelpState = { kind: 'on' | 'off'; text?: string } | { kind: 'value'; text: string; isDefault?: boolean }
21/** Eine Zeile unter FUNKTIONEN; `toggle` ist der Befehl, der den Zustand ändert, sonst z. B. „Einstellung“ oder „nur Info“. */
22export type HelpFeature = { name: string; state: HelpState; toggle: string }
23/** Eine Zeile unter EINSTELLUNGEN: Titel des userConfig-Felds (übersetzt) und sein aktueller Wert. */
24export type HelpSetting = { title: string; value: string; isDefault?: boolean }
25/** Schnappschuss beim Aufruf von `help`; die Zeichnung schreibt sich danach nicht um (HELP-SPEC §3 Punkt 3). */
26export type HelpData = {
27 /** Name der Mod im Titel */
28 mod: string
29 lang: HelpLang
30 /** Ein Satz, was die Mod macht */
31 intro: string
32 commands: HelpCommand[]
33 /** Gedimmte Zeilen unter den Befehlen (z. B. Aliase) */
34 notes?: string[]
35 /** BEDIENUNG: nur, wenn es Klicks oder Tasten gibt */
36 controls?: HelpCommand[]
37 features: HelpFeature[]
38 settings: HelpSetting[]
39 /** Weg zum Ändern der Einstellungen und zum Abschalten der Mod; Terminal und Desktop brauchen verschiedene Wege */
40 footer: { terminal: string; desktop: string }
41}
42
43const LABELS = {
44 en: {
45 help: 'Help',
46 commands: 'COMMANDS',
47 controls: 'CONTROLS',
48 features: 'FEATURES',
49 status: 'STATUS',
50 toggle: 'TOGGLE',
51 settings: 'SETTINGS (/plugin)',
52 value: 'VALUE',
53 on: 'on',
54 off: 'off',
55 isDefault: '(default)',
56 },
57 de: {
58 help: 'Hilfe',
59 commands: 'BEFEHLE',
60 controls: 'BEDIENUNG',
61 features: 'FUNKTIONEN',
62 status: 'STATUS',
63 toggle: 'UMSCHALTEN',
64 settings: 'EINSTELLUNGEN (/plugin)',
65 value: 'WERT',
66 on: 'an',
67 off: 'aus',
68 isDefault: '(Standard)',
69 },
70} as const
71
72export function helpLabels(lang: HelpLang) {
73 return LABELS[lang]
74}
75
76type Props = Record<string, string | number | boolean>
77const el = (type: 'Box' | 'Text', props: Props, children: RenderNode[]): RenderElement => ({ type, props, children })
78const text = (s: string, props: Props = {}) => el('Text', props, [s])
79const dim = (s: string) => text(s, { dimColor: true })
80const row = (props: Props, kids: RenderNode[]) => el('Box', { flexDirection: 'row', ...props }, kids)
81const col = (props: Props, kids: RenderNode[]) => el('Box', { flexDirection: 'column', ...props }, kids)
82const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
83
84/** Zustand als Text (für die Markdown-Fassung und zum Messen der Spaltenbreite). */
85export function stateText(s: HelpState, lang: HelpLang): string {
86 const L = LABELS[lang]
87 if (s.kind === 'value') return s.isDefault ? `${s.text} ${L.isDefault}` : s.text
88 return `${s.kind === 'on' ? '●' : '○'} ${s.text ?? (s.kind === 'on' ? L.on : L.off)}`
89}
90
91/**
92 * Zustand gezeichnet: „● an“ in `success`, „○ aus“ in `inactive`; mit eigenem Text bei `on` nur der Punkt grün. Werte in
93 * der normalen Schriftfarbe, „(Standard)“ gedimmt.
94 */
95function stateNode(s: HelpState, lang: HelpLang): RenderElement {
96 const L = LABELS[lang]
97 if (s.kind === 'value') return el('Text', {}, [text(s.text), ...(s.isDefault ? [dim(` ${L.isDefault}`)] : [])])
98 const on = s.kind === 'on'
99 const label = s.text ?? (on ? L.on : L.off)
100 const labelProps: Props = on ? (s.text === undefined ? { color: 'success' } : {}) : { color: 'inactive' }
101 return el('Text', {}, [text(on ? '● ' : '○ ', { color: on ? 'success' : 'inactive' }), text(label, labelProps)])
102}
103
104/**
105 * Spalten einer Zeile. Terminal: feste Zellen, die letzte füllt den Rest. Desktop: ganzzahlige Prozent mit Summe 100
106 * (`share` = gewünschte Breite in Zeichen, daraus die Anteile).
107 */
108function columns(sf: HelpSurface, inner: number, widths: number[], kids: RenderNode[], props: Props = {}): RenderElement {
109 if (sf === 'desktop') {
110 const pct = widths.slice(0, -1).map((w) => clamp(Math.round((w / inner) * 100), 10, 60))
111 const rest = Math.max(10, 100 - pct.reduce((a, b) => a + b, 0))
112 const all = [...pct, rest]
113 // Summe genau 100: Überhang vom größten Anteil abziehen
114 const over = all.reduce((a, b) => a + b, 0) - 100
115 if (over > 0) all[all.indexOf(Math.max(...all))]! -= over
116 return row(props, kids.map((k, i) => el('Box', { width: `${all[i]}%`, paddingRight: 1 }, [k])))
117 }
118 return row(
119 props,
120 kids.map((k, i) => (i < kids.length - 1 ? el('Box', { width: widths[i]!, flexShrink: 0 }, [k]) : el('Box', { flexGrow: 1, flexShrink: 1 }, [k]))),
121 )
122}
123
124const heading = (s: string, accent: string) => text(s, { color: accent, bold: true })
125
126/**
127 * Der ganze Baum für eine `CommandOutput`-Zeile. `columns` ist `e.viewport?.columns` (begrenzt auf 30-140), `accent` die
128 * Akzentfarbe der Mod (Hex oder Theme-Key), nur für Titel, Überschriften und Befehle.
129 */
130export function helpTree(d: HelpData, columnsHint: number, surface: HelpSurface, accent: string): RenderElement {
131 const L = LABELS[d.lang]
132 const cols = clamp(columnsHint || 100, 30, 140)
133 const inner = cols - 4 // Rahmen und paddingX
134 const narrow = cols < 60
135 const kids: RenderNode[] = [heading(`${d.mod} · ${L.help}`, accent), text(d.intro)]
136
137 const commandRows = (title: string, list: HelpCommand[]) => {
138 if (!list.length) return
139 kids.push(el('Box', { marginTop: 1 }, [heading(title, accent)]))
140 const cmdW = clamp(Math.max(...list.map((c) => c.cmd.length)) + 2, 12, Math.floor(inner * 0.5))
141 for (const c of list)
142 kids.push(
143 narrow
144 ? col({}, [text(c.cmd, { color: accent }), el('Box', { paddingLeft: 2 }, [text(c.does)])])
145 : columns(surface, inner, [cmdW, inner - cmdW], [text(c.cmd, { color: accent }), text(c.does)]),
146 )
147 }
148 commandRows(L.commands, d.commands)
149 for (const n of d.notes ?? []) kids.push(dim(n))
150 commandRows(L.controls, d.controls ?? [])
151
152 if (d.features.length) {
153 const nameW = clamp(Math.max(L.features.length, ...d.features.map((f) => f.name.length)) + 2, 12, Math.floor(inner * 0.34))
154 const stateW = clamp(Math.max(L.status.length, ...d.features.map((f) => stateText(f.state, d.lang).length)) + 2, 10, Math.floor(inner * 0.4))
155 if (narrow) {
156 kids.push(el('Box', { marginTop: 1 }, [heading(L.features, accent)]))
157 // Zustand und Umschalten in einem Text, damit sie als ein Absatz umbrechen statt als zwei schmale Spalten
158 for (const f of d.features)
159 kids.push(col({}, [text(f.name), el('Box', { paddingLeft: 2 }, [el('Text', {}, [stateNode(f.state, d.lang), dim(` · ${f.toggle}`)])])]))
160 } else {
161 const widths = [nameW, stateW, inner - nameW - stateW]
162 kids.push(columns(surface, inner, widths, [heading(L.features, accent), heading(L.status, accent), heading(L.toggle, accent)], { marginTop: 1 }))
163 for (const f of d.features) kids.push(columns(surface, inner, widths, [text(f.name), stateNode(f.state, d.lang), dim(f.toggle)]))
164 }
165 }
166
167 if (d.settings.length) {
168 const titleW = clamp(Math.max(L.settings.length, ...d.settings.map((s) => s.title.length)) + 2, 12, Math.floor(inner * 0.5))
169 const value = (s: HelpSetting) => stateNode({ kind: 'value', text: s.value, isDefault: s.isDefault }, d.lang)
170 if (narrow) {
171 kids.push(el('Box', { marginTop: 1 }, [heading(L.settings, accent)]))
172 for (const s of d.settings) kids.push(col({}, [text(s.title), el('Box', { paddingLeft: 2 }, [value(s)])]))
173 } else {
174 const widths = [titleW, inner - titleW]
175 kids.push(columns(surface, inner, widths, [heading(L.settings, accent), heading(L.value, accent)], { marginTop: 1 }))
176 for (const s of d.settings) kids.push(columns(surface, inner, widths, [text(s.title), value(s)]))
177 }
178 }
179
180 kids.push(el('Box', { marginTop: 1 }, [dim(surface === 'desktop' ? d.footer.desktop : d.footer.terminal)]))
181 return col({ borderStyle: 'round', borderDimColor: true, paddingX: 1, width: '100%', key: `${d.mod}-help` }, kids)
182}
183
184/**
185 * Kompakte Markdown-Fassung: was Claude mitliest und was `-p`, das SDK und VS Code zeigen. `tag` (Kennung `#…`) steht in
186 * der ersten Zeile, darüber findet der Render-Hook den Schnappschuss. Fußzeile mit dem Terminal-Weg. Leerzeilen zwischen
187 * den Blöcken: Sonst hängt Markdown (CommonMark) alles nach einer Liste an deren letzten Punkt.
188 */
189export function helpMarkdown(d: HelpData, tag: string): string {
190 const L = LABELS[d.lang]
191 const lines = [`**${d.mod} · ${L.help}**${tag ? ` · ${tag}` : ''}`, '', d.intro]
192 const list = (title: string, items: HelpCommand[]) => {
193 if (!items.length) return
194 lines.push('', `**${title}**`, ...items.map((c) => `- \`${c.cmd}\`: ${c.does}`))
195 }
196 list(L.commands, d.commands)
197 if (d.notes?.length) lines.push('', ...d.notes)
198 list(L.controls, d.controls ?? [])
199 if (d.features.length) lines.push('', `**${L.features}:** ${d.features.map((f) => `${f.name} ${stateText(f.state, d.lang)} (${f.toggle})`).join(' · ')}`)
200 if (d.settings.length)
201 lines.push('', `**${L.settings}:** ${d.settings.map((s) => `${s.title} ${s.value}${s.isDefault ? ` ${L.isDefault}` : ''}`).join(' · ')}`)
202 lines.push('', d.footer.terminal)
203 return lines.join('\n')
204}
205hooks/helpdata.ts 70 lines1// helpdata.ts: Inhalt von /todos help (0.7.0, docs/HELP-SPEC.md §5 „worklist“). Die Zeichnung macht help.ts (Kopie aus
2// templates/help/), hier stehen nur die Wortliste des Parsers und der Schnappschuss HelpData. Ohne `$`, ohne Engine testbar.
3import { helpLabels } from './help.ts'
4import type { HelpData } from './help.ts'
5import { T } from './i18n.ts'
6import { DEFAULT_SETTINGS } from './model.ts'
7import type { Settings } from './model.ts'
8
9/** Alle Wörter, die /todos annimmt (außer leer = Seitenleiste). Der Parser lehnt alles andere ab; ein Test prüft jedes gegen die Hilfe. */
10export const TODOS_WORDS = ['status', 'pause', 'resume', 'done', 'skip', 'retry', 'clear', 'history', 'close', 'help', '?'] as const
11export const HELP_WORDS = ['help', '?']
12
13/** Was der Schnappschuss vom Laufzustand braucht: Pause der Liste, Schleifenschutz hält an (zweiter Halt am selben To-do). */
14export type HelpRuntime = { paused: boolean; hold: boolean }
15
16/** Schnappschuss beim Aufruf von /todos help; die Zeichnung schreibt sich danach nicht um (HELP-SPEC §3 Punkt 3). */
17export function worklistHelp(s: Settings, st: HelpRuntime): HelpData {
18 const L = T[s.lang]
19 const H = L.hlp
20 const on = helpLabels(s.lang)
21 const flag = (v: boolean) => (v ? on.on : on.off)
22 const D = DEFAULT_SETTINGS
23 return {
24 mod: 'worklist',
25 lang: s.lang,
26 intro: H.intro,
27 commands: [
28 { cmd: `/todo ${H.task}`, does: H.todoAdd },
29 { cmd: '/todo', does: H.todoPane },
30 { cmd: '/todos', does: H.todosPane },
31 { cmd: '/todos status', does: H.status },
32 { cmd: '/todos pause', does: H.pause },
33 { cmd: '/todos resume', does: H.resume },
34 { cmd: '/todos done', does: H.done },
35 { cmd: '/todos skip', does: H.skip },
36 { cmd: '/todos retry', does: H.retry },
37 { cmd: '/todos clear', does: H.clear },
38 { cmd: '/todos history', does: H.history },
39 { cmd: '/todos close', does: H.close },
40 { cmd: '/todos help', does: H.help },
41 ],
42 notes: [H.note],
43 // Knöpfe der Seitenleiste mit ihren echten Beschriftungen (view.ts)
44 controls: [
45 { cmd: `${L.resume} · ${L.markDone} · ${L.skip}`, does: H.ctlNotice },
46 { cmd: `${L.stopWaiting} · ${L.keepWaiting}`, does: H.ctlWait },
47 { cmd: `${L.pause} · ${L.start} · ${L.go}`, does: H.ctlControl },
48 { cmd: '↑ ↓ ✕', does: H.ctlRow },
49 { cmd: L.placeholder, does: H.ctlInput },
50 { cmd: `${L.history} · ${H.show}`, does: H.ctlHistory },
51 ],
52 features: [
53 { name: H.fPaused, state: { kind: st.paused ? 'on' : 'off' }, toggle: st.paused ? '/todos resume' : '/todos pause' },
54 { name: H.fHaiku, state: { kind: s.haiku ? 'on' : 'off' }, toggle: H.setting },
55 { name: H.fHints, state: { kind: s.doneLine ? 'on' : 'off' }, toggle: H.setting },
56 { name: H.fSettle, state: { kind: 'value', text: H.seconds(s.settleSeconds), isDefault: s.settleSeconds === D.settleSeconds }, toggle: H.setting },
57 { name: H.fRow, state: { kind: 'value', text: String(s.maxAutoRun), isDefault: s.maxAutoRun === D.maxAutoRun }, toggle: H.setting },
58 { name: H.fGuard, state: st.hold ? { kind: 'on', text: H.guardOn } : { kind: 'off', text: H.guardOff }, toggle: H.info },
59 ],
60 settings: [
61 { title: H.sLanguage, value: s.lang, isDefault: s.lang === D.lang },
62 { title: H.sHaiku, value: flag(s.haiku), isDefault: s.haiku === D.haiku },
63 { title: H.sHints, value: flag(s.doneLine), isDefault: s.doneLine === D.doneLine },
64 { title: H.sSettle, value: String(s.settleSeconds), isDefault: s.settleSeconds === D.settleSeconds },
65 { title: H.sRow, value: String(s.maxAutoRun), isDefault: s.maxAutoRun === D.maxAutoRun },
66 ],
67 footer: { terminal: H.footerTerminal, desktop: H.footerDesktop },
68 }
69}
70hooks/i18n.ts 509 lines1// worklist: Texte auf Englisch (Standard) und Deutsch, ohne `$` (release/I18N.md). Die Sprache kommt aus userConfig `language`.
2// Eingaben und die Erkennung in check.ts verstehen immer beide Sprachen; diese Tabellen betreffen nur, was worklist ausgibt.
3
4export type Lang = 'en' | 'de'
5
6export function cleanLang(v: unknown): Lang {
7 return v === 'de' ? 'de' : 'en'
8}
9
10const two = (n: number) => String(n).padStart(2, '0')
11const WD = { en: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], de: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] }
12const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
13
14/** Uhrzeit `14:30` (beide Sprachen). */
15export function hhmm(t: number): string {
16 const d = new Date(t)
17 return `${two(d.getHours())}:${two(d.getMinutes())}`
18}
19
20/** Datum ohne Jahr: en `Oct 6`, de `06.10.` */
21export function shortDate(lang: Lang, t: number): string {
22 const d = new Date(t)
23 return lang === 'de' ? `${two(d.getDate())}.${two(d.getMonth() + 1)}.` : `${MON[d.getMonth()]} ${d.getDate()}`
24}
25
26/** Wochentag und Datum für die Tagesgruppen im Verlauf: en `Mon Oct 6`, de `Mo 06.10.` */
27export function dayDate(lang: Lang, t: number): string {
28 return `${WD[lang][new Date(t).getDay()]} ${shortDate(lang, t)}`
29}
30
31/** US-Dollar als Cent: en `0.05 ¢`, de `0,05 ct` */
32export function cents(lang: Lang, usd: number): string {
33 const v = (usd * 100).toFixed(2)
34 return lang === 'de' ? `${v.replace('.', ',')} ct` : `${v} ¢`
35}
36
37/**
38 * Ab 0.3.0: unsichtbarer Hinweis zu jedem gesendeten To-do (classic.UserPromptSubmit, `additionalContext`). Fynn sieht ihn
39 * im Chat nicht, das Modell liest ihn als System-Hinweis neben dem To-do. Die Marke darin ist „Done.“ bzw. „Fertig.“
40 */
41export const DONE_HINTS: Record<Lang, string> = {
42 en: 'This message is a task from the user\'s to-do list (worklist). If you need a decision from the user and the AskUserQuestion tool is available, ask with it and carry on after the answer; otherwise end your answer with a clear question and don\'t write "Done.". Write "Done." on a line of its own only when this to-do is completely finished.',
43 de: 'Diese Nachricht ist eine Aufgabe aus der To-do-Liste des Nutzers (worklist). Brauchst du eine Entscheidung des Nutzers und gibt es das Werkzeug AskUserQuestion, stell die Frage damit und mach nach der Antwort weiter; sonst schließ mit einer klaren Rückfrage und schreib kein „Fertig.“. Schreib „Fertig.“ in einer eigenen Zeile nur, wenn dieses To-do vollständig erledigt ist.',
44}
45
46/**
47 * Ab 0.4.0: unsichtbarer Hinweis an Fynns Antwort auf die Rückfrage eines To-dos (classic.UserPromptSubmit). So weiß Claude,
48 * dass die Antwort zu diesem To-do gehört, und schließt erst dann mit der Fertig-Marke, wenn es ganz erledigt ist.
49 */
50export const ANSWER_HINTS: Record<Lang, (todo: string) => string> = {
51 en: (todo) => `This message answers your question about the to-do "${todo}" (worklist). If this to-do is then completely finished, end with "Done." on a line of its own; if something is still open or unclear, ask.`,
52 de: (todo) => `Diese Nachricht antwortet auf deine Rückfrage zum To-do „${todo}“ (worklist). Ist dieses To-do danach vollständig erledigt, schließ mit „Fertig.“ in einer eigenen Zeile; ist noch etwas offen oder unklar, frag nach.`,
53}
54
55/** Ab 0.4.0: Text, den „Fortsetzen“ für ein angehaltenes To-do sendet; der Schluss-Hinweis geht unsichtbar mit. */
56export const CONTINUE_TEXTS: Record<Lang, string> = {
57 en: 'Continue with this to-do; decide open points sensibly yourself.',
58 de: 'Mach mit diesem To-do weiter; entscheide offene Punkte selbst sinnvoll.',
59}
60
61/** Bis 0.2.2 sichtbare Schlusszeile an jedem gesendeten To-do; bleibt, damit alte Nachrichten im Chat erkannt werden. */
62export const DONE_LINES: Record<Lang, string> = {
63 en: '(Worklist: If anything is unclear, end with a clear question and don\'t write "Done.". Otherwise finish the task completely and end with "Done.")',
64 de: '(Arbeitsliste: Ist etwas unklar, stell am Ende eine klare Rückfrage und schreib dann kein „Fertig.“. Sonst erledige die Aufgabe vollständig und schließe mit „Fertig.“)',
65}
66
67const en = {
68 // Prüfung (check.ts)
69 aborted: 'Interrupted.',
70 refused: 'Refused.',
71 turnError: 'Error in the turn.',
72 waitingHelpers: (names: readonly string[]) => `waiting for ${names.length === 1 ? 'a helper' : `${names.length} helpers`}: ${names.join(', ')}`,
73 planOpen: (done: number, total: number) => `Claude's plan isn't finished yet (${done}/${total}).`,
74 question: 'Claude has a question.',
75 problem: 'Claude reports a problem.',
76 done: 'Claude reports "done".',
77 task: 'task',
78 tasks: (n: number, kinds: string) => `${n === 1 ? '1 task' : `${n} tasks`} (${kinds})`,
79 crons: (n: number) => (n === 1 ? '1 scheduled wake-up' : `${n} scheduled wake-ups`),
80 waitingBackground: (parts: string) => `waiting for background work: ${parts}`,
81 haikuNoResult: 'Unclear whether Claude is done (the check gave no result).',
82 haikuDone: (why: string) => `Haiku: done.${why ? ` ${why}` : ''}`,
83 haikuQuestion: 'Claude is waiting for you.',
84 haikuBlocked: 'Claude is stuck.',
85 haikuUnsure: 'Unclear whether Claude is done.',
86 haikuOff: 'Unclear whether Claude is done (Haiku is off).',
87 haikuFailed: (reason: string) => `Unclear whether Claude is done (Haiku: ${reason}).`,
88 haikuLanguage: 'English',
89 checkFailed: (err: string) => `Check failed: ${err}`,
90 checkFailedShort: 'Check failed.',
91
92 // Ablauf (register.ts)
93 stopped: (reason: string) => `To-do list stopped: ${reason}`,
94 storeWarn: "To-do list: saving failed, the list only lasts until restart. /todos status shows more.",
95 maxAutoRun: (n: number) => `${n} to-dos in a row without your input. Take a quick look, then "Continue".`,
96 bgNotice: (what: string) => `Still running in the background: ${what}. Stop waiting?`,
97 holdNoResume: 'Stopped twice on this to-do: answer in the chat, mark it as done or skip it.',
98 nextStarting: 'Next to-do starts shortly …',
99 checking: 'checking whether Claude is clearly done …',
100 sendRejected: (why: string) => `Sending was refused: ${why}`,
101 sendFailed: (err: string) => `Sending failed: ${err}`,
102 cmdUnknown: (name: string) => `Unknown command /${name}: the to-do was not sent.`,
103 cmdOwn: (name: string) => `/${name} is worklist's own command and can't run as a to-do.`,
104 cmdFailed: (err: string) => `The command didn't run: ${err}`,
105 cmdRan: 'Command ran (without a turn).',
106 holdTwice: 'Stopped twice on this to-do. Please decide yourself.',
107 secondTime: 'Second time on this to-do: the list waits for you.',
108 unclearFree: 'Unclear whether Claude is free.',
109 reloadUnclear: 'After the reload it is unclear whether Claude is free.',
110 reloadInterrupted: 'The check was interrupted by the reload.',
111 reloadWaiting: 'Waiting for background work was interrupted by the reload. "Start now" continues.',
112 clearedRunning: 'This to-do was running when the chat was cleared. Continue sends it again; Mark as done if the new chat already did it.',
113 thinking: 'thinking',
114 noAnswer: (reason: string) => `no answer (${reason})`,
115 unreadable: (text: string) => `unreadable: ${text}`,
116
117 // Letzte Entscheidung (/todos status)
118 outcome: { WEITER: 'CONTINUE', WARTEN: 'WAIT', FRAGEN: 'ASK', STOPP: 'STOP' },
119 step: (n: number) => `step ${n}`,
120 ofTodo: (text: string) => `to-do "${text}"`,
121 fromChatShort: 'from the chat',
122 rulesUnclear: 'rules unclear, Haiku only at the next to-do',
123 listIdle: 'list empty or paused',
124
125 // Seitenleiste: Statuszeile und Fortschritt (view.ts, statusBox)
126 stRunning: (n: number, m: number, time: string) => `running · to-do ${n}/${m} · ${time}`,
127 stChat: (time: string) => `working · from the chat · ${time}`,
128 stAsk: 'waiting for your answer in the chat',
129 stStopped: 'stopped · waiting for you',
130 stWaiting: (what: string) => `waiting for background work (${what})`,
131 stChecking: 'checking whether Claude is clearly done',
132 stNext: 'free · next one starts shortly',
133 stEmpty: 'free · list empty',
134 stPaused: 'paused',
135 stFresh: 'ready · starts after your next message or with "Start"',
136 stSwitching: 'new chat, loading its list …',
137 helpers: (n: number) => (n === 1 ? '1 helper' : `${n} helpers`),
138 progress: (done: number, total: number) => `${done}/${total} done`,
139
140 // Seitenleiste: JETZT
141 now: 'NOW',
142 fromChat: '◆ from the chat',
143 checkingLine: '… checking whether Claude is clearly done',
144 plan: "Claude's plan",
145
146 // Seitenleiste: HINWEIS, DANACH, VERLAUF
147 noticeTitle: 'NOTICE · list stopped',
148 noticeWaitTitle: 'NOTICE · still waiting',
149 answerInChat: 'Just answer in the chat, then this to-do continues.',
150 resume: 'Continue',
151 markDone: 'Mark as done',
152 skip: 'Skip',
153 stopWaiting: "Don't wait",
154 keepWaiting: 'Keep waiting',
155 next: 'NEXT',
156 nothingOpen: 'nothing open',
157 nOpen: (n: number) => `${n} open`,
158 skippedMark: 'skipped',
159 placeholder: 'new to-do, Enter',
160 submit: 'queue',
161 pause: '⏸ Pause',
162 start: '▶ Start',
163 go: '▶ Start now',
164 history: 'HISTORY',
165 historyEmpty: 'empty',
166 nDone: (n: number) => `${n} done`,
167 byHand: 'by hand',
168 today: 'Today',
169 yesterday: 'Yesterday',
170 showLess: 'show less',
171 showAll: (n: number) => `show all ${n}`,
172 renderError: 'Display error, /todos status',
173
174 // Zeile im Chat
175 sentLine: (n: number, m: number) => `· worklist: to-do ${n}/${m} sent`,
176 sentLineCont: (n: number, m: number) => `· worklist: to-do ${n}/${m} · continued`,
177 sentLabel: 'sent:',
178
179 // Live-Aktivität
180 act: {
181 read: (f: string) => `reads ${f}`,
182 edit: (f: string) => `edits ${f}`,
183 write: (f: string) => `writes ${f}`,
184 shell: (c: string) => `command: ${c}`,
185 search: (p: string) => `searches ${p}`,
186 fetch: (u: string) => `opens ${u}`,
187 web: (q: string) => `researches ${q}`,
188 agent: (d: string) => `helper: ${d}`,
189 plan: 'plans',
190 tick: 'checks off',
191 mcp: (t: string) => `uses ${t}`,
192 },
193
194 // Befehle
195 cmdTodo: 'Queue a to-do: /todo <task> (without text: open the sidebar)',
196 cmdTodoHint: '<task>',
197 cmdTodos: 'To-do list: sidebar, status, pause, resume, done, skip, retry, clear, history, close, help',
198 help: [
199 'Usage:',
200 ' /todo <task> queue a task (everything after /todo is the task)',
201 ' /todo open the sidebar',
202 ' /todos open the sidebar',
203 ' /todos pause | resume pause the list, or continue (like the Continue button)',
204 ' /todos done mark the running to-do as done',
205 ' /todos skip skip the stopped to-do (to the end, marked "skipped")',
206 ' /todos retry send the stopped to-do again, in full',
207 ' /todos clear delete open to-dos (the history stays)',
208 ' /todos history show the history',
209 ' /todos status check state, last decision, Haiku cost',
210 ' /todos close close the sidebar',
211 ' /todos help all commands, functions and settings',
212 ].join('\n'),
213 paneWaiting: (reason: string) => `Sidebar is waiting: ${reason}`,
214 queued: (n: number, text: string) => `Queued (${n} open): ${text}`,
215 carried: (n: number) => `To-do list kept after /clear (${n} open)`,
216 carriedStopped: (n: number) => `To-do list kept after /clear (${n} open), stopped: the to-do that was running waits for you (sidebar or /todos status).`,
217 pausedMsg: 'List paused. /todos resume resumes it.',
218 resumed: 'List continues.',
219 resumedEmpty: 'List continues; nothing is open right now.',
220 doneMsg: (text: string) => `Marked as done: ${text}`,
221 noRunning: 'No running to-do.',
222 skipped: (text: string) => `Skipped, now at the end: ${text}`,
223 retried: (text: string) => `Sending again: ${text}`,
224 continued: (text: string) => `Continuing: ${text}`,
225 cleared: (n: number) => `${n} open to-dos deleted. The history stays.`,
226 unknown: (args: string) => `Unknown: ${args}. To queue a to-do use /todo <task>.`,
227 allCommands: 'All commands: /todos help',
228 // /todos help (0.7.0, docs/HELP-SPEC.md §5); Überschriften und an/aus stehen in help.ts
229 hlp: {
230 intro: 'To-do list next to the chat: queue tasks, even while Claude works; when Claude is clearly done, the next one starts.',
231 task: '<task>',
232 todoAdd: 'queue a task (everything after /todo is the task)',
233 todoPane: 'open the sidebar with its input field',
234 todosPane: 'open the sidebar',
235 status: 'check state, last decision, Haiku cost',
236 pause: 'pause the list',
237 resume: 'continue (like the Continue button)',
238 done: 'mark the running or stopped to-do as done',
239 skip: 'skip the stopped to-do (to the end, marked "skipped")',
240 retry: 'send the stopped to-do again, in full',
241 clear: 'delete open to-dos (the history stays)',
242 history: 'show the history',
243 close: 'close the sidebar',
244 help: 'this help (also /todos ?)',
245 note: '/todo help queues a to-do "help"; the help is /todos help.',
246 ctlNotice: 'when the list stops: continue in order, check off, or move to the end',
247 ctlWait: 'when background work runs longer than 2 minutes',
248 ctlControl: 'pause, resume, or start despite a question or background work',
249 ctlRow: 'per to-do: move up, move down, remove',
250 ctlInput: 'queue a to-do',
251 ctlHistory: 'fold the history open or shut, show all entries',
252 show: 'show all',
253 fPaused: 'List paused',
254 fHaiku: 'Haiku check',
255 fHints: 'Hidden hints',
256 fSettle: 'Settle time',
257 fRow: 'To-dos in a row',
258 fGuard: 'Loop guard',
259 guardOn: 'holding',
260 guardOff: 'idle',
261 setting: 'setting',
262 info: 'info only',
263 seconds: (n: number) => `${n} s`,
264 sLanguage: 'Language',
265 sHaiku: 'Haiku 5.5 for unclear cases',
266 sHints: 'Hidden hints',
267 sSettle: 'Settle time (seconds)',
268 sRow: 'To-dos in a row',
269 footerTerminal: 'Change settings: /plugin configure worklist · Turn the mod off: /plugin disable worklist',
270 footerDesktop: 'Turn the mod off: + → Plugins → Manage plugins · Change settings: /plugin configure worklist in a terminal',
271 },
272 failed: (err: string) => `That didn't work: ${err}`,
273
274 // /todos status
275 stateWorking: 'Claude is working',
276 state: { idle: 'free', waiting: 'waiting', checking: 'checking', ask: 'stopped', blocked: 'waiting for you', unclear: 'free, check before the next start', fresh: 'ready, starts after your next message or with Start' },
277 statusList: (open: number, running: boolean, paused: boolean, done: number) =>
278 `List: ${open} open${running ? ', 1 running' : ''}${paused ? ', paused' : ''} · done in this chat: ${done}`,
279 statusState: (state: string, reason: string, hold: boolean) => `Check state: ${state}${reason ? ` (${reason})` : ''}${hold ? ' · loop guard active' : ''}`,
280 statusDecision: (d: string) => `Last decision: ${d}`,
281 statusHaiku: (on: boolean, last: string, calls: number, cost: string) => `Haiku: ${on ? 'on' : 'off'} · last ${last} · so far ${calls}× in this project, ${cost}`,
282 statusRun: (auto: number, max: number, settle: number, hint: boolean, last: string) =>
283 `In a row without your input: ${auto}/${max} · settle time ${settle} s · hidden closing hint ${hint ? 'on' : 'off'}${last ? ` (last to-do: ${last})` : ''}`,
284 hintAttached: 'attached',
285 hintMissing: 'not attached',
286 statusHistory: (n: number) => `History in this project: ${n} entries`,
287
288 // /todos history
289 historyNone: 'History: nothing done in this project yet.',
290 historyHead: (n: number, cut: boolean) => `History (${n}, newest first${cut ? ', the last 30' : ''}):`,
291}
292
293export type Strings = typeof en
294
295const de: Strings = {
296 aborted: 'Abgebrochen.',
297 refused: 'Abgelehnt.',
298 turnError: 'Fehler beim Turn.',
299 waitingHelpers: (names) => `wartet auf ${names.length === 1 ? 'einen Helfer' : `${names.length} Helfer`}: ${names.join(', ')}`,
300 planOpen: (done, total) => `Claudes Plan ist noch nicht durch (${done}/${total}).`,
301 question: 'Claude hat eine Rückfrage.',
302 problem: 'Claude meldet ein Problem.',
303 done: 'Claude meldet „fertig“.',
304 task: 'Aufgabe',
305 tasks: (n, kinds) => `${n === 1 ? '1 Aufgabe' : `${n} Aufgaben`} (${kinds})`,
306 crons: (n) => (n === 1 ? '1 geplanter Weckauftrag' : `${n} geplante Weckaufträge`),
307 waitingBackground: (parts) => `wartet auf Hintergrundarbeit: ${parts}`,
308 haikuNoResult: 'Unklar, ob Claude fertig ist (Prüfung ohne Ergebnis).',
309 haikuDone: (why) => `Haiku: fertig.${why ? ` ${why}` : ''}`,
310 haikuQuestion: 'Claude wartet auf dich.',
311 haikuBlocked: 'Claude kommt nicht weiter.',
312 haikuUnsure: 'Unklar, ob Claude fertig ist.',
313 haikuOff: 'Unklar, ob Claude fertig ist (Haiku ist aus).',
314 haikuFailed: (reason) => `Unklar, ob Claude fertig ist (Haiku: ${reason}).`,
315 haikuLanguage: 'German',
316 checkFailed: (err) => `Prüfung fehlgeschlagen: ${err}`,
317 checkFailedShort: 'Prüfung fehlgeschlagen.',
318
319 stopped: (reason) => `To-do-Liste angehalten: ${reason}`,
320 storeWarn: 'To-do-Liste: Speichern ging nicht, die Liste gilt nur bis zum Neustart. /todos status zeigt mehr.',
321 maxAutoRun: (n) => `${n} To-dos in Folge ohne deinen Eingriff. Kurz drüberschauen, dann „Fortsetzen“.`,
322 bgNotice: (what) => `Im Hintergrund läuft noch: ${what}. Nicht mehr warten?`,
323 holdNoResume: 'Zweimal angehalten bei diesem To-do: Antworte im Chat, hake es ab oder überspringe es.',
324 nextStarting: 'Nächstes To-do startet gleich …',
325 checking: 'prüft, ob Claude sicher fertig ist …',
326 sendRejected: (why) => `Senden abgelehnt: ${why}`,
327 sendFailed: (err) => `Senden ging nicht: ${err}`,
328 cmdUnknown: (name) => `Unbekannter Befehl /${name}: Das To-do wurde nicht gesendet.`,
329 cmdOwn: (name) => `/${name} ist ein Befehl von worklist selbst und kann nicht als To-do laufen.`,
330 cmdFailed: (err) => `Der Befehl lief nicht: ${err}`,
331 cmdRan: 'Befehl ausgeführt (ohne Turn).',
332 holdTwice: 'Zweimal angehalten bei diesem To-do. Bitte selbst entscheiden.',
333 secondTime: 'Zum zweiten Mal bei diesem To-do: Die Liste wartet auf dich.',
334 unclearFree: 'Unklar, ob Claude frei ist.',
335 reloadUnclear: 'Nach dem Neuladen unklar, ob Claude frei ist.',
336 reloadInterrupted: 'Die Prüfung wurde durch das Neuladen unterbrochen.',
337 reloadWaiting: 'Das Warten auf Hintergrundarbeit wurde durch das Neuladen unterbrochen. „Jetzt starten“ setzt fort.',
338 clearedRunning: 'Dieses To-do lief, als der Chat geleert wurde. Fortsetzen sendet es neu; Abhaken, wenn der neue Chat es schon erledigt hat.',
339 thinking: 'denkt nach',
340 noAnswer: (reason) => `keine Antwort (${reason})`,
341 unreadable: (text) => `unlesbar: ${text}`,
342
343 outcome: { WEITER: 'WEITER', WARTEN: 'WARTEN', FRAGEN: 'FRAGEN', STOPP: 'STOPP' },
344 step: (n) => `Stufe ${n}`,
345 ofTodo: (text) => `To-do „${text}“`,
346 fromChatShort: 'aus dem Chat',
347 rulesUnclear: 'Regeln unklar, Haiku erst beim nächsten To-do',
348 listIdle: 'Liste leer oder pausiert',
349
350 stRunning: (n, m, time) => `läuft · To-do ${n}/${m} · ${time}`,
351 stChat: (time) => `arbeitet · aus dem Chat · ${time}`,
352 stAsk: 'wartet auf deine Antwort im Chat',
353 stStopped: 'angehalten · wartet auf dich',
354 stWaiting: (what) => `wartet auf Hintergrund (${what})`,
355 stChecking: 'prüft, ob Claude sicher fertig ist',
356 stNext: 'frei · nächstes startet gleich',
357 stEmpty: 'frei · Liste leer',
358 stPaused: 'pausiert',
359 stFresh: 'bereit · startet nach deiner nächsten Nachricht oder mit „Start“',
360 stSwitching: 'neuer Chat, Liste wird geladen …',
361 helpers: (n) => (n === 1 ? '1 Helfer' : `${n} Helfer`),
362 progress: (done, total) => `${done}/${total} erledigt`,
363
364 now: 'JETZT',
365 fromChat: '◆ aus dem Chat',
366 checkingLine: '… prüft, ob Claude sicher fertig ist',
367 plan: 'Claudes Plan',
368
369 noticeTitle: 'HINWEIS · Liste angehalten',
370 noticeWaitTitle: 'HINWEIS · wartet noch',
371 answerInChat: 'Antworte einfach im Chat, dann geht es mit diesem To-do weiter.',
372 resume: 'Fortsetzen',
373 markDone: 'Abhaken',
374 skip: 'Überspringen',
375 stopWaiting: 'Nicht mehr warten',
376 keepWaiting: 'Weiter warten',
377 next: 'DANACH',
378 nothingOpen: 'nichts offen',
379 nOpen: (n) => `${n} offen`,
380 skippedMark: 'übersprungen',
381 placeholder: 'neues To-do, Enter',
382 submit: 'einreihen',
383 pause: '⏸ Pause',
384 start: '▶ Start',
385 go: '▶ Jetzt starten',
386 history: 'VERLAUF',
387 historyEmpty: 'leer',
388 nDone: (n) => `${n} erledigt`,
389 byHand: 'von Hand',
390 today: 'Heute',
391 yesterday: 'Gestern',
392 showLess: 'weniger zeigen',
393 showAll: (n) => `alle ${n} zeigen`,
394 renderError: 'Anzeige-Fehler, /todos status',
395
396 sentLine: (n, m) => `· worklist: To-do ${n}/${m} gesendet`,
397 sentLineCont: (n, m) => `· worklist: To-do ${n}/${m} · fortgesetzt`,
398 sentLabel: 'gesendet:',
399
400 act: {
401 read: (f) => `liest ${f}`,
402 edit: (f) => `ändert ${f}`,
403 write: (f) => `schreibt ${f}`,
404 shell: (c) => `Befehl: ${c}`,
405 search: (p) => `sucht ${p}`,
406 fetch: (u) => `öffnet ${u}`,
407 web: (q) => `recherchiert ${q}`,
408 agent: (d) => `Helfer: ${d}`,
409 plan: 'plant',
410 tick: 'hakt ab',
411 mcp: (t) => `nutzt ${t}`,
412 },
413
414 cmdTodo: 'To-do einreihen: /todo <Aufgabe> (ohne Text: Seitenleiste)',
415 cmdTodoHint: '<Aufgabe>',
416 cmdTodos: 'To-do-Liste: Seitenleiste, status, pause, resume, done, skip, retry, clear, history, close, help',
417 help: [
418 'Nutzung:',
419 ' /todo <Aufgabe> einreihen (alles hinter /todo ist die Aufgabe)',
420 ' /todo Seitenleiste öffnen',
421 ' /todos Seitenleiste öffnen',
422 ' /todos pause | resume Liste pausieren bzw. fortsetzen (wie der Knopf Fortsetzen)',
423 ' /todos done laufendes abhaken',
424 ' /todos skip angehaltenes überspringen (ans Ende, Marke „übersprungen“)',
425 ' /todos retry angehaltenes noch einmal ganz senden',
426 ' /todos clear offene löschen (der Verlauf bleibt)',
427 ' /todos history Verlauf zeigen',
428 ' /todos status Prüfstand, letzte Entscheidung, Haiku-Kosten',
429 ' /todos close Seitenleiste schließen',
430 ' /todos help alle Befehle, Funktionen und Einstellungen',
431 ].join('\n'),
432 paneWaiting: (reason) => `Seitenleiste wartet: ${reason}`,
433 queued: (n, text) => `Eingereiht (${n} offen): ${text}`,
434 carried: (n) => `To-do-Liste nach /clear übernommen (${n} offen)`,
435 carriedStopped: (n) => `To-do-Liste nach /clear übernommen (${n} offen), angehalten: Das laufende To-do wartet auf dich (Seitenleiste oder /todos status).`,
436 pausedMsg: 'Liste pausiert. /todos resume setzt sie fort.',
437 resumed: 'Liste läuft weiter.',
438 resumedEmpty: 'Liste läuft weiter; gerade ist nichts offen.',
439 doneMsg: (text) => `Abgehakt: ${text}`,
440 noRunning: 'Kein laufendes To-do.',
441 skipped: (text) => `Übersprungen, jetzt am Ende: ${text}`,
442 retried: (text) => `Wird noch einmal gesendet: ${text}`,
443 continued: (text) => `Wird fortgesetzt: ${text}`,
444 cleared: (n) => `${n} offene To-dos gelöscht. Der Verlauf bleibt.`,
445 unknown: (args) => `Unbekannt: ${args}. Einreihen geht mit /todo <Aufgabe>.`,
446 allCommands: 'Alle Befehle: /todos help',
447 hlp: {
448 intro: 'To-do-Liste neben dem Chat: Aufgaben einreihen, auch während Claude arbeitet; ist Claude sicher fertig, startet die nächste.',
449 task: '<Aufgabe>',
450 todoAdd: 'Aufgabe einreihen (alles nach /todo ist die Aufgabe)',
451 todoPane: 'Seitenleiste mit Eingabefeld öffnen',
452 todosPane: 'Seitenleiste öffnen',
453 status: 'Prüfstand, letzte Entscheidung, Haiku-Kosten',
454 pause: 'Liste pausieren',
455 resume: 'fortsetzen (wie der Knopf Fortsetzen)',
456 done: 'laufendes bzw. angehaltenes To-do abhaken',
457 skip: 'angehaltenes To-do überspringen (ans Ende, Marke „übersprungen“)',
458 retry: 'angehaltenes To-do noch einmal ganz senden',
459 clear: 'offene To-dos löschen (der Verlauf bleibt)',
460 history: 'Verlauf zeigen',
461 close: 'Seitenleiste schließen',
462 help: 'diese Hilfe (auch /todos ?)',
463 note: '/todo help reiht ein To-do „help“ ein; die Hilfe ist /todos help.',
464 ctlNotice: 'wenn die Liste anhält: in der Reihenfolge weiter, abhaken oder ans Ende',
465 ctlWait: 'wenn Hintergrundarbeit länger als 2 Minuten läuft',
466 ctlControl: 'pausieren, fortsetzen oder trotz Rückfrage bzw. Hintergrundarbeit starten',
467 ctlRow: 'je To-do: nach oben, nach unten, entfernen',
468 ctlInput: 'To-do einreihen',
469 ctlHistory: 'Verlauf auf- und zuklappen, alle Einträge zeigen',
470 show: 'alle zeigen',
471 fPaused: 'Liste pausiert',
472 fHaiku: 'Haiku-Prüfung',
473 fHints: 'Unsichtbare Hinweise',
474 fSettle: 'Beruhigungszeit',
475 fRow: 'To-dos in Folge',
476 fGuard: 'Schleifenschutz',
477 guardOn: 'hält an',
478 guardOff: 'ruht',
479 setting: 'Einstellung',
480 info: 'nur Info',
481 seconds: (n) => `${n} s`,
482 sLanguage: 'Sprache',
483 sHaiku: 'Haiku 5.5 für unklare Fälle',
484 sHints: 'Unsichtbare Hinweise',
485 sSettle: 'Beruhigungszeit (Sekunden)',
486 sRow: 'To-dos in Folge',
487 footerTerminal: 'Einstellungen ändern: /plugin configure worklist · Mod abschalten: /plugin disable worklist',
488 footerDesktop: 'Mod abschalten: + → Plugins → Manage plugins · Einstellungen ändern: im Terminal /plugin configure worklist',
489 },
490 failed: (err) => `Das ging nicht: ${err}`,
491
492 stateWorking: 'Claude arbeitet',
493 state: { idle: 'frei', waiting: 'wartet', checking: 'prüft', ask: 'angehalten', blocked: 'wartet auf dich', unclear: 'frei, Prüfung vor dem nächsten Start', fresh: 'bereit, startet nach deiner nächsten Nachricht oder mit Start' },
494 statusList: (open, running, paused, done) => `Liste: ${open} offen${running ? ', 1 läuft' : ''}${paused ? ', pausiert' : ''} · in diesem Chat erledigt: ${done}`,
495 statusState: (state, reason, hold) => `Prüfstand: ${state}${reason ? ` (${reason})` : ''}${hold ? ' · Schleifenschutz aktiv' : ''}`,
496 statusDecision: (d) => `Letzte Entscheidung: ${d}`,
497 statusHaiku: (on, last, calls, cost) => `Haiku: ${on ? 'an' : 'aus'} · zuletzt ${last} · bisher ${calls}× im Projekt, ${cost}`,
498 statusRun: (auto, max, settle, hint, last) =>
499 `In Folge ohne Eingriff: ${auto}/${max} · Beruhigungszeit ${settle} s · unsichtbarer Schluss-Hinweis ${hint ? 'an' : 'aus'}${last ? ` (letztes To-do: ${last})` : ''}`,
500 hintAttached: 'angehängt',
501 hintMissing: 'nicht angehängt',
502 statusHistory: (n) => `Verlauf im Projekt: ${n} Einträge`,
503
504 historyNone: 'Verlauf: noch nichts erledigt in diesem Projekt.',
505 historyHead: (n, cut) => `Verlauf (${n}, neueste zuerst${cut ? ', die letzten 30' : ''}):`,
506}
507
508export const T: Record<Lang, Strings> = { en, de }
509hooks/model.ts 338 lines1// worklist: Datenmodell ohne `$` (SPEC → Datenmodell). Die Warteschlange gehört zum Chat (`queue:<sessionId>`), der Verlauf
2// zum Projekt (`history:<root>`, höchstens 300 Einträge). Erledigte To-dos stehen nur im Verlauf.
3import { DONE_LINES, cleanLang } from './i18n.ts'
4import type { Lang } from './i18n.ts'
5
6// skipped: per „Überspringen“ ans Ende gelegt; die Seitenleiste zeigt die Marke, bis es wieder startet (0.4.0)
7export type Todo = { id: string; text: string; status: 'open' | 'running'; createdAt: number; startedAt?: number; skipped?: boolean }
8export type Queue = { items: Todo[]; paused: boolean }
9export type HistoryEntry = { text: string; doneAt: number; durationMs: number; sessionId: string; result: string; how: 'auto' | 'manual' }
10export type Cost = { usd: number; calls: number }
11export type Settings = { haiku: boolean; doneLine: boolean; settleSeconds: number; maxAutoRun: number; lang: Lang }
12
13const HISTORY_MAX = 300
14const TODO_MAX = 2000 // Zeichen pro To-do; der Store hat 4 MiB für alles
15export const QUEUE_MAX = 200
16
17export const DEFAULT_SETTINGS: Settings = { haiku: true, doneLine: true, settleSeconds: 3, maxAutoRun: 15, lang: 'en' }
18
19const clampNum = (v: unknown, lo: number, hi: number, d: number) => {
20 const n = typeof v === 'number' ? v : Number(v)
21 return Number.isFinite(n) ? Math.min(hi, Math.max(lo, Math.round(n))) : d
22}
23
24/** userConfig-Werte prüfen (plugin.json → userConfig). */
25export function cleanSettings(o: Readonly<Record<string, unknown>>): Settings {
26 return {
27 haiku: typeof o.haiku === 'boolean' ? o.haiku : DEFAULT_SETTINGS.haiku,
28 doneLine: typeof o.doneLine === 'boolean' ? o.doneLine : DEFAULT_SETTINGS.doneLine,
29 settleSeconds: clampNum(o.settleSeconds, 1, 30, DEFAULT_SETTINGS.settleSeconds),
30 maxAutoRun: clampNum(o.maxAutoRun, 1, 100, DEFAULT_SETTINGS.maxAutoRun),
31 lang: cleanLang(o.language),
32 }
33}
34
35/** Gespeicherte Warteschlange lesen; Unlesbares fällt weg. */
36export function cleanQueue(v: unknown): Queue {
37 const o = v && typeof v === 'object' ? (v as Record<string, unknown>) : {}
38 const raw = Array.isArray(o.items) ? o.items : []
39 const items: Todo[] = []
40 for (const x of raw) {
41 if (!x || typeof x !== 'object') continue
42 const t = x as Record<string, unknown>
43 if (typeof t.id !== 'string' || typeof t.text !== 'string' || !t.text.trim()) continue
44 items.push({
45 id: t.id,
46 text: t.text,
47 status: t.status === 'running' ? 'running' : 'open',
48 createdAt: typeof t.createdAt === 'number' ? t.createdAt : 0,
49 ...(typeof t.startedAt === 'number' ? { startedAt: t.startedAt } : {}),
50 ...(t.skipped === true ? { skipped: true } : {}),
51 })
52 }
53 return { items: items.slice(0, QUEUE_MAX), paused: o.paused === true }
54}
55
56export function cleanHistory(v: unknown): HistoryEntry[] {
57 if (!Array.isArray(v)) return []
58 const out: HistoryEntry[] = []
59 for (const x of v) {
60 if (!x || typeof x !== 'object') continue
61 const h = x as Record<string, unknown>
62 if (typeof h.text !== 'string' || typeof h.doneAt !== 'number') continue
63 out.push({
64 text: h.text,
65 doneAt: h.doneAt,
66 durationMs: typeof h.durationMs === 'number' ? h.durationMs : 0,
67 sessionId: typeof h.sessionId === 'string' ? h.sessionId : '',
68 result: typeof h.result === 'string' ? h.result : '',
69 how: h.how === 'manual' ? 'manual' : 'auto',
70 })
71 }
72 return out.slice(0, HISTORY_MAX)
73}
74
75export function cleanCost(v: unknown): Cost {
76 const o = v && typeof v === 'object' ? (v as Record<string, unknown>) : {}
77 return { usd: typeof o.usd === 'number' && Number.isFinite(o.usd) ? o.usd : 0, calls: typeof o.calls === 'number' ? o.calls : 0 }
78}
79
80/** Neuer Eintrag vorn, die ältesten fallen weg. */
81export function pushHistory(list: readonly HistoryEntry[], e: HistoryEntry): HistoryEntry[] {
82 return [e, ...list].slice(0, HISTORY_MAX)
83}
84
85export function running(q: Queue): Todo | undefined {
86 return q.items.find((t) => t.status === 'running')
87}
88
89export function openItems(q: Queue): Todo[] {
90 return q.items.filter((t) => t.status === 'open')
91}
92
93export function nextOpen(q: Queue): Todo | undefined {
94 return q.items.find((t) => t.status === 'open')
95}
96
97export function add(q: Queue, t: Todo): Queue {
98 return { ...q, items: [...q.items, t].slice(0, QUEUE_MAX) }
99}
100
101export function remove(q: Queue, id: string): Queue {
102 return { ...q, items: q.items.filter((t) => t.id !== id) }
103}
104
105/** Status setzen; ein To-do, das wieder läuft, verliert die Marke „übersprungen“. */
106export function setStatus(q: Queue, id: string, status: Todo['status'], startedAt?: number): Queue {
107 return {
108 ...q,
109 items: q.items.map((t) => {
110 if (t.id !== id) return t
111 const { skipped, ...rest } = t
112 return { ...rest, status, ...(startedAt !== undefined ? { startedAt } : {}), ...(skipped && status !== 'running' ? { skipped } : {}) }
113 }),
114 }
115}
116
117/** Ein offenes To-do unter den offenen um eins nach oben (-1) oder unten (+1); das laufende bleibt, wo es ist. */
118export function move(q: Queue, id: string, by: -1 | 1): Queue {
119 const open = q.items.filter((t) => t.status === 'open')
120 const i = open.findIndex((t) => t.id === id)
121 const j = i + by
122 if (i < 0 || j < 0 || j >= open.length) return q
123 const swapped = [...open]
124 const a = swapped[i]
125 const b = swapped[j]
126 if (!a || !b) return q
127 swapped[i] = b
128 swapped[j] = a
129 let k = 0
130 return { ...q, items: q.items.map((t) => (t.status === 'open' ? (swapped[k++] ?? t) : t)) }
131}
132
133/** Überspringen (Knopf, `/todos skip`): offen, ans Ende, mit der Marke `skipped`. */
134export function skipToEnd(q: Queue, id: string): Queue {
135 const t = q.items.find((x) => x.id === id)
136 if (!t) return q
137 return { ...q, items: [...q.items.filter((x) => x.id !== id), { id: t.id, text: t.text, status: 'open', createdAt: t.createdAt, skipped: true }] }
138}
139
140/** Dasselbe To-do offen ganz nach vorn (Fortsetzen nach STOPP, `/todos retry`); die Marke `skipped` fällt weg. */
141export function toFront(q: Queue, id: string): Queue {
142 const t = q.items.find((x) => x.id === id)
143 if (!t) return q
144 return { ...q, items: [{ id: t.id, text: t.text, status: 'open', createdAt: t.createdAt }, ...q.items.filter((x) => x.id !== id)] }
145}
146
147/** Laufendes zurück auf offen, an seinem Platz vorn (Stufen 1–2: STOPP). */
148export function reopen(q: Queue, id: string): Queue {
149 return {
150 ...q,
151 items: q.items.map((t) => (t.id === id ? { id: t.id, text: t.text, status: 'open', createdAt: t.createdAt, ...(t.skipped ? { skipped: true } : {}) } : t)),
152 }
153}
154
155/** Erster Satz der Antwort, höchstens 120 Zeichen, ohne Markdown-Zierrat: die Kurz-Ergebniszeile im Verlauf (kostenlos). */
156export function firstSentence(answer: string, max = 120): string {
157 const plain = answer
158 .replace(/(```|~~~)[\s\S]*?(\1|$)/g, ' ')
159 // Listen-Marken am Zeilenanfang („1.“, „-“): sonst wäre das Kurz-Ergebnis nur „1.“
160 .replace(/^\s*(\d+[.)]|[-*+•])\s+/gm, '')
161 .replace(/[`*_#>]+/g, '')
162 .replace(/\s+/g, ' ')
163 .trim()
164 if (!plain) return ''
165 const m = plain.match(/^(.+?[.!?])(\s|$)/u)
166 const s = (m?.[1] ?? plain).trim()
167 if (s.length <= max) return s
168 const cut = s.slice(0, max - 1)
169 const sp = cut.lastIndexOf(' ')
170 return `${sp > max * 0.6 ? cut.slice(0, sp) : cut}…`
171}
172
173/**
174 * Anzeige ohne die Fertig-Marke: entfernt eine letzte Zeile, die nur „Fertig.“ oder „Done.“ ist (auch fett). Das ist die
175 * Marke aus der Schlusszeile (je nach Sprache). Nie mitten in einem Satz; sonst unverändert.
176 */
177export function hideDoneMarker(text: string): string {
178 const m = text.match(/^([\s\S]*?)(?:^|\n)\s*(\*\*|__)?(Fertig|Done)\.(\*\*|__)?\s*$/u)
179 if (!m) return text
180 return (m[1] ?? '').replace(/\s+$/u, '')
181}
182
183/** Alle Fassungen der Schlusszeile, auch frühere: ältere Zeilen im Chat sollen weiter erkannt werden. */
184const ALL_DONE_LINES = [
185 DONE_LINES.en,
186 DONE_LINES.de,
187 '(Arbeitsliste: Ist etwas unklar, stell am Ende eine klare Rückfrage. Sonst erledige die Aufgabe vollständig und schließe mit „Fertig.“)',
188]
189
190/** Bis 0.2.2 gesendet: `[To-do n/m] <Text>` plus Schlusszeile. Erkennt solche Texte in `turn.start` wieder (Fallback). */
191export function isTodoPrompt(text: string): boolean {
192 return /^\[To-do \d+\/\d+\] /.test(text)
193}
194
195// ---------- Gesendete To-dos (ab 0.3.0 ohne Rahmen) ----------
196
197/**
198 * Ein gesendetes To-do, pro Chat gemerkt: Daran erkennen `turn.start` und die Zeile im Chat den Text wieder, denn beim
199 * Modell kommt ab 0.3.0 genau Fynns Text an, ohne Präfix. Statt des Texts nur eine Prüfsumme (Store 4 MiB).
200 */
201// c: eine Fortsetzung („Mach mit diesem To-do weiter …“) statt des To-do-Texts (0.4.0, Knopf „Fortsetzen“)
202export type SentRec = { id: string; h: string; n: number; m: number; c?: boolean }
203
204const SENT_MAX = 50
205
206/** Prüfsumme eines gesendeten Texts (FNV-1a, 32 Bit, mit Länge); Leerraum am Rand zählt nicht. */
207export function textHash(text: string): string {
208 const s = text.trim()
209 let h = 0x811c9dc5
210 for (let i = 0; i < s.length; i++) {
211 h ^= s.charCodeAt(i)
212 h = Math.imul(h, 0x01000193) >>> 0
213 }
214 return `${h.toString(36)}.${s.length.toString(36)}`
215}
216
217export function cleanSent(v: unknown): SentRec[] {
218 if (!Array.isArray(v)) return []
219 const out: SentRec[] = []
220 for (const x of v) {
221 if (!x || typeof x !== 'object') continue
222 const r = x as Record<string, unknown>
223 if (typeof r.id !== 'string' || typeof r.h !== 'string' || typeof r.n !== 'number' || typeof r.m !== 'number') continue
224 out.push({ id: r.id, h: r.h, n: r.n, m: r.m, ...(r.c === true ? { c: true } : {}) })
225 }
226 return out.slice(-SENT_MAX)
227}
228
229/** Neuer Eintrag hinten, die ältesten fallen weg. */
230export function pushSent(list: readonly SentRec[], r: SentRec): SentRec[] {
231 return [...list, r].slice(-SENT_MAX)
232}
233
234/** Der zuletzt gesendete Eintrag mit genau diesem Text. */
235export function findSent(list: readonly SentRec[], text: string): SentRec | undefined {
236 const h = textHash(text)
237 for (let i = list.length - 1; i >= 0; i--) if (list[i]?.h === h) return list[i]
238 return undefined
239}
240
241/** Ein To-do, das mit `/name` beginnt, ist ein Befehl: Name ohne Schrägstrich (auch `plugin:skill`), Rest als Argumente. */
242export function parseCommand(text: string): { name: string; args: string } | null {
243 const m = text.trim().match(/^\/([^\s/]+)(?:\s+([\s\S]*))?$/u)
244 if (!m || !m[1]) return null
245 return { name: m[1], args: (m[2] ?? '').trim() }
246}
247
248/**
249 * Der Turn, den ein Skill-Befehl auslöst, beginnt mit `<command-name>/name</command-name>` (CLI 2.1.290, Prototyp). Liefert
250 * den Befehl als `/name args`, sonst null.
251 */
252export function commandOfTurn(text: string): string | null {
253 const name = text.match(/<command-name>\/?([^<\s]+)<\/command-name>/u)?.[1]
254 if (!name) return null
255 const args = (text.match(/<command-args>([\s\S]*?)<\/command-args>/u)?.[1] ?? '').trim()
256 return `/${name}${args ? ` ${args}` : ''}`
257}
258
259/** Zerlegt einen bis 0.2.2 gesendeten To-do-Text für die Anzeige im Verlauf: Nummer, Gesamtzahl, To-do ohne Schlusszeile. */
260export function parseSent(text: string): { n: number; m: number; text: string; withLine: boolean } | null {
261 const m = text.trim().match(/^\[To-do (\d+)\/(\d+)\] ([\s\S]*)$/)
262 if (!m) return null
263 let body = (m[3] ?? '').trim()
264 const line = ALL_DONE_LINES.find((l) => body.endsWith(l))
265 const withLine = line !== undefined
266 if (line) body = body.slice(0, body.length - line.length).trim()
267 return body ? { n: Number(m[1]), m: Number(m[2]), text: body, withLine } : null
268}
269
270/** Einreihen: Leerzeichen zusammenfassen, Länge begrenzen. */
271export function cleanText(text: string): string {
272 return text.replace(/[ \t]+/g, ' ').trim().slice(0, TODO_MAX)
273}
274
275// ---------- Laufzustand (in $.state, überlebt einen Hot Reload) ----------
276
277// unclear: Claudes letzte Antwort war nach den Regeln nicht eindeutig. fresh (0.4.0): nach Neustart, Resume oder Chatwechsel;
278// gesendet wird erst nach einem sauberen Turn-Ende, nach „Start“ oder wenn Fynn einreiht
279export type CheckState = 'idle' | 'waiting' | 'checking' | 'ask' | 'blocked' | 'unclear' | 'fresh'
280export type PlanItem = { id: string; text: string; status: 'pending' | 'in_progress' | 'completed' }
281
282export type Runtime = {
283 sid: string
284 busy: boolean
285 turnSeq: number
286 turn: { startedAt: number; todoId: string | null; text: string; fromFynn: boolean }
287 state: CheckState
288 stateReason: string
289 // kind 'background': Hintergrundarbeit läuft länger als die Wartegrenze (0.4.0); 'cleared': To-do lief beim /clear (0.6.0, bleibt
290 // über Fynns Nachricht hinweg stehen); sonst Rückfrage, STOPP oder Halt ohne To-do
291 notice: { reason: string; todoId: string | null; kind?: 'background' | 'cleared' } | null
292 autoRun: number
293 strikes: { id: string; n: number }
294 hold: boolean
295 plan: PlanItem[]
296 sessionDone: number
297 expectOwn: string | null
298 // Befehls-To-do gesendet (`$.command.run`): Name ohne Schrägstrich, bis sein Turn beginnt oder es ohne Turn abgehakt ist
299 expectCmd: string | null
300 // Gesendete To-dos dieses Chats (auch in `$.store` `sent:<sessionId>`, damit der Rahmen im Chat nach Resume bleibt)
301 sent: SentRec[]
302 lastResult: string // Kurz-Ergebnis (erster Satz) der letzten Antwort, nicht die ganze Antwort
303 // Warten auf Hintergrundarbeit (0.4.0): Kurzform für die Statuszeile, Beginn der Wartephase, Hinweis schon gezeigt,
304 // Aufgaben, auf die nach „Nicht mehr warten“ nicht mehr gewartet wird (IDs; 'cron' für geplante Weckaufträge)
305 stateShort: string
306 waitSince: number
307 waitNoticed: boolean
308 ignoreBg: string[]
309 // Zeitpunkt des letzten Turn-Endes: Ein /todo kurz danach hat Fynn während dieses Turns getippt (Phase 0, Runde 3)
310 turnEndAt: number
311}
312
313export function freshRuntime(sid: string): Runtime {
314 return {
315 sid,
316 busy: false,
317 turnSeq: 0,
318 turn: { startedAt: 0, todoId: null, text: '', fromFynn: false },
319 state: 'idle',
320 stateReason: '',
321 notice: null,
322 autoRun: 0,
323 strikes: { id: '', n: 0 },
324 hold: false,
325 plan: [],
326 sessionDone: 0,
327 expectOwn: null,
328 expectCmd: null,
329 sent: [],
330 lastResult: '',
331 stateShort: '',
332 waitSince: 0,
333 waitNoticed: false,
334 ignoreBg: [],
335 turnEndAt: 0,
336 }
337}
338hooks/view.ts 382 lines1// worklist: Anzeige der Seitenleiste ohne `$`. Aus einem View-Modell werden gebaut: Statuszeile mit Fortschritt der Liste,
2// JETZT (nur wenn etwas läuft), HINWEIS (nur wenn die Liste anhält), DANACH (Eingabefeld oben, darunter die Liste) und VERLAUF.
3// Jeder Bereich wird zwischengespeichert und nur neu gebaut, wenn sich seine Daten, die Breite oder die Sprache ändern: Neu
4// gebaute Knöpfe nahmen im Desktop keinen Klick an (mods/quick-replies/SPEC.md, Prototyp 3).
5// Kein Svg (mods/orchestrator/SPEC.md:166: interaktives Svg im Pane flackerte im Desktop mit weißem Hintergrund).
6import type { Elements, RenderElement, RenderNode } from 'claude-code'
7import { dayDate } from './i18n.ts'
8import type { Lang, Strings } from './i18n.ts'
9import type { PlanItem } from './model.ts'
10
11// Farben aus limit-bars (mods/limit-bars/hooks/view.ts), dort aus der clawd-buddy-Palette
12export const ORANGE = '#D77757'
13const YELLOW = '#F2C94C'
14const EMPTY = '#4A4A4A'
15const GREY = '#9A9A9A'
16
17/** Abstand zwischen Knöpfen (SPEC: mindestens 2 Spalten). */
18const GAP = 2
19const HISTORY_SHORT = 10
20const DAY = 86_400_000
21
22type El = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'> & { Input?: Elements['terminal']['Input'] }
23
24/** Statuszeile ganz oben: Symbol und ein Satz. Farbe nur bei „läuft“ (accent) und „wartet auf dich“ (strong). */
25export type Tone = 'accent' | 'strong' | 'dim'
26export type StatusLine = { icon: string; text: string; tone: Tone }
27
28/** JETZT: nur, solange Claude arbeitet oder ein To-do läuft bzw. geprüft wird. */
29export type NowView = {
30 kind: 'todo' | 'chat'
31 index: number // 1-basiert, nur bei 'todo'
32 total: number
33 text: string
34 elapsedMs: number
35 activity: string
36 plan: readonly PlanItem[]
37 waiting: string // „wartet auf Hintergrundarbeit: …“, sonst ''
38 checking: boolean
39}
40
41type QueueItem = { id: string; text: string; skipped: boolean }
42type HistoryItem = { text: string; doneAt: number; durationMs: number; result: string; how: 'auto' | 'manual' }
43
44/**
45 * Hinweis, wenn die Liste anhält:
46 * - question: ein To-do hat eine Rückfrage (Stufen 5–9); Fynns Antwort im Chat setzt es fort
47 * - stop: Abbruch, Fehler, unbekannter Befehl (STOPP)
48 * - hold: ohne To-do (Schleifenschutz, maxAutoRun); nur „Fortsetzen“
49 * - background: Hintergrundarbeit läuft länger als 2 Minuten
50 */
51export type NoticeKind = 'question' | 'stop' | 'hold' | 'background'
52// noResume: nach dem zweiten Halt am selben To-do helfen nur Fynns Antwort, Abhaken oder Überspringen (SPEC 0.4.2)
53type Notice = { kind: NoticeKind; reason: string; noResume?: boolean }
54
55/** Knopf in der Kopfzeile von DANACH: Pause, Start (pausiert) oder Jetzt starten (Liste wartet). */
56export type Control = 'pause' | 'start' | 'go'
57
58export type View = {
59 lang: Lang
60 status: StatusLine
61 progress: { done: number; total: number } | null
62 now: NowView | null
63 notice: Notice | null
64 queue: readonly QueueItem[]
65 control: Control
66 draft: string
67 history: readonly HistoryItem[] // neueste zuerst
68 historyOpen: boolean
69 historyAll: boolean
70 inputKey: string // Schlüssel des Eingabefelds; wechselt nach jedem Einreihen, damit es leer neu erscheint
71 todayStart: number // Mitternacht heute (lokal), für die Gruppen
72}
73
74export type Actions = {
75 resume: () => void
76 markDone: () => void
77 skip: () => void
78 stopWaiting: () => void
79 keepWaiting: () => void
80 up: (id: string) => void
81 down: (id: string) => void
82 remove: (id: string) => void
83 add: (text: string) => void
84 draft: (text: string) => void
85 control: () => void
86 toggleHistory: () => void
87 toggleAll: () => void
88}
89
90/** Laufzeit `m:ss` bzw. `h:mm:ss`. */
91export function clock(ms: number): string {
92 const s = Math.max(0, Math.floor(ms / 1000))
93 const two = (n: number) => String(n).padStart(2, '0')
94 const h = Math.floor(s / 3600)
95 const m = Math.floor((s % 3600) / 60)
96 return h > 0 ? `${h}:${two(m)}:${two(s % 60)}` : `${m}:${two(s % 60)}`
97}
98
99/** Dauer im Verlauf (beide Sprachen gleich): `< 1 min`, `4 min`, `1 h 12 min`. */
100export function duration(ms: number): string {
101 const min = Math.floor(Math.max(0, ms) / 60000)
102 if (min < 1) return '< 1 min'
103 if (min < 60) return `${min} min`
104 return `${Math.floor(min / 60)} h ${min % 60} min`
105}
106
107/** Textbalken (kein Svg im Pane). */
108function bar(done: number, total: number, width: number): string {
109 const w = Math.max(4, width)
110 const filled = total <= 0 ? 0 : Math.round((done / total) * w)
111 return '▰'.repeat(filled) + '▱'.repeat(w - filled)
112}
113
114/** Gruppenname eines Tages im Verlauf: Heute/Today, Gestern/Yesterday, sonst Wochentag und Datum. */
115function dayLabel(at: number, todayStart: number, lang: Lang, tx: Strings): string {
116 if (at >= todayStart) return tx.today
117 if (at >= todayStart - DAY) return tx.yesterday
118 return dayDate(lang, at)
119}
120
121/** Lange To-dos: höchstens `lines` Zeilen bei `cols` Spalten, an der Wortgrenze gekürzt. */
122export function clamp(text: string, cols: number, lines = 3): string {
123 const t = text.replace(/\s+/g, ' ').trim()
124 const max = Math.max(12, cols) * lines
125 if (t.length <= max) return t
126 const cut = t.slice(0, max - 1)
127 const space = cut.lastIndexOf(' ')
128 return `${space > max * 0.6 ? cut.slice(0, space) : cut}…`
129}
130
131type Cached = { sig: string; node: RenderNode }
132
133/**
134 * Baut die Seitenleiste. `cache` gehört dem Aufrufer und überlebt die Zeichnungen; jeder Bereich bleibt dasselbe Objekt,
135 * solange seine Signatur gleich ist. Die Wurzel ist ein neues Objekt, ihre Kinder sind die zwischengespeicherten Bereiche.
136 */
137export function renderPane(el: El, v: View, tx: Strings, act: Actions, cols: number, surface: string, cache: Map<string, Cached>): RenderElement {
138 const width = Math.max(20, cols)
139 const part = (name: string, sig: string, build: () => RenderNode): RenderNode => {
140 const full = `${surface}|${width}|${v.lang}|${sig}`
141 const hit = cache.get(name)
142 if (hit && hit.sig === full) return hit.node
143 const node = build()
144 cache.set(name, { sig: full, node })
145 return node
146 }
147 const children: RenderNode[] = []
148 children.push(part('status', JSON.stringify([v.status, v.progress]), () => statusBox(el, v, tx, width)))
149 const now = v.now
150 if (now) children.push(part('now', JSON.stringify(now), () => nowBox(el, now, tx, width)))
151 const notice = v.notice
152 if (notice) children.push(part('notice', JSON.stringify(notice), () => noticeBox(el, notice, tx, act)))
153 children.push(
154 part('queue', JSON.stringify([v.queue, v.control, !!el.Input, v.inputKey]), () => queueBox(el, v, tx, act, width)),
155 part('history', JSON.stringify([v.history.length, v.history[0]?.doneAt ?? 0, v.historyOpen, v.historyAll, v.todayStart]), () =>
156 historyBox(el, v, tx, act),
157 ),
158 )
159 return el.Box({ flexDirection: 'column', rowGap: 1, children })
160}
161
162function statusBox(el: El, v: View, tx: Strings, width: number): RenderNode {
163 const { Box, Text } = el
164 const s = v.status
165 const color = s.tone === 'dim' ? undefined : ORANGE
166 const rows: RenderNode[] = [
167 Text({ wrap: 'wrap', bold: s.tone === 'strong', color, dimColor: s.tone === 'dim', children: [`${s.icon} ${s.text}`] }),
168 ]
169 const p = v.progress
170 if (p && p.total > 0) {
171 rows.push(
172 Text({
173 wrap: 'truncate-end',
174 children: [Text({ color: ORANGE, children: [bar(p.done, p.total, Math.min(24, width - 18))] }), Text({ dimColor: true, children: [` ${tx.progress(p.done, p.total)}`] })],
175 }),
176 )
177 }
178 return Box({ flexDirection: 'column', paddingX: 1, children: rows })
179}
180
181function nowBox(el: El, now: NowView, tx: Strings, width: number): RenderNode {
182 const { Box, Text } = el
183 const rows: RenderNode[] = []
184 const label = now.kind === 'todo' ? `◆ To-do ${now.index}/${now.total}` : tx.fromChat
185 rows.push(
186 Box({
187 flexDirection: 'row',
188 justifyContent: 'space-between',
189 children: [Text({ bold: true, color: ORANGE, children: [label] }), Text({ dimColor: true, children: [clock(now.elapsedMs)] })],
190 }),
191 )
192 rows.push(Text({ bold: true, wrap: 'wrap', children: [clamp(now.text, width - 4)] }))
193 if (now.activity) rows.push(Text({ color: GREY, wrap: 'truncate-end', children: [`↳ ${now.activity}`] }))
194 if (now.waiting) rows.push(Text({ color: YELLOW, wrap: 'truncate-end', children: [now.waiting] }))
195 if (now.checking) rows.push(Text({ color: GREY, wrap: 'truncate-end', children: [tx.checkingLine] }))
196 if (now.plan.length > 0) {
197 const done = now.plan.filter((s) => s.status === 'completed').length
198 rows.push(
199 Box({
200 flexDirection: 'row',
201 justifyContent: 'space-between',
202 marginTop: 1,
203 children: [
204 Text({ children: [Text({ color: ORANGE, children: [bar(done, now.plan.length, Math.min(20, width - 16))] }), ` ${done}/${now.plan.length}`] }),
205 Text({ dimColor: true, children: [tx.plan] }),
206 ],
207 }),
208 )
209 for (const s of now.plan) {
210 const mark = s.status === 'completed' ? '✓' : s.status === 'in_progress' ? '▶' : '○'
211 rows.push(
212 Text({
213 wrap: 'truncate-end',
214 dimColor: s.status === 'completed',
215 strikethrough: s.status === 'completed',
216 bold: s.status === 'in_progress',
217 children: [`${mark} ${s.text}`],
218 }),
219 )
220 }
221 }
222 return Box({
223 flexDirection: 'column',
224 borderStyle: 'round',
225 borderColor: ORANGE,
226 paddingX: 1,
227 children: [Text({ dimColor: true, children: [tx.now] }), ...rows],
228 })
229}
230
231function noticeBox(el: El, notice: Notice, tx: Strings, act: Actions): RenderNode {
232 const { Box, Text, Button } = el
233 const text: RenderNode[] = [Text({ bold: true, color: ORANGE, children: [notice.kind === 'background' ? tx.noticeWaitTitle : tx.noticeTitle] }), Text({ wrap: 'wrap', children: [notice.reason] })]
234 if (notice.kind === 'question') text.push(Text({ dimColor: true, wrap: 'wrap', children: [tx.answerInChat] }))
235 // Die Empfehlung vorn und hervorgehoben; Überspringen klein und mit Abstand, damit es nicht aus Versehen trifft
236 const buttons: RenderNode[] =
237 notice.kind === 'background'
238 ? [
239 Button({ key: 'stop-waiting', label: tx.stopWaiting, variant: 'primary', onPress: () => act.stopWaiting() }),
240 Button({ key: 'keep-waiting', label: tx.keepWaiting, onPress: () => act.keepWaiting() }),
241 ]
242 : notice.kind === 'hold'
243 ? [Button({ key: 'resume', label: tx.resume, variant: 'primary', onPress: () => act.resume() })]
244 : [
245 ...(notice.noResume ? [] : [Button({ key: 'resume', label: tx.resume, variant: 'primary', onPress: () => act.resume() })]),
246 Button({ key: 'mark-done', label: tx.markDone, ...(notice.noResume ? { variant: 'primary' as const } : {}), onPress: () => act.markDone() }),
247 Box({ marginLeft: GAP, children: [Button({ key: 'skip', label: tx.skip, plain: true, onPress: () => act.skip() })] }),
248 ]
249 return Box({
250 flexDirection: 'column',
251 borderStyle: 'round',
252 borderColor: ORANGE,
253 paddingX: 1,
254 rowGap: 1,
255 children: [
256 Box({ flexDirection: 'column', children: text }),
257 Box({ flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', columnGap: GAP, rowGap: 1, children: buttons }),
258 ],
259 })
260}
261
262function queueBox(el: El, v: View, tx: Strings, act: Actions, width: number): RenderNode {
263 const { Box, Text, Button, Input } = el
264 const rows: RenderNode[] = [
265 // Kopfzeile: Titel und Zahl links, Start/Pause rechts
266 Box({
267 flexDirection: 'row',
268 justifyContent: 'space-between',
269 alignItems: 'center',
270 children: [
271 Text({ children: [Text({ bold: true, color: ORANGE, children: [tx.next] }), Text({ dimColor: true, children: [` · ${v.queue.length === 0 ? tx.nothingOpen : tx.nOpen(v.queue.length)}`] })] }),
272 Button({
273 key: 'pause',
274 label: v.control === 'pause' ? tx.pause : v.control === 'start' ? tx.start : tx.go,
275 plain: true,
276 onPress: () => act.control(),
277 }),
278 ],
279 }),
280 ]
281 // Eingabefeld oben: die häufigste Aktion. Mobil gibt es keins (types: Elements['mobile']); dort bleibt /todo <Aufgabe>
282 if (Input) {
283 rows.push(
284 Box({
285 marginTop: 1,
286 marginBottom: v.queue.length > 0 ? 1 : 0,
287 children: [
288 Input({
289 key: v.inputKey,
290 label: '+',
291 placeholder: tx.placeholder,
292 submitLabel: tx.submit,
293 // /todo öffnet mit focus: true; der Fokus soll im Eingabefeld stehen (types: InputProps.autoFocus)
294 autoFocus: true,
295 // Der Entwurf überlebt ein Neuzeichnen (onInput merkt ihn), nach dem Einreihen ist er leer
296 value: v.draft,
297 onInput: (value: string) => act.draft(value),
298 onSubmit: (value: string) => act.add(value),
299 }),
300 ],
301 }),
302 )
303 }
304 v.queue.forEach((q, i) => {
305 const first = i === 0
306 const last = i === v.queue.length - 1
307 rows.push(
308 Box({
309 flexDirection: 'row',
310 alignItems: 'flex-start',
311 columnGap: 1,
312 children: [
313 Text({ color: GREY, children: [`${i + 1}.`] }),
314 // Rahmen, Innenabstand, Nummer und die drei Knöpfe abgezogen
315 Box({
316 flexGrow: 1,
317 flexShrink: 1,
318 flexDirection: 'column',
319 children: [
320 Text({ wrap: 'wrap', children: [clamp(q.text, width - 18)] }),
321 ...(q.skipped ? [Text({ dimColor: true, children: [tx.skippedMark] })] : []),
322 ],
323 }),
324 Box({
325 flexDirection: 'row',
326 flexShrink: 0,
327 columnGap: GAP,
328 children: [
329 // Auch am Rand gezeichnet (ohne Wirkung), damit die Spalten untereinander stehen
330 Button({ key: `up-${q.id}`, label: '↑', plain: true, onPress: () => (first ? undefined : act.up(q.id)) }),
331 Button({ key: `down-${q.id}`, label: '↓', plain: true, onPress: () => (last ? undefined : act.down(q.id)) }),
332 Button({ key: `remove-${q.id}`, label: '✕', plain: true, onPress: () => act.remove(q.id) }),
333 ],
334 }),
335 ],
336 }),
337 )
338 })
339 return Box({ flexDirection: 'column', borderStyle: 'round', borderColor: EMPTY, paddingX: 1, children: rows })
340}
341
342function historyBox(el: El, v: View, tx: Strings, act: Actions): RenderNode {
343 const { Box, Text, Button } = el
344 const rows: RenderNode[] = [
345 Box({
346 flexDirection: 'row',
347 justifyContent: 'space-between',
348 children: [
349 Button({ key: 'history', label: `${v.historyOpen ? '▾' : '▸'} ${tx.history}`, plain: true, onPress: () => act.toggleHistory() }),
350 Text({ dimColor: true, children: [v.history.length === 0 ? tx.historyEmpty : tx.nDone(v.history.length)] }),
351 ],
352 }),
353 ]
354 if (v.historyOpen) {
355 const shown = v.historyAll ? v.history : v.history.slice(0, HISTORY_SHORT)
356 let day = ''
357 for (const h of shown) {
358 const d = dayLabel(h.doneAt, v.todayStart, v.lang, tx)
359 if (d !== day) {
360 day = d
361 rows.push(Text({ color: GREY, bold: true, children: [d] }))
362 }
363 const meta = [duration(h.durationMs), h.how === 'manual' ? tx.byHand : '', h.result].filter(Boolean).join(' · ')
364 rows.push(
365 Box({
366 flexDirection: 'column',
367 children: [
368 Text({ dimColor: true, strikethrough: true, wrap: 'truncate-end', children: [`✓ ${h.text}`] }),
369 Text({ dimColor: true, wrap: 'truncate-end', children: [` ${meta}`] }),
370 ],
371 }),
372 )
373 }
374 if (v.history.length > HISTORY_SHORT) {
375 rows.push(
376 Button({ key: 'history-all', label: v.historyAll ? tx.showLess : tx.showAll(v.history.length), plain: true, onPress: () => act.toggleAll() }),
377 )
378 }
379 }
380 return Box({ flexDirection: 'column', paddingX: 1, children: rows })
381}
382types/index.d.ts 41 lines1// $.state-Werte von worklist. Jeder Wert muss hier deklariert sein (docs/raw/en/interface.md:717-769); der Vertrag muss
2// in sich geschlossen sein (kein import). Gleiche Form wie `Runtime` in hooks/model.ts.
3// `rt` hält den Laufzustand über einen Hot Reload (/reload-plugins) hinweg; /clear und ein neuer Prozess setzen ihn zurück.
4
5declare module 'claude-code' {
6 interface PluginState {
7 worklist: {
8 rt: {
9 sid: string
10 busy: boolean
11 turnSeq: number
12 turn: { startedAt: number; todoId: string | null; text: string; fromFynn: boolean }
13 // ab 0.4.0: 'fresh' nach Neustart, Resume oder Chatwechsel (läuft, sendet erst nach Fynns Zutun)
14 state: 'idle' | 'waiting' | 'checking' | 'ask' | 'blocked' | 'unclear' | 'fresh'
15 stateReason: string
16 // ab 0.4.0: kind 'background' für „Nicht mehr warten?“
17 notice: { reason: string; todoId: string | null; kind?: 'background' } | null
18 autoRun: number
19 strikes: { id: string; n: number }
20 hold: boolean
21 plan: { id: string; text: string; status: 'pending' | 'in_progress' | 'completed' }[]
22 sessionDone: number
23 expectOwn: string | null
24 // ab 0.3.0: Befehls-To-do, auf dessen Turn gewartet wird, und die gesendeten To-dos des Chats (Prüfsumme statt Text)
25 expectCmd: string | null
26 // ab 0.4.0: c = Fortsetzung statt To-do-Text
27 sent: { id: string; h: string; n: number; m: number; c?: boolean }[]
28 lastResult: string
29 // ab 0.4.0: Warten mit Grenze (Kurzform, Beginn, Hinweis gezeigt, ausgenommene Aufgaben) und letztes Turn-Ende
30 stateShort: string
31 waitSince: number
32 waitNoticed: boolean
33 ignoreBg: string[]
34 turnEndAt: number
35 }
36 // Zähler zum Neuzeichnen: nur die Seitenleiste liest ihn, also zeichnet ein Schreiben nur sie neu
37 paint: number
38 }
39 }
40}
41