SLOPSHOPPER

worklist

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…

newpanerowsguardcommandtoast
★ 3v0.7.2MITupdated 2026-10-09FynnXland/fynn-mods/mods/worklist
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · worklist
│ ┃ To-dos ✕ › fix the failing auth test and add an audit log call │ ┃ ◌ checking whether Claude is clearly done │ ┃ ● worklist: worklist turn.start: TypeError: undefined is not an objec │ ┃ ╭──────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ NEXT · nothing open ⏸ Pause │ ⎿ Read 6 lines │ ┃ │ │ ⏺ Update(src/auth.ts) │ ┃ │ +: new to-do, Enter ⏎ queue │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰──────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ▸ HISTORY empty │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /todo │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · To-dos
◌ checking whether Claude is clearly done ╭──────────────────────────────────────────────────────────╮ │ NEXT · nothing open ⏸ Pause │ │ │ │ +: new to-do, Enter ⏎ queue │ ╰──────────────────────────────────────────────────────────╯ ▸ HISTORY empty
README

worklist

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.

Usage

InputEffect
/todo <task>queue a task (everything after /todo is the task); confirmed by a toast only, so Claude doesn't read it
/todo · /todosopen the sidebar
/todos pause · /todos resumepause the list, or continue it (with an open notice, resume acts like Continue)
/todos donemark the running or stopped to-do as done by hand
/todos skipskip the stopped to-do: it moves to the end, marked "skipped", and the next one starts
/todos retrysend the stopped to-do again, in full
/todos cleardelete open to-dos; the history stays
/todos historyshow the history (last 30)
/todos statuscheck state, last decision with reason, Haiku cost
/todos closeclose 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:

  • Status line at the top: one symbol and one sentence on what applies and what happens next, for example ● 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).
  • NOW (only while something runs): what Claude is working on (to-do n/m or "from the chat"), elapsed time, what Claude is doing right now, and Claude's own plan (TaskCreate/TodoWrite) with a progress bar.
  • NOTICE (only when the list stops): the reason in one sentence and the buttons, recommended one first:
  • Continue (highlighted): keeps the order. If Claude asked a question, worklist sends a short continuation for the same to-do ("Continue with this to-do; decide open points sensibly yourself."), shown as 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.
  • Mark as done: checks the to-do off by hand; the next one starts.
  • Skip (small, set apart): the to-do moves to the end, marked "skipped"; the next one starts. If it's the only one, nothing is sent; it starts after your next message or with Start now.
  • With a question, a line above the buttons says: "Just answer in the chat, then this to-do continues."
  • If the same to-do stops twice in a row without your answer (loop guard), Continue is gone; answer in the chat, mark it as done or skip it.
  • NEXT: the input field "new to-do, Enter" at the top, below it the queue with ↑ ↓ ✕, and Pause / Start / Start now at the right of the header.
  • HISTORY (collapsible): project-wide, grouped by day, struck through with duration and a short result (the first sentence of Claude's answer).

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:

  • Desktop app: send /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.
  • Terminal: /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.

When it continues: the "certainly free" check

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.

  1. Interrupted → stop (to-do goes back into the list, notice)
  2. Error, refusal → stop
  3. Background work or scheduled wake-ups (classic.Stop) → wait (see Waiting, with a limit)
  4. A helper (subagent) is still running ($.agent.list()) → wait
  5. Claude's plan has open steps → ask
  6. A question in the last paragraph (? 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) → ask
  7. A problem ("failed", "couldn't", "konnte nicht" …), the last tool ended with an error, or an empty answer → ask
  8. The last sentence is itself "Done.", "Done, …", "All done." (or German "Fertig.", "Erledigt.") → continue. "Done" in the middle of a sentence or with a restriction ("Done, except …") doesn't count.
  9. Otherwise Haiku 5.5 (only the end of the answer and the to-do; model claude-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) → ask
  10. Before sending, a 3 s settle time, then check again: no new turn, no new helper, same chat, not paused

When 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.

Waiting, with a limit

  • Helpers and workflows (stages 3 and 4): worklist waits and checks every 10 seconds whether they are still busy ($.agent.list()). Once none is busy, it checks the last turn end again; no new turn is needed.
  • Background shells, monitors, scheduled wake-ups (stage 3): after 2 minutes without a new turn, a notice asks once per waiting phase: "Still running in the background: npm run dev. Stop waiting?" Don't wait (recommended) checks again without these tasks; they stay ignored in this chat (a dev server can keep running). Keep waiting waits on.

Hidden hints

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.

Language

/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.

Stored data

  • Queue per chat in $.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 per project under history:<project-folder>, at most 300 entries.
  • Haiku cost per project under cost:<project-folder>.
  • Sent to-dos per chat under 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.
  • Restart or --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).

Configuration

/config → worklist's plugin options:

KeyDefaultMeaning
languageenLanguage of the texts: en or de.
haikuonAsk Haiku 5.5 in unclear cases (measured at about 0.01 US cents per case). Off: the list stops in those cases.
doneLineonHidden 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.
settleSeconds3Settle time before sending the next to-do (1–30 s), after which worklist checks again that Claude is free.
maxAutoRun15Maximum to-dos in a row without your intervention (1–100); then the list stops.

Rights

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).
  • Apart from the hidden hints and the display-only changes above, the hooks only observe and pass everything through unchanged. No file system, no processes, no network.

Installation

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

Known limitations

  • /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.
  • Hover flicker in the desktop app over buttons and the input field: not caused by worklist (it persists even without any redraw from worklist), probably caused by frequent redraws from other mods (clawd-buddy).
  • Cursor in the input field: /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.
  • After /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.
  • Waiting: a scheduled wake-up (/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.
  • Commands as to-dos: if a command starts no turn within a few seconds, worklist counts it as done once it has run. The command list Claude Code offers to mods is incomplete (/cost is missing but runs), so whether a command exists is decided by running it.
  • Haiku 5.5 with Claude Code 2.1.291: that version doesn't know the model id yet and logs [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.
  • Model allowlist or third-party provider: stage 9 calls the fixed id 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.
  • The rules of stages 6–8 are text patterns; unusual wording ends up with Haiku or leads to "ask", never to an unchecked continue.
  • The elapsed-time clock only ticks while the sidebar is visible and Claude is working.
  • Storage: all of worklist's data shares 4 MiB. The history is limited to 300 entries per project (text cut to 200 characters); each project folder and worktree has its own. Empty lists are deleted; unfinished lists of old chats remain, and so do the sent-to-do checksums of every chat that sent a to-do (at most 50 small entries per chat). If saving fails, a toast appears and the list keeps running in memory only.
  • Project folder is read at startup; after a worktree switch or /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.
  • After /resume or /branch worklist detects the other chat on the next redraw, command, button press or turn (via the session ID), not immediately.
  • Changing 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.

Credits

Ideas and workflows are modeled on arbeitsliste from nikisge/niklas-mods (no license; no code taken).

Source 8 files
hooks/register.ts 1825 lines
1// 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 lines
1// 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}
303
hooks/help.ts 205 lines
1// 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}
205
hooks/helpdata.ts 70 lines
1// 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}
70
hooks/i18n.ts 509 lines
1// 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 }
509
hooks/model.ts 338 lines
1// 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}
338
hooks/view.ts 382 lines
1// 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}
382
types/index.d.ts 41 lines
1// $.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