SLOPSHOPPER

junkd

One plugin for handoff, orch, tldr, ctx-limit, ctx-tokens, the status line and the tools pane, plus the /junkd frame. /junkd:handoff saves a session handoff…

newpanebandspinnerguardprompt
v0.11.10no licenseupdated 2026-10-25Krab00/junk-drawer/plugins/junkd
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · junkd
│ ┃ ahandoff ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Auto 2: Handoffs 3: Settings q: × │ ┃ ────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ ────────────────────────────────────────── │ ┃ ────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ────────────────────────────────────────── │ ┃ ────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Auto handoff off 0 handoffs a: Arm │ ┃ │ ┃ Context │ ┃ ██████████████████████████████████ 97k / 3 │ ┃ 97k of 200k tokens · handoff at 300k · … │ ┃ checked every 5 tool calls · next check │ ┃ in 5 │ ┃ │ ┃ Loop │ ┃ State ○ idle, off │ ┃ Since 2026-10-26T17:17:48.331Z (0s a │ ┃ Retries clear 0/3 · resume 0/3 │ ┃ Written none ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ 🪃 off junkd

Draws

Pane · ahandoff
1: Auto 2: Handoffs 3: Settings q: × ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ──────────────────────────────────────────────────── Auto handoff off 0 handoffs a: Arm Context ██████████████████████████████████ 97k / 300k 97k of 200k tokens · handoff at 300k · 203k left checked every 5 tool calls · next check in 5 Loop State ○ idle, off Since 2026-10-26T17:17:48.331Z (0s ago) Retries clear 0/3 · resume 0/3 Written none Pending none Threshold src=settings handoff_threshold=300 tokens=3000 Measured 17:17 session.measure percent=49 used=97400 li Log /Users/dev/.claude/junkd/handoff/preview-sessi Continue with Continue after the handoff p: Set prompt sent after each /junkd:handon default ┌────────────────────────────────────────┐ │ Continue after the handoff │ └────────────────────────────────────────┘ 5h limit 31% used · resumes 2 min after reset Last handoff — n: Handoff now This session no handoffs yet off · a arms the loop ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ──────────────────────────────────────────────────── keys tab move a arm n handoff now r resume p prompt
Pane · orch
1: Settings q: × ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ──────────────────────────────────────────────────── Orchestrator the main-loop guard, agents and /junkd:orch Orchestrator: enabled on a: off the guard and the Main-loop guard fable always off hold the m Allow Fable subagents off b: on an explicit Fable c: Implementer context cap (k tokens) reuse an implementer ┌──────────────────────────────────────────────────────┐ │ 400 │ └──────────────────────────────────────────────────────┘ UI review on d: off ui-reviewer on we Inject orchestrator rules on e: off ORCHESTRATOR.md i f: Fix rounds fix rounds per task; --rounds overrides · 1– ┌──────────────────────────────────────────────────────┐ │ 3 │ └──────────────────────────────────────────────────────┘ CLAUDE.md pointer block off g: on the CLAUDE.md orc removes it h: Dashboard: write every N seconds status.js again every 5–600, clamped ┌──────────────────────────────────────────────────────┐ │ 30 │ └──────────────────────────────────────────────────────┘ i: Dashboard: timeline window (hours) the hours the dashbo ┌──────────────────────────────────────────────────────┐ │ 24 │ └──────────────────────────────────────────────────────┘ Agent watchdog on j: off stalls, deaths an Watchdog: stop idle leftovers on k: off stop a finished a l: Watchdog: idle minutes before a stop idle minutes of a ┌──────────────────────────────────────────────────────┐ │ 10 │ └──────────────────────────────────────────────────────┘ m: Watchdog: stall after N minutes minutes without a tool ┌──────────────────────────────────────────────────────┐ │ 15 │ └──────────────────────────────────────────────────────┘ Lessons loop on n: off lessons by stack waits on pending dashboard: open ▸ the task board and timeline in the brows /junkd:dashboard enter in a field saves it · also in /config, under junkd … ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ──────────────────────────────────────────────────── keys tab move enter save field a-n setting q close
Pane · statusline
statusline q: × ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ──────────────────────────────────────────────────── Status line wired off a: on statusLine in settings.json points at j config: pluginConfigs junkd@inline Segments ctx on b: offcontext free / used branch on c: offthe git branch worktree on d: offthe linked worktree, only inside one model on e: offthe model's name effort on f: offthe effort level limits on g: off5h / week rate-limit use preview (nothing to draw) also in /config, under junkd (statusline_…) ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ──────────────────────────────────────────────────── keys a-g switch tab move q close
Pane · junkd-tools
r: t: 1: 1 Agents 2: Tools 3: MCP 4: Skills q: × ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ──────────────────────────────────────────────────── reading… ● running ◐ waiting ○ done · agents of this session only ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ──────────────────────────────────────────────────── keys ↑↓ select enter fold a all r refresh t transcript
Pane · junkd
✚ junkd junk-drawer q: × ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ──────────────────────────────────────────────────── Plugins user scope, ~/.claude/settings.json Settings the main settings of core, handoff, orch, tldr, ctx reading… Commands slash commands of the enabled plugins No commands. ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ──────────────────────────────────────────────────── keys tab move q close
Pane · junkd-lessons
r: j: k: lessons pending of the three stores · this repo… q: × ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ──────────────────────────────────────────────────── id scope stack to from text no pending lesson ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────── ──────────────────────────────────────────────────── keys j/k select p promote d drop u up a store r reload
Prompt hint
⟨Claude Code's own drawing⟩ 🪃 off junkd
README

junkd

One plugin for six tools and the frame over them. Every command is a skill of junkd, so on Claude Code each is /junkd:<name>:

CommandDoes
/junkd:handoffSave a handoff of the session to disk and print its short id (panel, auto [prompt], off drive the auto loop on Claude Code)
/junkd:handon [id]Resume a handoff in a fresh session (the newest without an id)
`/junkd:ahandoff [prompt] \off \panel \status`Arm the auto handoff loop: at a context threshold handoff, /clear, /junkd:handon, continue; resume after the 5h rate limit (Claude Code only)
/junkd:orch …The orchestration loop: tasks, implementer and verifier subagents, evidence gates, draft PRs (panel opens its settings pane)
/junkd:dashboard [close]Open the orch dashboard, a local HTML page with the task board, the agents' timeline, the recent events and the lessons; close stops its writes (/junkd:orch dashboard too)
/junkd:orch-initWrite the project's .orch/config.md once, before the first /junkd:orch
/junkd:direction [<spec>…] [--since <ref>] [--head <ref>]Score the code against the spec files .orch/config.md names; one decision per gap: change code or update spec
`/junkd:tldr [N \on \off \status]`Summarize the last substantive reply in at most N sentences, or keep every reply terse
`/junkd:ctx-limit [<tokens> \on \off \status \action <m> \cmd <shell>]`A context-size limit a per-prompt hook enforces: block, warn, or run a command
/junkd:ctx-tokensHow full the context is, from the session transcript
`/junkd:statusline [on \off \status]`The colored status line in settings.json, a switch per segment; no argument toggles its pane (Claude Code only)
`/junkd:tools [agents \tools \mcp \skills]`Open or close the tools pane: this session's agents over the repo's worktrees, and the tools, MCP servers and skills it used (Claude Code only)
`/junkd:lessons [list \promote <id> \drop <id> \up <id>]`Show the pending orch lessons of the three stores (local, repo, global) to promote, drop or move up a store; an orch run does not end while one is pending (Claude Code tab)
/junkd:explain <topic>Explain a topic from the repo in the format that fits: a slider page for parameters, a Mermaid diagram for structure or flow, STE text otherwise
/junkd:junkd (/junkd where the host resolves it)Open or close the junkd set of panes (Claude Code only)

On Codex the same skills answer as $handoff, $orch, $tldr, $ctx-limit… (the model sees them as junkd:<name>), on Kimi Code as /skill:<name>, on OpenCode through the skill tool or the /<name> wrappers bin/opencode-commands.sh generates. The hooks module (Claude Code only) adds the auto handoff loop, the orch main-loop guard, the panes, the status line's wiring and $.junkd for other mods; the skills, the bin/ helpers and the shell hooks work on every host.

handoff, orch, tldr, ctx-limit, ctx-tokens and statusline were six plugins before; see Moving from the handoff, orch, tldr, ctx-limit, ctx-tokens and statusline plugins.

Install

/plugin marketplace add Krab00/junk-drawer
/plugin install junkd@junk-drawer

Codex: codex plugin add junkd@junk-drawer; after an update of a local marketplace, re-run that command (codex plugin marketplace upgrade refreshes only a Git marketplace). Kimi Code installs from a clone, in the TUI: /plugins marketplace /abs/path/to/junk-drawer/kimi-marketplace.json, choose junkd in the picker, then "Trust and install" (a GitHub URL and a bare /plugins install junkd do not work). OpenCode: npx skills add Krab00/junk-drawer -a opencode (or a local path), then bin/opencode-commands.sh for the /<name> commands. The root README has the details per host.

Develop from a checkout

Install junkd from a local clone, as a directory marketplace, so the hooks module, the shell hooks and the junkd:<role> agent types run from your working tree:

/plugin marketplace add /abs/path/to/junk-drawer
/plugin install junkd@junk-drawer
/reload-plugins

A directory-source plugin loads in place from /abs/path/to/junk-drawer/plugins/junkd: an edit there takes effect at the next session start or /reload-plugins, and switching the checkout's branch changes what runs. Where junk-drawer was added from GitHub before, the marketplace add re-points it at the folder (the plugins already installed from it follow), and claude plugin update junkd@junk-drawer records the checkout's version. The --plugin-dir flag is not needed. A --plugin-dir session next to an installed copy keeps its rows under junkd@inline, the installed one under junkd@junk-drawer: the status line reads the entry its command names (see statusline); the pane warns when both exist. Both are named junkd, and a session runs one mod of that name: with --plugin-dir it is the folder you pass (on 2.1.305, /plugin lists 1 mod active · junkd with the marketplace copy installed and enabled), so an old marketplace copy such as 0.4.0 does not draw a status line or write the line's rows next to it. Update or disable the installed copy (/plugin disable junkd@junk-drawer) all the same when you work from --plugin-dir: its pluginConfigs entry stays in settings.json and is the first one a script without --config would read.

The install is live when:

  • the ✚ junkd segment shows under the prompt;
  • junkd:implementer (and the other junkd:<role> types) is in the agent list;
  • ~/.claude/junkd/orch/_agents/<session>/ fills after a subagent of the session stops (the SubagentStop record the main-loop guard reads).

bin/junkd-doctor checks the same from a shell (Claude Code only; on Codex, Kimi Code and OpenCode it prints "Claude Code only" and exits 0). One PASS/FAIL line each: junkd enabled in settings.json, its installed_plugins.json entry, the junk-drawer marketplace a directory source at an existing checkout, the checkout's plugin-root bin, agents and scripts links resolving, and this session's _agents folder (INFO, not a FAIL, when no subagent stopped yet). It reads ${CLAUDE_CONFIG_DIR:-$HOME/.claude}, writes nothing and exits 1 on any FAIL. Run it through the Bash tool of a session, so it sees CLAUDE_CODE_SESSION_ID.

Hosts and data roots

The helpers (handoff-path, handoff-load, tldr-flag, tldr-hook, ctx-limit, ctx-limit-hook, ctx-tokens) detect the host they run on through skills/handoff/bin/host-lib, and each host keeps its own data (ctx-limit's files in a junkd/ folder beside the terse flag: ctx-limit and ctx-tokens):

HostDetected byHandoffsTerse flag
Claude CodeCLAUDECODE, CLAUDE_PLUGIN_ROOT, else CLAUDE_CONFIG_DIR${CLAUDE_CONFIG_DIR:-$HOME/.claude}/handoffs/${CLAUDE_CONFIG_DIR:-$HOME/.claude}/junkd/tldr-on
CodexCODEX_THREAD_ID, a PLUGIN_ROOT under .codex or $CODEX_HOME, else CODEX_HOME${CODEX_HOME:-$HOME/.codex}/junk-drawer/handoffs/${CODEX_HOME:-$HOME/.codex}/junk-drawer/tldr-on
Kimi CodeKIMI_PLUGIN_ROOT, the helper under plugins/managed/, else KIMI_CODE_HOME${KIMI_CODE_HOME:-$HOME/.kimi-code}/junk-drawer/handoffs/${KIMI_CODE_HOME:-$HOME/.kimi-code}/junk-drawer/tldr-on
OpenCodeOPENCODE, OPENCODE_PID, the helper under .agents/skills/ or .opencode/${XDG_DATA_HOME:-$HOME/.local/share}/opencode/junk-drawer/handoffs/${XDG_DATA_HOME:-$HOME/.local/share}/opencode/junk-drawer/tldr-on

JUNK_DRAWER_RUNTIME=claude|codex|kimi|opencode overrides the detection. The config-dir variables (CLAUDE_CONFIG_DIR, CODEX_HOME, KIMI_CODE_HOME) count only after every host's own signal, so one a shell profile exports never pulls another host's helper into it. With no signal at all a helper exits 2 with one line naming JUNK_DRAWER_RUNTIME and writes nothing: it never falls back to ~/.claude (ctx-limit's hook exits 0 instead, so the guard never blocks a prompt on a guess).

Install quirks: Codex drops every symlink (the plugin-root bin/, agents/, scripts/ and orch-init's sibling links), so the skills run their own ./bin; its hooks (terse mode, ctx-limit) are .codex-plugin/hooks.json (Codex refuses the modules key of hooks/hooks.json) and runs only after you trust it when a session asks you to review the plugin's hooks. Kimi Code keeps the symlinks of a local-path install rewritten to absolute paths into your checkout (the plugin-root bin/ and scripts/, orch-init's bin/ and commands/): moving the checkout breaks them, and switching its branch changes what they run. Its manifest names no agents ("agents": []), so agents/ is not read as agent definitions. Skills in ~/.agents/skills shadow the plugin's on Kimi and on Codex (a stale npx skills add -g copy wins).

The /junkd frame (Claude Code only)

/junkd:junkd (/junkd where the host resolves a bare name to the skill), or a click on the ✚ junkd segment under the prompt, opens a set of panes as tabs and closes them together:

  1. junkd — focused, the frame below.
  2. handoff — handoff's pane (below).
  3. orch — orch's settings pane (below).
  4. statusline — the status line's pane (below).
  5. tools — the tools pane (below), only while tools_enabled is on.
  6. lessons — the pending orch lessons (below), only while orch_enabled and orch_lessons are on.

All six are panes of the one hooks module, so the set opens them directly, marked as the set's. A feature pane already open on its own closes first and opens again behind junkd's tab, so its tab never comes first, and from then on it is the set's. The set closes as one: q or × in any of its panes (the engine draws one on each pane and gives the module no single control for the set, so the module answers a close of any member by closing every member), a second /junkd:junkd, or closing a tab by hand closes junkd and every feature pane that is still the set's, in one action, with no leftover and no second press. A feature pane that is not the set's closes alone, and the set stays: one you closed and opened again on your own (/junkd:handoff panel, /junkd:orch panel, /junkd:ahandoff panel, /junkd:statusline, /junkd:tools, /junkd:lessons, the 🪃 or 📒 click), or one a settings ▸ press left open (that press closes junkd and the other panes, so the pane left is the only one up). Its own × closes just that pane. The same command that opened a member (/junkd:orch panel, say) still toggles that pane alone.

Where the set sits depends on the layout. Only in the fullscreen layout ("tui": "fullscreen" in settings.json), from 110 terminal columns, it docks beside the transcript; narrower, and always on the main screen, it sits above the prompt. The feature panes open after junkd's, and Claude Code counts only the first open of a press as yours: until you have opened that pane by hand once (/junkd:handoff panel, /junkd:orch panel), its tab may wait undrawn below 144 columns. After that, 110 columns are enough.

The header reads ✚ junkd junk-drawer <sha>: the short sha of the marketplace checkout (none where it is not a git checkout). The junkd pane has three sections, their titles in junk-drawer's orange (the theme's accent):

  • Plugins — every plugin of the marketplace (.claude-plugin/marketplace.json: junk-drawer, junkd), on or off at user scope as enabledPlugins in ~/.claude/settings.json ($CLAUDE_CONFIG_DIR/settings.json) says; the title names the file it writes. A plugin on in this project only is marked. Keys 1–9 flip one; the pane then says to run /reload-plugins. handoff, orch, tldr, ctx-limit and statusline are no plugins of their own anymore: each group's enabled row in Settings is their on/off.
  • Settings — the groups core, handoff, orch, tldr, ctx, statusline and tools, in that order, each with its main rows and enabled first, with their current values. A letter flips a boolean; a number is typed and saved with enter, a choice (orch_guard, core_output_style, ctx_action) is picked with tab and enter. The group's own rules validate what it gets, and a refusal shows in the note line. Each row: the field, its title, the control on the right (a switch as an on/off pill and its letter, a choice as its options with the current one inverse, a number as a field). settings ▸ on handoff, orch, statusline and tools opens that pane (handoff on its Settings tab, tools on its Agents tab), closes junkd and the set's other feature panes (while they are still the set's), then asks again for the keys, so the feature pane ends alone with them; it is yours from then on. core, tldr and ctx have no pane and no button.
  • Commands — the enabled plugins' slash commands with their one-line descriptions, as the engine lists them ($.command.list(), filtered to the plugins on in this session), junkd's /junkd:* among them.

Keys

KeyDoes
1–9Flip the n-th plugin on or off at user scope, then /reload-plugins
a…Flip the n-th boolean setting (letters follow the drawn order; q is skipped)
tabMove between the number fields and the choice buttons
enterSave the number field, or pick the focused choice
settings ▸Open that group's pane; junkd and the set's other pane close so it can take the keys
qClose the set (/junkd or the ✚ junkd segment toggles it; closing junkd's tab closes it too)

Settings

Every setting is a userConfig row of junkd, keyed <group>_<field>, and a /config row junkd.<group>_<field> (Claude Code takes only identifier keys in a manifest's userConfig, so the group and the field are joined by _). Seven groups, each feature group with its enabled row first:

GroupRowsDetails
corecore_output_style, core_cache_check_sessionsOutput style, prompt-cache check
handoffhandoff_enabled, handoff_threshold, handoff_checkEvery, handoff_autoOn, handoff_resumeAfterLimit, handoff_staleDays, handoff_segmenthandoff settings
orchorch_enabled, orch_guard, orch_allow_fable_agents, orch_implementer_ctx_k, orch_ui_review, orch_inject_rules, orch_rounds, orch_claude_md, orch_dashboard_refresh, orch_dashboard_window_h, orch_watchdog, orch_idle_stop, orch_idle_stop_minutes, orch_stall_minutes, orch_lessonsorch settings, dashboard, watchdog settings, lessons settings
tldrtldr_enabled, tldr_terse, tldr_sentencestldr settings
ctxctx_enabled, ctx_threshold, ctx_action, ctx_cmdctx-limit settings
statuslinestatusline_enabled, statusline_ctx, statusline_branch, statusline_worktree, statusline_model, statusline_effort, statusline_limitsstatusline settings
toolstools_enabled, tools_refresh, tools_agents_onlytools settings

The enabled rows: handoff_enabled off is no auto handoff loop and no 🪃 segment (/junkd:handoff, /junkd:handon and the pane still work); orch_enabled off is no main-loop guard and no rule sheet at session start (/junkd:orch and its pane still work); tldr_enabled off keeps the per-turn terse hook silent (/junkd:tldr N still summarizes); ctx_enabled off (the default) is no context limit; statusline_enabled off takes junkd's line out of settings.json; tools_enabled off closes the tools pane, takes its tab out of the set and counts nothing. Scope: user-global; project/session overrides: planned.

A shell hook runs before the hooks module at a session start and reads no plugin setting, so each shell hook reads its row from settings.json itself (pluginConfigs, the first entry whose key starts with junkd@, its .options.<row>), and only where that names no value a flag file the module mirrors the row into decides. The flag files live under ~/.claude/junkd/ ($CLAUDE_CONFIG_DIR/junkd/): output-style-off, tldr-on, tldr-off, orch-off, orch-rules-off, ctx-limit's ctx-limit-threshold, ctx-limit-action and ctx-limit-cmd, the status line's copy statusline.sh, and the setup stamps setup-<version>.stamp.

Moving from the handoff, orch, tldr, ctx-limit, ctx-tokens and statusline plugins

handoff, orch, tldr, ctx-limit, ctx-tokens and statusline were separate plugins of this marketplace; they are now part of junkd, which no longer depends on them. Uninstall the old ones (/plugin uninstall handoff@junk-drawer, orch@junk-drawer, tldr@junk-drawer, ctx-limit@junk-drawer, ctx-tokens@junk-drawer, statusline@junk-drawer, and the same plugins on Codex and Kimi Code) and install junkd. Nothing is copied over: set the values you had changed again (/config, the junkd pane, a feature pane), or rename the keys in ~/.claude/settings.json by hand.

BeforeNow
/handoff, /handoff:handoff/junkd:handoff
/handon, /handoff:handon/junkd:handon
/ahandoff/junkd:ahandoff
/orch, /orch:orch/junkd:orch
/orch:init/junkd:orch-init
/tldr, /tldr:tldr/junkd:tldr
agent types orch:<role> (orch:implementer, …)junkd:<role> (junkd:implementer, …)
pluginConfigs["handoff@junk-drawer"].options.<field>: enabled, threshold, checkEvery, autoOn, resumeAfterLimit, staleDays, segmentpluginConfigs["junkd@junk-drawer"].options.handoff_<field>
pluginConfigs["orch@junk-drawer"].options.<field>: guard, allow_fable_agents, implementer_ctx_k, ui_review, inject_rules, roundspluginConfigs["junkd@junk-drawer"].options.orch_<field>
pluginConfigs["tldr@junk-drawer"].options.<field>: terse, sentencespluginConfigs["junkd@junk-drawer"].options.tldr_<field>
pluginConfigs["junkd@junk-drawer"].options.output_stylepluginConfigs["junkd@junk-drawer"].options.core_output_style
/config rows handoff.<field>, orch.<field>, tldr.<field>, junkd.output_stylejunkd.handoff_<field>, junkd.orch_<field>, junkd.tldr_<field>, junkd.core_output_style
—new rows orch_enabled, tldr_enabled (both on by default), orch_claude_md (off by default)
~/.claude/tldr-on~/.claude/junkd/tldr-on (run /junkd:tldr on again; the old file is not read)
~/.claude/orch/rules-off~/.claude/junkd/orch-rules-off (written by the module at the next session start)
$.handoff, $.orch, $.tldr$.junkd (below)
/ctx-limit, /ctx-limit:ctx-limit/junkd:ctx-limit
ctx-tokens (the bare command)/junkd:ctx-tokens; ctx-tokens stays on the Bash tool's PATH while junkd is on
/statusline:init/junkd:statusline on (on by default: the first session start wires it)
~/.claude/ctx-limit-threshold, -action, -cmd~/.claude/junkd/ctx-limit-*: the module moves them once at session start where the new ones are missing
<home>/junk-drawer/ctx-limit-* (Codex, Kimi Code)<home>/junk-drawer/junkd/ctx-limit-*: not moved, set the limit again with the skill
~/.claude/junk-drawer-statusline.sh~/.claude/junkd/statusline.sh: statusline-setup on (the first session start, or /junkd:statusline on) moves the statusLine and removes the old copy
—new rows ctx_* (the guard off by default) and statusline_* (all on by default)

Saved handoffs stay where they were (~/.claude/handoffs) and need nothing. Codex and Kimi Code keep their flags and handoffs under <home>/junk-drawer/.

$.junkd for other mods

The hooks module adds one noun, $.junkd, to the engine's $ (its contract: types/index.d.ts, exported as Junkd; list junkd under dependencies in your plugin.json and the engine lays it beside the API reference). The host builds a noun as one flat object of methods and refuses an object member, so the former $.handoff, $.orch and $.tldr are not nested inside it: their settings are rows of the one schema, their panes are open's pane, and their live reads are handoffStatus and orchGuardState. Every call resolves asynchronously and takes one object:

  • schema() — one entry per row: { key, group, field, type, title, default, main, options? }, in manifest order.
  • config() — every row's current value, by key.
  • get({ key }), set({ key, value, scope? }) — one row; set validates by the group's rules, writes junkd.<key> through $.config, applies it (flag files included) and resolves to the stored value.
  • open({ pane, route? }) — toggle a pane: junkd (the whole set), handoff (route auto, handoffs or settings opens on that tab, or switches to it while the pane is up), orch, tools (route agents, tools, mcp or skills, the same way). Each pane takes its own routes only: open({ pane: 'handoff', route: 'mcp' }) is junkd: no tab mcp. While tools_enabled is off, open({ pane: 'tools' }) rejects with the reason.
  • handoffStatus() — { armed, percent, phase } of the auto handoff loop.
  • orchGuardState() — { active, model }: whether the main-loop guard holds right now, and the model it judged.

A refused call rejects with the reason: junkd: no setting <key>, junkd: scope <s> is not supported yet, junkd: no pane <pane>, junkd: no tab <route>, or the group's own refusal of a value. The per-feature sections below detail each group's part.

Output style (STE)

junkd ships one output style, junkd:STE (output-styles/STE.md): Simplified Technical English, ASD-STE100. Short declarative sentences, one word for one meaning, the active voice, a procedure as a numbered list, no preamble, a two-sentence summary at the end of a turn. Code, logs and paths stay as they are. It is listed by /output-style like any other style once the plugin is on.

A setup step makes it the default for a person who never chose a style, and for nobody else. At session start, in the main session only, scripts/setup.sh looks at ~/.claude/settings.json ($CLAUDE_CONFIG_DIR/settings.json):

  • no outputStyle key → the key is set to junkd:STE (temp file, then mv; the other keys and their order stay) and one line, junkd: outputStyle set to junkd:STE in …, goes into the model's context (a SessionStart hook's stdout; it is not shown on screen). A missing or empty file becomes {"outputStyle": "junkd:STE"}.
  • an outputStyle key with any value → nothing is written, nothing is added to the context.
  • a file that does not parse, or is not a JSON object → nothing is written, nothing is added to the context, and the step looks again next session.

The step runs once per plugin version: a stamp, ~/.claude/junkd/setup-<version>.stamp, is written once the key is there (set or found). Delete the key later and it stays deleted until the next version. A /clear, a compaction and every subagent are skipped. The hook always exits 0.

Keep your own style. Pick it once with /output-style (that writes the key) — the step never touches a key that exists, whatever its value, and /output-style changes it any time, junkd:STE included.

Turn the step off. Set core_output_style to off in /config or in the junkd pane (ste is the default). The shell hook reads the row where Claude Code stores it (pluginConfigs in settings.json, the first junkd@ entry), and the hooks module also mirrors it into the flag file `~/.cla

Source 32 files
hooks/index.tsx 187 lines
1// Plugin junkd's one hooks module: handoff, orch, tldr, ctx, statusline and tools, the core setting and the /junkd
2// frame, each in its own files, composed here. A feature file exports `register<Feature>(on, options)` for the hooks only it has, and plain
3// named functions for the rest (see feature.ts); this file holds what is shared:
4// - `$.junkd`, the noun other mods call (types/index.d.ts): schema answers here; get, set, config, open and
5//   orchGuardState need `$`, which a noun may not capture, so the hooks on junkd.<method> below answer them.
6// - session.start, tool.call, the unfiltered command.run and the PromptHint render: the host takes one hook per
7//   event and filter from a module (a repeat `on` fails the load), and more than one feature needs each of them.
8// - handoff.tsx: /junkd:handoff, /junkd:handon, /junkd:ahandoff, the auto handoff loop, the 🪃 segment, its pane.
9// - orch.tsx: the main-loop guard (tool.call, agent.spawn), /junkd:orch panel and its pane.
10// - tldr.tsx: /junkd:tldr's sentences and the terse flag file bin/tldr-hook reads.
11// - ctx.ts: the ctx rows and /junkd:ctx-limit, mirrored into the files bin/ctx-limit-hook reads.
12// - statusline.tsx: the statusline rows, /junkd:statusline, its pane, and bin/statusline-setup on|off.
13// - tools-pane.tsx (with tools.ts and tools-views.tsx): /junkd:tools and its pane, this session's agents over the
14//   repo's worktrees and the tools, MCP servers and skills it used (counted by tool.call and command.run here).
15// - core.ts: the core rows: output_style (and the flag file scripts/setup.sh reads), cache_check_sessions.
16// - cache.tsx: the prompt-cache check at session start (scripts/cache-check.sh) and its band above the prompt.
17// - panel.tsx (with set.ts): /junkd, ✚ junkd and the set of tabs (junkd, handoff, orch, statusline, tools, lessons);
18//   a × or q on any pane of the set closes the whole set.
19// - dashboard.tsx (with dashboard.ts): /junkd:dashboard, the orch dashboard's status.js beside a copy of its page.
20// - watchdog.tsx (with watchdog.ts): the agent watchdog of orch, its timer, its ⚠ segment and its prompt.submit line.
21// - lessons.tsx (with lessons.ts, lessons-views.tsx): the lessons loop of orch: its tab, its 📒 segment, the blocking
22//   dialog and its prompt.submit line, /junkd:lessons.
23// Settings: one userConfig row per `<group>_<field>` (settings.ts), the /config row `junkd.<group>_<field>`.
24// Paths follow CLAUDE_CONFIG_DIR like Claude Code itself; the flag files live under its junkd/ folder.
25import type { Register } from 'claude-code'
26import type { JunkdHandoffRoute, JunkdToolsRoute } from '../types'
27import { registerCache } from './cache'
28import { coreStart, registerCore } from './core'
29import { ctxStart, registerCtx } from './ctx'
30import { allSettings, getSetting, openPane, setSetting } from './dispatch'
31import { handoffContinuation, handoffSegment, handoffStart, handoffStatus, handoffToolCall, registerHandoff } from './handoff'
32import { orchCatchDeny, orchGuardState, orchStart, orchToolDeny, registerOrch } from './orch'
33import { panelButton, panelStart, panelToggle, registerPanel } from './panel'
34import { SCHEMA, isKey } from './settings'
35import { registerStatusline, statuslineStart } from './statusline'
36import { registerTldr, tldrStart } from './tldr'
37import { registerTools, toolsCommandRan, toolsCount, toolsStart } from './tools-pane'
38import { dashboardStart, dashboardToolCall, registerDashboard } from './dashboard.tsx'
39import { registerWatchdog, watchCallEnd, watchCallStart, watchdogKeep, watchdogLines, watchdogSegment, watchdogStart } from './watchdog.tsx'
40import { lessonsPromptLine, lessonsSegment, lessonsStart, registerLessons } from './lessons.tsx'
41
42const NO_HOOK = 'junkd: the hooks module did not answer'
43const ROUTES: readonly JunkdHandoffRoute[] = ['auto', 'handoffs', 'settings']
44const TOOLS_ROUTES: readonly JunkdToolsRoute[] = ['agents', 'tools', 'mcp', 'skills']
45const reason = (err: unknown) => (err as Error)?.message ?? String(err)
46
47export const register: Register = (on, options) => {
48  // `$.junkd`. The host hands a noun's method one input and forbids the noun capturing `$`: schema and handoffStatus
49  // need none and answer here; the rest throw unless the hooks below answer (they always do while this module runs).
50  on('engine.create', async (_$, e, next) => ({
51    ...(await next(e)),
52    junkd: {
53      schema: async () => SCHEMA.map(f => ({ ...f, ...(f.options ? { options: [...f.options] } : {}) })),
54      config: async () => { throw new Error(NO_HOOK) },
55      get: async () => { throw new Error(NO_HOOK) },
56      set: async () => { throw new Error(NO_HOOK) },
57      open: async () => { throw new Error(NO_HOOK) },
58      handoffStatus: async () => handoffStatus(),
59      orchGuardState: async () => { throw new Error(NO_HOOK) },
60    },
61  }))
62  // A `{ deny }` here rejects the caller's promise with the reason (the host's own rule for a noun's event).
63  on('junkd.get', async ($, e) => {
64    if (!isKey(e?.key)) return { deny: `junkd: no setting ${String(e?.key)}` }
65    try {
66      return { value: await getSetting($, e.key) }
67    } catch (err) {
68      return { deny: reason(err) }
69    }
70  })
71  on('junkd.set', async ($, e) => {
72    if (!isKey(e?.key)) return { deny: `junkd: no setting ${String(e?.key)}` }
73    if (e.scope !== undefined && e.scope !== 'user') return { deny: `junkd: scope ${String(e.scope)} is not supported yet` }
74    try {
75      const r = await setSetting($, e.key, e.value)
76      return r.deny !== undefined ? { deny: r.deny } : { value: r.value }
77    } catch (err) {
78      return { deny: reason(err) }
79    }
80  })
81  on('junkd.config', async $ => {
82    try {
83      return { value: await allSettings($) }
84    } catch (err) {
85      return { deny: reason(err) }
86    }
87  })
88  on('junkd.open', async ($, e) => {
89    const pane = e?.pane
90    const route = e?.route
91    // Each pane its own routes: the tools tabs on the tools pane, handoff's on the others (which refuse them).
92    const known: readonly string[] = pane === 'tools' ? TOOLS_ROUTES : ROUTES
93    if (route !== undefined && !known.includes(route)) return { deny: `junkd: no tab ${String(route)}` }
94    try {
95      if (pane === 'junkd') {
96        if (route !== undefined) return { deny: `junkd: the junkd pane has no tab ${String(route)}` }
97        await panelToggle($)
98      } else if (pane === 'handoff' || pane === 'orch' || pane === 'statusline' || pane === 'tools' || pane === 'lessons') await openPane($, pane, route === undefined ? {} : { route })
99      else return { deny: `junkd: no pane ${String(pane)}` }
100    } catch (err) {
101      return { deny: reason(err) }
102    }
103    return { value: undefined }
104  })
105  on('junkd.orchGuardState', async $ => ({ value: await orchGuardState($) }))
106
107  // Every feature's start, side by side; one that fails leaves the others started, and its error is raised after.
108  on('session.start', async ($, e, next) => {
109    const watched = watchdogStart($).catch(() => undefined) // the watchdog's timer, beside the features
110    const failed = (await Promise.allSettled([coreStart($), handoffStart($), orchStart($), tldrStart($), ctxStart($), statuslineStart($), toolsStart($), panelStart($)])).find(
111      r => r.status === 'rejected',
112    )
113    await watched
114    if (failed) throw failed.reason
115    await dashboardStart($) // after the features' starts: it reads orch's settings; never throws
116    await lessonsStart($).catch(() => undefined) // after orch's start too: the stores, and the dialog of an orch session
117    return next(e)
118  })
119
120  // The main loop's next turn: the watchdog's pending lines, then the lessons line while the dialog stands, as
121  // context. A prompt that does not enter gives the watchdog's lines back for the next one.
122  on('prompt.submit', async ($, e, next) => {
123    const lines = watchdogLines()
124    const lesson = await lessonsPromptLine($).catch(() => undefined)
125    const continuation = handoffContinuation(e) // the handon turn of the auto loop carries its continuation prompt
126    const context = [...lines.length ? [lines.join('\n')] : [], ...lesson ? [lesson] : [], ...continuation ? [continuation] : []]
127    if (!context.length) return next(e)
128    const r = await next({ ...e, context: [...(e.context ?? []), ...context] })
129    if (r.drop !== undefined && lines.length) watchdogKeep(lines)
130    return r
131  })
132
133  // orch's guard first (the model's own calls only), then handoff's gate past the context threshold, then the tools
134  // pane's count of the model's call (it never throws). A hook that throws is skipped and the tool runs: the .catch
135  // refuses instead while the guard holds the main loop.
136  on('tool.call', async ($, e, next) => {
137    const deny = next.origin.plugin === 'engine' ? await orchToolDeny($, e) : undefined
138    if (deny) return { deny }
139    await watchCallStart($, e, next.origin.plugin) // the watchdog: a subagent's call in flight (never throws)
140    const r = await handoffToolCall($, e, next)
141    await watchCallEnd($, e, next.origin.plugin)
142    if (next.origin.plugin === 'engine') await toolsCount($, e, r)
143    if (next.origin.plugin === 'engine') await dashboardToolCall($, e, r)
144    return r
145  }).catch(($, e, next) => {
146    void watchCallEnd($, e, next.origin.plugin) // a call the hook above started and could not end
147    if (next.called) return next(e)
148    const deny = orchCatchDeny(e.agentId)
149    return deny ? { deny } : next(e)
150  })
151
152  // Every command, after it ran: the tools pane's Skills tab counts the slash runs. Registered before the features'
153  // own command.run hooks, so it is the outermost and sees a run one of them answers.
154  on('command.run', async ($, run, next) => {
155    const out = await next(run)
156    await toolsCommandRan($, run)
157    return out
158  })
159
160  // What is beneath (another plugin's hint, or the engine's own line), then 🪃 (handoff) and ✚ junkd, side by side.
161  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
162    const { Box } = $.ui.resolve(e)
163    const below = await next(e).catch(() => undefined)
164    const parts = [below, handoffSegment($, e), lessonsSegment($, e), panelButton($, e)].filter(Boolean)
165    parts.splice(below ? 1 : 0, 0, ...[watchdogSegment($, e, () => void openPane($, 'tools', { route: 'agents' }).catch(err => $.ui.toast(`✗ ${reason(err)}`)))].filter(Boolean))
166    if (parts.length === 1) return parts[0]!
167    return (
168      <Box flexDirection="row" gap={2}>
169        {parts}
170      </Box>
171    )
172  })
173
174  registerCore(on, options)
175  registerCache(on, options)
176  registerHandoff(on, options)
177  registerOrch(on, options)
178  registerTldr(on, options)
179  registerCtx(on, options)
180  registerStatusline(on, options)
181  registerTools(on, options)
182  registerPanel(on, options)
183  registerDashboard(on, options)
184  registerWatchdog(on, options)
185  registerLessons(on, options)
186}
187
hooks/cache.tsx 67 lines
1// cache: the prompt-cache check. At every main session start but a compaction (a new session, /clear, a resume),
2// scripts/cache-check.sh reads the newest `core_cache_check_sessions` sessions of the session's project folder and
3// prints one suggestion line or nothing; the line is drawn in a band above the prompt until it is dismissed or the
4// next check replaces it. A failure goes to the debug log alone; nothing reaches the model or the screen.
5import type { On } from 'claude-code'
6import { C } from './cache-lib'
7import type { $ } from './feature'
8import { P } from './junkd-views'
9
10// The check's line for the band; '' while there is none or it was dismissed.
11let cacheLine = ''
12
13// The script caps itself at 3 s in its own process group; the module's own timeout is the backstop above it.
14const TIMEOUT_MS = 5_000
15
16// scripts/cache-check.sh over the project folder <dir> (0 sessions: off). The last session's line never outlives it.
17export async function cacheCheck($: $, dir: string): Promise<void> {
18  const had = cacheLine !== ''
19  cacheLine = ''
20  try {
21    if (had) $.ui.invalidate('ui.render')
22    if (!C.sessions) return
23    const r = await $.process.run([`${$.plugin.root}/scripts/cache-check.sh`, String(C.sessions), dir], { timeoutMs: TIMEOUT_MS })
24    if (r.exitCode !== 0) throw new Error(`exit ${r.exitCode}: ${r.stderr.trim()}`)
25    cacheLine = r.stdout.trim()
26    if (cacheLine) $.ui.invalidate('ui.render')
27  } catch (err) {
28    $.ui.log(`junkd cache check: ${(err as Error)?.message ?? String(err)}`, { to: 'debug' })
29  }
30}
31
32export function registerCache(on: On, _options: Record<string, unknown>): void {
33  // classic.SessionStart, not session.start: it fires after /clear too and carries agent_id. The project folder is
34  // the transcript's, exact where a slug of the cwd is not (a hashed long path, non-ASCII). Detached: the session
35  // never waits on it.
36  on('classic.SessionStart', ($, e, next) => {
37    if (!e.agent_id && e.source !== 'compact') void cacheCheck($, e.transcript_path.replace(/\/[^/]*$/, ''))
38    return next(e)
39  })
40
41  // The line above the prompt, under what is beneath (another plugin's band); a survey has the band alone. A
42  // warning: bold, in the theme's warning colour, in a round border of it, with a dismiss button.
43  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
44    if (!cacheLine || e.props.hasSurvey) return next(e)
45    const { Box, Button, Text } = $.ui.resolve(e)
46    const below = await next(e).catch(() => undefined)
47    const line = (
48      <Box key="cache-check" flexDirection="row" gap={1} borderStyle="round" borderColor={P.warn} paddingX={1}>
49        <Text color={P.warn} bold>
50          ⚠️ {cacheLine}
51        </Text>
52        <Button key="cache-dismiss" role="dismiss" label="dismiss" onPress={() => { cacheLine = ''; $.ui.invalidate('ui.render') }}>
53          ✕
54        </Button>
55      </Box>
56    )
57    return below ? (
58      <Box flexDirection="column">
59        {below}
60        {line}
61      </Box>
62    ) : (
63      line
64    )
65  })
66}
67
hooks/core.ts 97 lines
1// core: the `core` settings group, two rows.
2// - output_style (ste|off). The setup step (scripts/setup.sh, SessionStart) is off while the flag file
3//   ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/junkd/output-style-off exists: a shell hook reads no `$.config`, so the row's
4//   truth is mirrored there, at every write to the row and at session start. `ste` (the default) or `off`, as the
5//   manifest's options; nothing else is taken.
6// - cache_check_sessions (0-30, a whole number): how many sessions the prompt-cache check (cache.tsx) reads, 0 = off.
7//   Kept in cache-lib.ts's C; no flag file (the module runs the script itself).
8import type { On } from 'claude-code'
9import type { JunkdStyle } from '../types'
10import { C, applyCacheSettings, coerceSessions } from './cache-lib'
11import type { $, SetResult } from './feature'
12import { configKey, configKeys, fieldOf, groupOptions, keyOf } from './settings'
13
14let styleIsOff = false
15
16const STYLE_ERROR = (raw: unknown) => `junkd: ${String(raw)} is not a valid output_style (ste or off)`
17function coerceStyle(raw: unknown): { value: JunkdStyle; error?: undefined } | { error: string; value?: undefined } {
18  const v = String(raw).trim().toLowerCase()
19  return v === 'ste' || v === 'off' ? { value: v } : { error: STYLE_ERROR(raw) }
20}
21
22let home = ''
23async function homeDir($: $): Promise<string> {
24  if (home) return home
25  home = (await $.env.get('HOME')) ?? ''
26  if (!home) home = (await $.process.run(['sh', '-c', 'echo $HOME'])).stdout.trim()
27  return home
28}
29async function configDir($: $) {
30  return (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await homeDir($)}/.claude`
31}
32
33async function writeStyleFlag($: $): Promise<void> {
34  const flag = `${await configDir($)}/junkd/output-style-off`
35  if (styleIsOff) await $.fs.write(flag, '')
36  else await $.process.run(['rm', '-f', '--', flag])
37}
38
39export async function coreGet(_$: $, field: string): Promise<JunkdStyle | number> {
40  if (field === 'cache_check_sessions') return C.sessions
41  return styleIsOff ? 'off' : 'ste'
42}
43
44// The host sends our own `$.config.set` past our config.set hook, so the flag is written here too.
45export async function coreSet($: $, field: string, raw: unknown): Promise<SetResult> {
46  if (field === 'cache_check_sessions') {
47    const n = coerceSessions(raw)
48    if (n.error !== undefined) return { deny: n.error }
49    const r = await $.config.set({ key: configKey('core_cache_check_sessions'), value: n.value })
50    if (r.deny !== undefined) return { deny: r.deny }
51    C.sessions = n.value
52    return { value: n.value }
53  }
54  if (field !== 'output_style') return { deny: `junkd: no setting ${keyOf('core', field)}` }
55  const c = coerceStyle(raw)
56  if (c.error !== undefined) return { deny: c.error }
57  const { deny } = await $.config.set({ key: configKey('core_output_style'), value: c.value })
58  if (deny !== undefined) return { deny }
59  styleIsOff = c.value === 'off'
60  await writeStyleFlag($)
61  return { value: c.value }
62}
63
64export async function coreStart($: $): Promise<void> {
65  await writeStyleFlag($).catch(() => undefined)
66}
67
68export function registerCore(on: On, options: Record<string, unknown>): void {
69  // A value the manifest's options do not name (an edit of the file by hand) is the default, ste.
70  const o = groupOptions(options, 'core')
71  styleIsOff = coerceStyle(o.output_style).value === 'off'
72  applyCacheSettings(o.cache_check_sessions)
73
74  // The /config menu's writes to the core rows land here. cache_check_sessions: read as `set` reads it, refused where
75  // `set` refuses. output_style: a value the options do not name is refused, one the row took is mirrored into the
76  // flag file.
77  on('config.set', { key: configKeys('core') }, async ($, e, next) => {
78    const field = fieldOf('core', e.key)
79    if (field === 'cache_check_sessions') {
80      const n = coerceSessions(e.value)
81      if (n.error !== undefined) return { deny: n.error }
82      const r = await next({ ...e, value: n.value })
83      if (r.deny === undefined) C.sessions = n.value
84      return r
85    }
86    if (field !== 'output_style') return next(e)
87    const c = coerceStyle(e.value)
88    if (c.error !== undefined) return { deny: c.error }
89    const r = await next({ ...e, value: c.value })
90    if (r.deny === undefined) {
91      styleIsOff = c.value === 'off'
92      await writeStyleFlag($)
93    }
94    return r
95  })
96}
97
hooks/ctx.ts 219 lines
1// ctx: the `ctx` settings group (enabled, threshold, action, cmd) and /junkd:ctx-limit. bin/ctx-limit-hook
2// (UserPromptSubmit, a shell hook: it reads no `$.config`) decides each turn from three files under
3// ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/junkd/, which bin/ctx-limit (the CLI the skill runs) writes too:
4// - ctx-limit-threshold: the limit in tokens, there while the guard is on (enabled's and threshold's truth);
5// - ctx-limit-action: block, warn or cmd (none: block);
6// - ctx-limit-cmd: the command of action cmd (none while it is empty).
7// The files are the truth, as tldr-on is for terse: get reads them, so a change by the CLI shows. A write to a row,
8// by `$.junkd.set` or by the /config menu, writes its file first, then the row; a refused row puts the file back.
9// threshold's file is written only while the guard is on (the row keeps the number enabled writes). At session start
10// the files of the plugin before junkd (${CLAUDE_CONFIG_DIR:-$HOME/.claude}/ctx-limit-*) move into junkd/ once, where
11// junkd/ has none of its own.
12import type { On } from 'claude-code'
13import type { JunkdCtxAction as Action, JunkdCtxField as Field } from '../types'
14import type { $, SetResult } from './feature'
15import { configKey, configKeys, fieldOf, groupOptions, keyOf, rowsOf } from './settings'
16
17const isField = (key: unknown): key is Field => rowsOf('ctx').some(f => f.field === key)
18export const CTX_ACTIONS: readonly Action[] = ['block', 'warn', 'cmd']
19// The least threshold a set takes: below it every prompt of a session would be refused.
20export const CTX_MIN = 1000
21const DEFAULTS = { enabled: false, threshold: 300000, action: 'block' as Action, cmd: '' }
22// The rows as last stored (or as the options at load have them).
23const S = { ...DEFAULTS }
24
25type File = 'threshold' | 'action' | 'cmd'
26let home = ''
27async function homeDir($: $): Promise<string> {
28  if (home) return home
29  home = (await $.env.get('HOME')) ?? ''
30  if (!home) home = (await $.process.run(['sh', '-c', 'echo $HOME'])).stdout.trim()
31  return home
32}
33async function configDir($: $) {
34  return (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await homeDir($)}/.claude`
35}
36// The same spelling as bin/ctx-limit and bin/ctx-limit-hook: ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/junkd/ctx-limit-<name>.
37async function filePath($: $, name: File): Promise<string> {
38  return `${await configDir($)}/junkd/ctx-limit-${name}`
39}
40
41// A file's text, or undefined when there is none (or it cannot be read).
42async function readFile($: $, path: string): Promise<string | undefined> {
43  if (!(await $.fs.exists(path).catch(() => false))) return undefined
44  return $.fs.read(path).catch(() => undefined)
45}
46// What the hook reads: the threshold as a whole number (junk is off, as the hook takes it), the action (none or junk
47// is block), the command.
48const asThreshold = (text: string | undefined) => (text !== undefined && /^\d+$/.test(text.trim()) ? Number(text.trim()) : undefined)
49const asAction = (text: string | undefined): Action => {
50  const t = text?.trim()
51  return CTX_ACTIONS.includes(t as Action) ? (t as Action) : 'block'
52}
53
54// A file to its new text (`null`: removed). Answers the reason when it could not.
55async function writeFile($: $, path: string, text: string | null): Promise<string | undefined> {
56  try {
57    if (text !== null) await $.fs.write(path, text)
58    else {
59      const r = await $.process.run(['rm', '-f', '--', path])
60      if (r.exitCode !== 0) return `ctx: could not remove ${path}: ${r.stderr.trim() || `rm exited ${r.exitCode}`}`
61    }
62  } catch (err) {
63    return `ctx: could not write ${path}: ${(err as Error)?.message ?? String(err)}`
64  }
65  return undefined
66}
67
68// A raw value to the field's kind; `error` when it does not fit.
69type Value = boolean | number | string
70type Coerced = { value: Value; error?: undefined } | { error: string; value?: undefined }
71// 300000, '300000', '300k', '1m' (any case) -> a whole number of tokens; undefined for anything else.
72export function parseTokens(raw: unknown): number | undefined {
73  if (typeof raw === 'number') return Number.isSafeInteger(raw) ? raw : undefined
74  const m = /^(\d+)([kKmM]?)$/.exec(String(raw).trim())
75  if (!m) return undefined
76  const n = Number(m[1]) * (m[2] === '' ? 1 : /[kK]/.test(m[2]!) ? 1000 : 1000000)
77  return Number.isSafeInteger(n) ? n : undefined
78}
79export function coerce(field: Field, raw: unknown): Coerced {
80  if (field === 'enabled') return { value: raw === true || raw === 'true' || raw === 'on' }
81  if (field === 'threshold') {
82    const n = parseTokens(raw)
83    if (n === undefined || n < CTX_MIN) {
84      return { error: `junkd: ${String(raw)} is not a valid ctx_threshold (a whole number of tokens, ${CTX_MIN} or more; 300k and 1m are read too)` }
85    }
86    return { value: n }
87  }
88  if (field === 'action') {
89    const v = String(raw).trim().toLowerCase()
90    return CTX_ACTIONS.includes(v as Action) ? { value: v } : { error: `junkd: ${String(raw)} is not a valid ctx_action (block, warn or cmd)` }
91  }
92  return { value: String(raw ?? '').trim() }
93}
94
95// The file a row's value is mirrored into, and its new text (`null`: removed); none where the row has no file now
96// (threshold while the guard is off).
97async function mirrorOf($: $, field: Field, value: Value): Promise<{ path: string; text: string | null } | undefined> {
98  if (field === 'enabled') return { path: await filePath($, 'threshold'), text: value === true ? `${await thresholdNow($)}\n` : null }
99  if (field === 'threshold') {
100    const path = await filePath($, 'threshold')
101    return asThreshold(await readFile($, path)) === undefined ? undefined : { path, text: `${value}\n` }
102  }
103  if (field === 'action') return { path: await filePath($, 'action'), text: `${value}\n` }
104  return { path: await filePath($, 'cmd'), text: value === '' ? null : `${value}\n` }
105}
106
107// A row's write, on either path: its file first, then the row (`store`). A file that could not be written stores
108// nothing; a refused row puts the file back as it was.
109async function storeRow<R extends { deny?: string }>($: $, field: Field, value: Value, store: () => Promise<R>): Promise<R | { deny: string }> {
110  const mirror = await mirrorOf($, field, value)
111  if (mirror === undefined) return store()
112  const was = await readFile($, mirror.path)
113  const failed = await writeFile($, mirror.path, mirror.text)
114  if (failed !== undefined) return { deny: failed }
115  const r = await store()
116  if (r.deny !== undefined) await writeFile($, mirror.path, was ?? null)
117  return r
118}
119
120function apply(field: Field, value: Value): void {
121  ;(S as Record<Field, Value>)[field] = value
122}
123
124// The threshold enabled writes, and get answers: the file's number while the guard is on, else the row.
125async function thresholdNow($: $): Promise<number> {
126  return asThreshold(await readFile($, await filePath($, 'threshold'))) ?? S.threshold
127}
128
129export async function ctxGet($: $, field: string): Promise<Value> {
130  if (field === 'enabled') return asThreshold(await readFile($, await filePath($, 'threshold'))) !== undefined
131  if (field === 'threshold') return thresholdNow($)
132  if (field === 'action') return asAction(await readFile($, await filePath($, 'action')))
133  return (await readFile($, await filePath($, 'cmd')))?.trim() ?? ''
134}
135
136export async function ctxSet($: $, field: string, raw: unknown): Promise<SetResult> {
137  if (!isField(field)) return { deny: `junkd: no setting ${keyOf('ctx', field)}` }
138  const c = coerce(field, raw)
139  if (c.error !== undefined) return { deny: c.error }
140  // The host sends this write through the other plugins' hooks and skips ours below, so the file is written here.
141  const { deny } = await storeRow($, field, c.value, () => $.config.set({ key: configKey(keyOf('ctx', field)), value: c.value }))
142  if (deny !== undefined) return { deny }
143  apply(field, c.value)
144  return { value: c.value }
145}
146
147// The files of the plugin before junkd (${CLAUDE_CONFIG_DIR:-$HOME/.claude}/ctx-limit-*) into junkd/, each where
148// junkd/ has none yet: copied, then the old one removed. A file that fails leaves the others moved.
149export async function ctxStart($: $): Promise<void> {
150  const cfg = await configDir($)
151  await Promise.all(
152    (['threshold', 'action', 'cmd'] as const).map(async name => {
153      try {
154        const old = `${cfg}/ctx-limit-${name}`
155        const text = await readFile($, old)
156        if (text === undefined || (await $.fs.exists(await filePath($, name)))) return
157        if ((await writeFile($, await filePath($, name), text)) === undefined) await writeFile($, old, null)
158      } catch {}
159    }),
160  )
161}
162
163// A one-line status, as bin/ctx-limit status prints it.
164async function statusLine($: $): Promise<string> {
165  const on = (await ctxGet($, 'enabled')) === true
166  const action = await ctxGet($, 'action')
167  const cmd = action === 'cmd' ? `, cmd: ${(await ctxGet($, 'cmd')) || '(none)'}` : ''
168  return `ctx-limit: ${on ? `ON @ ${await ctxGet($, 'threshold')} tokens` : 'OFF'}, action: ${action}${cmd}`
169}
170
171// /junkd:ctx-limit's argument, through the group's set (the rows and the files stay in step). Answers the toast.
172async function runCommand($: $, args: string): Promise<string> {
173  const [word = '', ...rest] = args.trim().split(/\s+/)
174  const all = async (...writes: [Field, unknown][]) => {
175    for (const [field, value] of writes) {
176      const r = await ctxSet($, field, value)
177      if (r.deny !== undefined) return `✗ ${r.deny}`
178    }
179    return `✓ ${await statusLine($)}`
180  }
181  if (word === '' || word === 'status') return statusLine($)
182  if (word === 'on') return all(['enabled', true])
183  if (word === 'off' || word === 'reset') return all(['enabled', false])
184  if (word === 'action') return all(['action', rest.join(' ')])
185  if (word === 'cmd') {
186    const cmd = args.trim().slice(3).trim()
187    if (cmd === '') return '✗ usage: /junkd:ctx-limit cmd <shell command>'
188    return all(['cmd', cmd], ['action', 'cmd'])
189  }
190  if (rest.length === 0 && parseTokens(word) !== undefined) return all(['threshold', word], ['enabled', true])
191  return '✗ usage: /junkd:ctx-limit [<tokens e.g. 300k> | on | off | status | action <block|warn|cmd> | cmd <shell>]'
192}
193
194export function registerCtx(on: On, options: Record<string, unknown>): void {
195  const o = groupOptions(options, 'ctx')
196  // A value the row cannot hold (a hand edit of the file) is the default.
197  S.enabled = o.enabled === true
198  S.threshold = (coerce('threshold', o.threshold ?? DEFAULTS.threshold).value as number | undefined) ?? DEFAULTS.threshold
199  S.action = (coerce('action', o.action ?? DEFAULTS.action).value as Action | undefined) ?? DEFAULTS.action
200  S.cmd = typeof o.cmd === 'string' ? o.cmd.trim() : DEFAULTS.cmd
201
202  // The /config menu's own writes to a ctx row land here (a hook never sees its plugin's own `$.config.set`): read as
203  // `set` reads it, refused where `set` refuses; the file first, as `set` writes it.
204  on('config.set', { key: configKeys('ctx') }, async ($, e, next) => {
205    const field = fieldOf('ctx', e.key)
206    if (!isField(field)) return next(e)
207    const c = coerce(field, e.value)
208    if (c.error !== undefined) return { deny: c.error }
209    const r = await storeRow($, field, c.value, () => next({ ...e, value: c.value }))
210    if (r.deny === undefined) apply(field, c.value)
211    return r
212  })
213  // /junkd:ctx-limit: every form through the group's set, the answer a toast (the skill's CLI is the other hosts').
214  on('command.run', { command: 'junkd:ctx-limit' }, async ($, e) => {
215    $.ui.toast(await runCommand($, e.args))
216    return {}
217  })
218}
219
hooks/dispatch.ts 69 lines
1// From a settings key or a pane name to the feature that owns it. The engine follows `$` into another file's
2// function only when it is called by name, so each group is spelled out with its own `if` (see feature.ts).
3import type { JunkdKey, JunkdValue } from '../types'
4import { coreGet, coreSet } from './core'
5import { ctxGet, ctxSet } from './ctx'
6import type { $, HideInput, OpenInput, SetResult, ShowInput } from './feature'
7import { handoffGet, handoffHide, handoffOpen, handoffSet, handoffShow } from './handoff'
8import { orchGet, orchHide, orchOpen, orchSet, orchShow } from './orch'
9import { SCHEMA, entryOf } from './settings'
10import { statuslineGet, statuslineHide, statuslineOpen, statuslineSet, statuslineShow } from './statusline'
11import { tldrGet, tldrSet } from './tldr'
12import { toolsGet, toolsHide, toolsOpen, toolsSet, toolsShow } from './tools-pane'
13import { lessonsHide, lessonsOpen, lessonsShow } from './lessons.tsx'
14
15export type FeaturePane = 'handoff' | 'orch' | 'statusline' | 'tools' | 'lessons'
16
17// One row's current value. The key is a row of SCHEMA (the caller checked).
18export async function getSetting($: $, key: JunkdKey): Promise<JunkdValue> {
19  const { group, field } = entryOf(key)
20  if (group === 'core') return coreGet($, field)
21  if (group === 'handoff') return handoffGet($, field)
22  if (group === 'orch') return orchGet($, field)
23  if (group === 'ctx') return ctxGet($, field)
24  if (group === 'statusline') return statuslineGet($, field)
25  if (group === 'tools') return toolsGet($, field)
26  return tldrGet($, field)
27}
28
29// Validates, writes the row and applies it: the feature's own rules. The key is a row of SCHEMA (the caller checked).
30export async function setSetting($: $, key: JunkdKey, raw: unknown): Promise<SetResult> {
31  const { group, field } = entryOf(key)
32  if (group === 'core') return coreSet($, field, raw)
33  if (group === 'handoff') return handoffSet($, field, raw)
34  if (group === 'orch') return orchSet($, field, raw)
35  if (group === 'ctx') return ctxSet($, field, raw)
36  if (group === 'statusline') return statuslineSet($, field, raw)
37  if (group === 'tools') return toolsSet($, field, raw)
38  return tldrSet($, field, raw)
39}
40
41// Every row's current value, by key, in manifest order.
42export async function allSettings($: $): Promise<Record<JunkdKey, JunkdValue>> {
43  const out = {} as Record<JunkdKey, JunkdValue>
44  for (const f of SCHEMA) out[f.key] = await getSetting($, f.key)
45  return out
46}
47
48export async function showPane($: $, pane: FeaturePane, input: ShowInput = {}): Promise<void> {
49  if (pane === 'handoff') return handoffShow($, input)
50  if (pane === 'statusline') return statuslineShow($, input)
51  if (pane === 'tools') return toolsShow($, input)
52  if (pane === 'lessons') return lessonsShow($, input)
53  return orchShow($, input)
54}
55export async function hidePane($: $, pane: FeaturePane, input: HideInput = {}): Promise<void> {
56  if (pane === 'handoff') return handoffHide($, input)
57  if (pane === 'statusline') return statuslineHide($, input)
58  if (pane === 'tools') return toolsHide($, input)
59  if (pane === 'lessons') return lessonsHide($, input)
60  return orchHide($, input)
61}
62export async function openPane($: $, pane: FeaturePane, input: OpenInput = {}): Promise<void> {
63  if (pane === 'handoff') return handoffOpen($, input)
64  if (pane === 'statusline') return statuslineOpen($, input)
65  if (pane === 'tools') return toolsOpen($, input)
66  if (pane === 'lessons') return lessonsOpen($, input)
67  return orchOpen($, input)
68}
69
hooks/handoff.tsx 1002 lines
1// handoff: /junkd:handoff, /junkd:handon, /junkd:ahandoff, the auto handoff loop (a context threshold, the 5h-limit
2// resume), the 🪃 segment and its pane. The `$`-free parts are handoff-lib.ts (state, options), handoff-ui.tsx and
3// handoff-views.tsx (the pane). index.tsx runs handoffStart at session start, handoffToolCall inside the one tool.call
4// hook and handoffSegment inside the one PromptHint render.
5import { atom, read, update } from 'claude-code'
6import type { Next, On, RenderInput, RenderSurface, ToolCallInput } from 'claude-code'
7
8import type { JunkdAhandoff as Ahandoff } from '../types'
9import type { HideInput, OpenInput, SetResult, ShowInput } from './feature'
10import { isSets, shutSet } from './set'
11import { configKey, configKeys, fieldOf, groupOptions, keyOf } from './settings'
12import { CLEAR_TIMEOUT_MS, DAY, DEFAULT_PROMPT, LIMIT_GRACE, LIMIT_PROMPT, MAX_ASKS, MAX_CLEAR_RETRIES, MAX_RESUME_RETRIES, PROMPT_DELAY_MS, S, coerce, current, idOf, isField, isLegacy, kTokens, limit, loadOptions, loopState, setPhase, status, thresholdText, thresholdTokens, type $, type Entry, type Field, type Route } from './handoff-lib'
13import { Footer, P, Rule, TABS, Tabs, fields, type Api, type Ctx, type Elements } from './handoff-ui'
14import { VIEWS } from './handoff-views'
15import { toolsTurnEnd } from './tools-pane'
16import { dashboardTurnEnd } from './dashboard.tsx'
17import { lessonsTurnEnd } from './lessons.tsx'
18
19const PANE = 'ahandoff'
20const EDGE = 2 // empty columns at the pane's right edge (a docked pane's last column may not show)
21const HANDOFF_FILE = /\/handoffs\/[^/]+\.md$/
22// What arming says while the `enabled` setting is off; /junkd:handoff, /junkd:handon and the pane keep working.
23const OFF = 'handoff: auto handoff is disabled (the enabled setting is off: /config, or the pane\'s Settings tab)'
24
25function redraw($: $) {
26  $.ui.invalidate('ui.render')
27}
28
29const STORE = 'ahandoff'
30
31const view = atom({ plugin: 'junkd', key: 'ahandoff' } as const, {
32  on: false, phase: 'idle', percent: 0, threshold: 300, checkEvery: 5,
33  prompt: '', lastId: '', count: 0, route: 'auto', paneOpen: false, base: 0, fresh: false,
34} satisfies Ahandoff)
35
36// ponytail: plugin commands are namespaced (`junkd:handon`) — junkd's own first, else any plugin's by suffix.
37async function findCommand($: $, suffix: 'handoff' | 'handon'): Promise<string> {
38  const names = (await $.command.list()).map(c => c.name)
39  return names.find(n => n === `junkd:${suffix}`) ?? names.find(n => n.endsWith(`:${suffix}`)) ?? `junkd:${suffix}`
40}
41
42let home = ''
43async function homeDir($: $): Promise<string> {
44  if (home) return home
45  home = (await $.env.get('HOME')) ?? ''
46  if (!home) home = (await $.process.run(['sh', '-c', 'echo $HOME'])).stdout.trim()
47  return home
48}
49
50// Paths follow CLAUDE_CONFIG_DIR like Claude Code itself (unset or empty: the home's .claude).
51async function configDir($: $): Promise<string> {
52  return (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await homeDir($)}/.claude`
53}
54
55// Where bin/handoff-path writes: `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/handoffs`.
56async function handoffDir($: $): Promise<string> {
57  return `${await configDir($)}/handoffs`
58}
59
60// ------------------------------------------------------------------ the measurement log
61// Every measurement the loop makes or skips writes one line to `${CLAUDE_CONFIG_DIR:-~/.claude}/junkd/handoff/<session>.log`:
62//   <iso> <hook> percent=<n> used=<tokens> limit=<tokens> armed=<bool> fresh=<bool> decision=<fire|skip:reason> base=<n> threshold=<n> phase=<p> window=<n>
63// (a trailing `note=<words>` where the hook did something besides decide; `legacy-percent` while the threshold is 100 or less). Appended with `>>`, never read back; a log
64// that cannot be written is dropped, the loop never waits on it for more than the one append, and a toast says so once
65// per phase (diskTrouble). The last line (without its time) is also kept in S.last, for `/junkd:ahandoff status`.
66const PLAIN_ID = /^[A-Za-z0-9_-]+$/
67type Hook = 'session.start' | 'tool.call' | 'turn.complete' | 'session.measure' | 'config.set' | 'cycle'
68async function logPath($: $): Promise<string | undefined> {
69  const sid = await $.session.id().catch(() => '')
70  return PLAIN_ID.test(sid) ? `${await configDir($)}/junkd/handoff/${sid}.log` : undefined
71}
72async function measured($: $, hook: Hook, percent: number, decision: string, note = '', used = S.tokens): Promise<void> {
73  if (isLegacy()) note = note ? `${note},legacy-percent` : 'legacy-percent'
74  const body = `${hook} percent=${percent} used=${used} limit=${limit()} armed=${S.armed} fresh=${S.fresh} decision=${decision} base=${S.base} threshold=${S.threshold} phase=${S.phase} window=${S.window}${note ? ` note=${note}` : ''}`
75  S.last = body
76  S.lastAt = Date.now()
77  try {
78    const path = await logPath($)
79    if (!path) return
80    S.logPath = path
81    const dir = path.slice(0, path.lastIndexOf('/'))
82    const r = await $.process.run(['sh', '-c', 'mkdir -p -- "$1" && printf \'%s\\n\' "$3" >> "$2"', 'sh', dir, path, `${new Date(S.lastAt).toISOString()} ${body}`])
83    if (r.exitCode !== 0) throw new Error(r.stderr.trim() || `exit ${r.exitCode}`) // a full disk fails the >> with a status, not a throw
84  } catch {
85    diskTrouble($)
86  }
87}
88
89// Free text (an error's message, a path) as one token of a log note: no whitespace, no `,` (the note's separator).
90// ponytail: cut at 200 characters so one line stays one line; a longer message is only ever a stack nobody greps.
91const token = (v: unknown): string => String(v instanceof Error ? v.message : v).replace(/[\s,]+/g, '_').slice(0, 200) || '?'
92
93// The log or the store could not be written (seen live: a full disk swallowed both and nothing said so): one toast per
94// phase epoch (setPhase), never one per turn.
95function diskTrouble($: $) {
96  if (S.warned === S.epoch) return
97  S.warned = S.epoch
98  try {
99    $.ui.toast('auto-handoff: cannot write log/state — disk full?')
100  } catch {}
101}
102
103// What a hook stage threw, in the log: `hook:error` with `stage=<s>,err=<message>`.
104function hookError($: $, hook: Hook, stage: string, err: unknown): Promise<void> {
105  return measured($, hook, S.percent, 'hook:error', `stage=${stage},err=${token(err)}`)
106}
107
108// A promise nobody waits for (a step the next turn runs, a redraw, a press): what it rejects with is logged, never left
109// unhandled. Every detached promise of this module goes through here.
110function detach($: $, stage: string, p: Promise<unknown>): void {
111  p.catch(err => hookError($, 'cycle', stage, err))
112}
113
114async function listHandoffs($: $, force?: boolean): Promise<Entry[]> {
115  if (S.entries && !force) return S.entries
116  const dir = await handoffDir($)
117  const home = await homeDir($)
118  S.dir = home && dir.startsWith(`${home}/`) ? `~${dir.slice(home.length)}` : dir
119  let files: Awaited<ReturnType<$['fs']['list']>> = []
120  try {
121    files = await $.fs.list(dir)
122  } catch {
123    files = [] // ponytail: no folder yet = no handoffs
124  }
125  const now = Date.now()
126  const entries = await Promise.all(
127    files
128      .filter(f => f.kind === 'file' && f.name.endsWith('.md'))
129      .map(async f => {
130        const path = `${dir}/${f.name}`
131        let title = ''
132        try {
133          title = (await $.fs.read(path)).split('\n').find(l => l.startsWith('# '))?.slice(2).trim() ?? ''
134        } catch {}
135        return { id: idOf(f.name) ?? f.name.replace(/\.md$/, ''), path, mtimeMs: f.mtimeMs, title, stale: now - f.mtimeMs > S.staleDays * DAY }
136      }),
137  )
138  S.entries = entries.sort((a, b) => b.mtimeMs - a.mtimeMs)
139  return S.entries
140}
141
142// The live window's fill: the host's used tokens (what the limit compares with) and its percent (shown); 0 before the
143// first response of a window.
144async function readUsage($: $) {
145  const { context } = await $.session.usage()
146  S.tokens = context.tokens ?? S.tokens
147  S.window = context.window ?? S.window
148  return { percent: context.percent ?? 0, used: context.tokens ?? 0 }
149}
150
151// Mirror S into $.state and redraw the pane and the segment.
152function sync($: $, percent?: number) {
153  if (percent !== undefined) S.percent = percent
154  $.ui.invalidate('ui.render')
155  return update($, view, v => ({
156    ...v, on: S.armed, phase: S.phase, prompt: S.prompt, lastId: S.lastId ?? '', count: S.count,
157    threshold: S.threshold, checkEvery: S.checkEvery, percent: S.percent, route: S.route, paneOpen: S.paneOpen,
158    base: S.base, fresh: S.fresh,
159  }))
160}
161
162// Best effort, never rejects: a store that cannot be written (a full disk) is logged (`cycle:persist-failed`) and said
163// in a toast, and the caller goes on — the loop's state is S, the store only what a new process starts from.
164async function persist($: $): Promise<void> {
165  try {
166    await $.store.set(STORE, {
167      on: S.armed, prompt: S.prompt,
168      pending: S.phase === 'resume' && S.lastId ? { id: S.lastId, prompt: S.prompt } : undefined,
169      resumeAt: S.blocked ? S.resumeAt : undefined,
170    })
171  } catch (err) {
172    await measured($, 'cycle', S.percent, 'cycle:persist-failed', `err=${token(err)}`)
173    diskTrouble($)
174  }
175}
176
177// Ends the cycle where it stands and starts the loop idle again, still armed: what was written, asked or pending is
178// dropped, and a chain still waiting to start (continueFrom) stops when it does.
179function resetCycle() {
180  setPhase('idle')
181  S.written = null
182  S.candidate = null
183  S.pending = null
184  S.external = null
185  S.clearTries = 0
186  S.resumeTries = 0
187  S.fresh = false
188  S.chain++
189}
190
191// A handoff file the cycle may go on from: directly in the handoffs folder, under a name idOf reads an id from, and on
192// disk. HANDOFF_FILE takes any `/handoffs/<name>.md`; a Write elsewhere (another `handoffs` folder, a `..` out of the
193// real one, a nested folder) would have the loop /clear for a file /junkd:handon never finds there. Undefined when the
194// file is one, else why not: `outside-handoffs`, `not-a-handoff-name` or `not-on-disk`. A check that fails is no file.
195async function notHandoff($: $, path: string): Promise<string | undefined> {
196  const dir = normalPath(await handoffDir($))
197  const p = normalPath(path)
198  if (dir === undefined || p === undefined || p.slice(0, p.lastIndexOf('/')) !== dir) return 'outside-handoffs'
199  if (idOf(p.slice(p.lastIndexOf('/') + 1)) === null) return 'not-a-handoff-name'
200  return (await $.fs.exists(p).catch(() => false)) ? undefined : 'not-on-disk'
201}
202
203// An absolute path with its doubled slashes and `.` segments dropped; undefined for a relative path or one with a `..`
204// segment (never resolved: a path that climbs is not the loop's).
205function normalPath(path: string): string | undefined {
206  if (!path.startsWith('/')) return undefined
207  const parts = path.split('/').filter(x => x !== '' && x !== '.')
208  return parts.includes('..') ? undefined : `/${parts.join('/')}`
209}
210
211// A turn died on the 5h limit: one timer to 2 min past the window's reset, then the continuation prompt.
212// ponytail: no 5h interval — the next limit hit schedules the next timer. The timer lives in this
213// process only; after a restart session.start just tells you it was pending.
214let limitTimer: { cancel(): void } | null = null
215async function scheduleResume($: $) {
216  if (S.blocked || !S.resetsAt) return
217  S.blocked = true
218  S.resumeAt = Date.parse(S.resetsAt) + LIMIT_GRACE
219  const delay = Math.max(0, S.resumeAt - (await $.clock.now()))
220  limitTimer = $.clock.after(delay, () => detach($, 'limit-resume', resumeAfterLimit($)))
221  await persist($)
222  $.ui.toast(`handoff: 5h limit hit — resuming at ${hhmm(S.resumeAt)}`)
223  await sync($)
224}
225async function resumeAfterLimit($: $) {
226  limitTimer = null
227  if (!S.blocked) return
228  S.blocked = false
229  S.resumeAt = null
230  await persist($)
231  await sync($)
232  if (S.armed) await sendPrompt($, S.prompt === DEFAULT_PROMPT ? LIMIT_PROMPT : S.prompt)
233}
234function cancelResume() {
235  limitTimer?.cancel()
236  limitTimer = null
237  S.blocked = false
238  S.resumeAt = null
239}
240const hhmm = (ms: number) => new Date(ms).toTimeString().slice(0, 5)
241
242
243// A handoff file written this session: its id becomes the last one, the list is read again.
244function record(path: string) {
245  const name = path.replace(/^.*\//, '')
246  const id = idOf(name) ?? name.replace(/\.md$/, '')
247  S.lastId = id
248  if (!S.session.includes(id)) S.session.push(id)
249  S.entries = null
250  return id
251}
252
253// The handoff file is there: its id is the last one, /clear and /junkd:handon <id> follow. The asks count belongs to the
254// loop's own requests for a file, so a file nobody asked for does not touch it beyond the reset.
255// The chain is started before the persist (seen live: a persist that rejected on a full disk came first, and the phase
256// sat at resume with nothing running); the persist is best effort, never rejects, and the turn does not wait on it.
257function follow($: $, path: string) {
258  const id = record(path)
259  S.calls = 0
260  S.asks = 0
261  S.external = null
262  S.count++
263  S.resumeTries = 0
264  setPhase('resume')
265  continueFrom($, id)
266  detach($, 'persist', persist($))
267}
268
269async function load($: $) {
270  if (S.entries === null) {
271    await listHandoffs($)
272    redraw($)
273  }
274}
275
276// /junkd:handoff in the next turn. ponytail: detached — $.command.run is refused inside a hook the turn waits on.
277function queueHandoff($: $) {
278  detach($, 'queue-handoff', $.clock.sleep(0).then(async () => $.command.run({ command: await findCommand($, 'handoff') })))
279}
280
281// Why the /clear did not happen: `timeout` (no answer in CLEAR_TIMEOUT_MS), `error` (the call rejected or threw) or `denied`
282// (it resolved without a string `text`: a hook above answered `{ deny }` and the engine hands that back as `{}`; a clear that
283// ran resolves `{ text: "" }` and opens a fresh window); undefined when it ran.
284async function clearFailure($: $, cleared: Promise<unknown>): Promise<'timeout' | 'error' | 'denied' | undefined> {
285  let timer: { cancel(): void } | undefined
286  const timeout = new Promise<'timeout'>(done => { timer = $.clock.after(CLEAR_TIMEOUT_MS, () => done('timeout')) })
287  try {
288    const r = await Promise.race([cleared, timeout])
289    if (r === 'timeout') return 'timeout'
290    return clearRan(r) ? undefined : 'denied'
291  } catch {
292    return 'error'
293  } finally {
294    timer?.cancel()
295    cleared.catch(() => {}) // a clear that fails after the timeout is not an unhandled rejection
296  }
297}
298
299// The success shape of a resolved /clear: an object whose `text` is a string (empty is fine). A refused
300// one has a `text` key too, holding undefined (it prints as `{}`), so the value is what tells them apart.
301function clearRan(r: unknown): boolean {
302  return typeof r === 'object' && r !== null && typeof (r as { text?: unknown }).text === 'string'
303}
304
305// /clear → /junkd:handon <id> carrying the continuation prompt, detached like queueHandoff.
306// One turn does both: the continuation rides the handon submission as hidden context (handoffContinuation), so the
307// model loads the handoff and goes on in the same turn, however it loads it (under the orch guard an agent does,
308// and a turn that ends with that agent running would let a separate prompt start before the handoff is in). Only
309// when no handon submission took it (a handon that raised none) is it sent as a prompt of its own, as before.
310// Each call is a numbered chain: one that a newer call (a retry) or a reset (resetCycle) superseded stops at its next
311// step. S.inFlight is up from the chain's start to its end, the handon included, so no turn end retries over it.
312function continueFrom($: $, id: string) {
313  const text = S.prompt || DEFAULT_PROMPT
314  const chain = ++S.chain
315  const latest = () => chain === S.chain
316  detach($, 'continue', $.clock.sleep(0).then(async () => {
317    if (!latest()) return
318    S.inFlight = true
319    try {
320      S.fresh = S.armed // the guard is the loop's: a manual resume while off needs none
321      setPhase('clear')
322      // ponytail: queue /clear before any other await — a headless session exits right after the turn.
323      const cleared = $.command.run({ command: 'clear' })
324      detach($, 'sync', sync($))
325      detach($, 'log', measured($, 'cycle', S.percent, 'cycle:clear-issued'))
326      // The host may never answer, a hook may refuse it (denied) or it may throw (seen live: the clear was never queued and the phase stayed
327      // `clear` for good): any of the three leaves the phase at `clear` with fresh off, and the next turn end retries.
328      const failed = await clearFailure($, cleared)
329      if (!latest()) return
330      if (failed) {
331        S.fresh = false // the window was not cleared: the next turn end is judged as a turn end, not as a fresh window
332        await measured($, 'cycle', S.percent, `cycle:clear-failed:${failed}`)
333        await sync($)
334        return
335      }
336      S.clearTries = 0
337      setPhase('resume')
338      detach($, 'sync', sync($))
339      const pending = (S.pending = { id, text, delivered: false })
340      try {
341        await $.command.run({ command: await findCommand($, 'handon'), args: id })
342      } finally {
343        if (S.pending === pending) S.pending = null
344      }
345      // a handon that threw left the phase at resume: the chain ends here, and the next turn end retries (resumeTries)
346      if (!latest()) return
347      setPhase('idle')
348      S.resumeTries = 0
349      await persist($)
350      S.tokens = 0
351      await sync($, 0)
352      if (!pending.delivered) await sendPrompt($, text)
353    } finally {
354      if (latest()) S.inFlight = false
355    }
356  }))
357}
358
359// What the handon submission of a continuing loop carries to the model: the person's continuation prompt, said as
360// theirs, to follow in the same turn. Called by the module's one prompt.submit hook (index.tsx) with each submission;
361// undefined for every other one. The first matching submission takes it.
362export function handoffContinuation(e: { command?: { name: string; args: string } }): string | undefined {
363  const p = S.pending
364  if (!p || p.delivered || !e.command || !/(^|:)handon$/.test(e.command.name) || e.command.args.trim() !== p.id) return undefined
365  p.delivered = true
366  const how = p.text.startsWith('/')
367    ? `run this slash command (the Skill tool, the command name as the skill and the rest as its arguments): ${p.text}`
368    : p.text
369  return `ahandoff: the person has already told you what to do once the handoff is loaded, so do not stop after loading it or wait for them. In this same turn, after loading it, continue as they asked: ${how}`
370}
371
372// Runs the armed prompt as the next turn, detached like queueHandoff ($.prompt.submit is refused
373// inside a hook the turn waits on), PROMPT_DELAY_MS after the command that armed it ended. The host refuses a
374// submitted text starting with /, so a slash command goes through $.command.run instead.
375function runPrompt($: $, text: string) {
376  detach($, 'prompt', $.clock.sleep(PROMPT_DELAY_MS).then(() => sendPrompt($, text)))
377}
378async function sendPrompt($: $, text: string) {
379  const [, command, args = ''] = /^\/(\S+)\s*([\s\S]*)$/.exec(text) ?? []
380  if (command) {
381    await $.command.run({ command, args })
382    return
383  }
384  const r = await $.prompt.submit({ text })
385  // a hook or a settings hook that refuses the prompt leaves no turn: say so instead of leaving the loop silent
386  if (r.drop !== undefined) $.ui.toast(`ahandoff: the prompt was refused — ${r.drop}`)
387}
388
389// `/junkd:ahandoff status`: armed or off, the limit as it stands, the last measurement, and the log's path; then the
390// loop's own state (loopState), one `label value` line each, the threshold's source last.
391async function statusText($: $): Promise<string> {
392  const path = await logPath($)
393  const last = S.last ? `${new Date(S.lastAt).toISOString()} ${S.last}` : 'none yet'
394  const head = `handoff auto ${S.armed ? 'on' : 'off'} — threshold ${thresholdText()} — limit ${kTokens(limit())} — last measurement: ${last} — log: ${path ?? 'none (no session id)'}`
395  return [head, ...loopState().map(([k, v]) => `${k} ${v}`)].join('\n')
396}
397
398async function panel($: $) {
399  await toggle($)
400  return {}
401}
402async function auto($: $, prompt: string, pane = true) {
403  if (!S.enabled) return { text: OFF }
404  // the pane opens first: only an open made while the command runs is placed at any width
405  const opened = pane ? openPane($, 'auto') : undefined
406  await arm($)
407  await opened
408  if (!prompt) return { text: `handoff auto on — threshold ${thresholdText()}` }
409  runPrompt($, prompt)
410  return { text: `handoff auto on — threshold ${thresholdText()} — running: ${prompt}` }
411}
412async function off($: $) {
413  await disarm($)
414  return { text: 'handoff auto off.' }
415}
416
417// Who opened the pane through handoffShow({ owner }) (the /junkd set of panes); any other open or close clears
418// it, so `hide({ owner })` closes only a pane that owner opened and nobody reopened since.
419let owner: string | undefined
420
421// `$.ui.open` before any await: only an open made while the press or command runs counts as asked.
422async function openPane($: $, route?: Route, focus = true) {
423  const opened = $.ui.open({ id: PANE, title: 'handoff', focus: focus || undefined })
424  owner = undefined
425  if (route) S.route = route
426  S.paneOpen = (await opened).isPlaced
427  await sync($)
428  detach($, 'load', load($))
429}
430
431// The engine does not raise our own `$.ui.close` to our `ui.close` hook, so we clear the flag here.
432async function closePane($: $) {
433  S.paneOpen = false
434  owner = undefined
435  await $.ui.close({ id: PANE })
436  await sync($)
437}
438
439function toggle($: $, route?: Route) {
440  return S.paneOpen ? closePane($) : openPane($, route)
441}
442
443// A threshold the host's window can't hold would never fire; say so in the pane and a toast. ponytail: a warning, not a
444// clamp — the window is not known before the first measurement, and what to lower it to is the person's call.
445function warnUnreachable($: $) {
446  if (S.window <= 0 || thresholdTokens() < S.window) return
447  const text = `ahandoff: threshold ${thresholdText()} is not below the ${kTokens(S.window)} window — it will never fire, lower it`
448  S.note = `✗ ${text}`
449  $.ui.toast(text)
450}
451
452async function arm($: $) {
453  if (!S.enabled) {
454    S.note = `✗ ${OFF}`
455    redraw($)
456    return
457  }
458  S.armed = true
459  setPhase('idle')
460  S.resumeTries = 0
461  S.calls = 0
462  S.asks = 0
463  S.external = null
464  S.armedAt = Date.now()
465  warnUnreachable($)
466  await persist($)
467  await sync($)
468}
469
470// Drops the cycle too: a handoff written but not yet acted on, and the fresh-window guard, are the old loop's.
471async function disarm($: $) {
472  S.armed = false
473  cancelResume()
474  setPhase('idle')
475  S.written = null
476  S.candidate = null
477  S.clearTries = 0
478  S.resumeTries = 0
479  S.fresh = false
480  S.base = 0 // the baseline belongs to the window a handoff of this loop started; a loop armed again starts from none
481  S.armedAt = null
482  S.asks = 0
483  S.external = null
484  await persist($)
485  await sync($)
486}
487
488async function remove($: $, ids: string[]) {
489  const entries = await listHandoffs($)
490  const paths = entries.filter(x => ids.includes(x.id)).map(x => x.path)
491  if (paths.length) await $.process.run(['rm', '--', ...paths])
492  S.entries = null
493  S.confirm = null
494  await listHandoffs($)
495  return paths.length
496}
497
498// Writes one field to its `/config` row (`junkd.handoff_<field>`) and mirrors it into S. `{ deny }` when the value
499// does not fit or a hook refused; the pane's note says which either way.
500async function setOption($: $, field: Field, raw: string | number | boolean) {
501  const c = coerce(field, raw)
502  if (c.error !== undefined) {
503    S.note = `✗ ${c.error}`
504    redraw($)
505    return { deny: c.error }
506  }
507  const value = c.value
508  const { deny } = await $.config.set({ key: configKey(keyOf('handoff', field)), value })
509  if (deny) {
510    S.note = `✗ ${deny}`
511    redraw($)
512    return { deny }
513  }
514  S.note = `✓ ${field} = ${String(value)}`
515  await apply($, field, value)
516  return { value: current(field) } as const
517}
518
519// A threshold changed while the loop is armed: limit() reads S.threshold on every measurement, so the next one compares with
520// the new limit; the baseline (S.base) is the window's, not the threshold's, and stays. Say so in the log and a toast.
521async function rearmed($: $, hook: Hook) {
522  await measured($, hook, S.percent, 'skip:threshold-changed', 'rearmed')
523  $.ui.toast(`ahandoff: threshold now ${thresholdText()}`)
524}
525
526// One field as its row took it, into S — the one place a write lands, from setOption or the /config menu's hook.
527async function apply($: $, field: Field, value: number | boolean) {
528  if (field === 'threshold') {
529    const changed = S.threshold !== value // a write can reach apply twice (the hook and setOption): the second changes nothing
530    S.threshold = value as number
531    S.thresholdSrc = 'settings'
532    if (changed && S.armed) await rearmed($, 'config.set')
533  }
534  else if (field === 'checkEvery') S.checkEvery = value as number
535  else if (field === 'staleDays') { S.staleDays = value as number; S.entries = null; detach($, 'load', load($)) }
536  else if (field === 'segment') S.segment = value as boolean
537  else if (field === 'autoOn') S.autoOn = value as boolean
538  else if (field === 'resumeAfterLimit') { S.resumeAfterLimit = value as boolean; if (!value) cancelResume() }
539  else if (field === 'enabled') await applyEnabled($, value as boolean)
540  await sync($)
541}
542
543// `enabled` as the row took it. Off disarms the loop (and drops a pending 5h-limit resume), and arm() refuses while
544// it is off, so the loop's hooks need only `S.armed`. On arms nothing: the loop waits for /junkd:ahandoff (autoOn arms at
545// load only).
546async function applyEnabled($: $, on: boolean) {
547  S.enabled = on
548  if (!on && S.armed) await disarm($)
549}
550
551function api($: $): Api {
552  return {
553    go: route => {
554      S.route = route
555      S.note = ''
556      S.confirm = null
557      S.viewing = null
558      detach($, 'sync', sync($))
559      detach($, 'load', load($))
560    },
561    arm: () => arm($),
562    disarm: () => detach($, 'disarm', disarm($)),
563    handoffNow: async () => {
564      if (S.armed) {
565        setPhase('handoff')
566        S.asks = 1 // the queued /junkd:handoff is the first ask, as the turn-end fire counts it
567      }
568      S.note = '✓ handoff queued'
569      queueHandoff($)
570      await sync($)
571    },
572    resume: async id => continueFrom($, id),
573    handon: async id => {
574      await $.command.run({ command: await findCommand($, 'handon'), args: id })
575    },
576    copy: async (id, surface) => {
577      const r = await $.ui.copy({ text: id, surface: surface as RenderSurface })
578      S.note = r.isCopied ? `✓ copied ${id}` : `✗ could not copy ${id}`
579      redraw($)
580    },
581    remove: async id => {
582      S.note = (await remove($, [id])) ? `✓ deleted ${id}` : `✗ ${id} not found`
583      redraw($)
584    },
585    deleteStale: async () => {
586      const stale = (await listHandoffs($, true)).filter(x => x.stale).map(x => x.id)
587      S.note = `✓ deleted ${await remove($, stale)} stale handoff(s)`
588      redraw($)
589    },
590    openFolder: async () => {
591      const r = await $.process.run(['open', await handoffDir($)])
592      if (r.exitCode !== 0) S.note = `✗ ${r.stderr.trim() || 'could not open the folder'}`
593      redraw($)
594    },
595    set: async (field, value) => { await setOption($, field, value) },
596    refresh: () => {
597      redraw($)
598      detach($, 'load', load($))
599    },
600  }
601}
602
603// ------------------------------------------------------------------ what index.tsx and dispatch.ts call
604
605// $.junkd.open's route is any pane's tab: past this check it is one of handoff's own.
606function checkRoute(route: unknown): asserts route is Route | undefined {
607  if (route !== undefined && !TABS.some(t => t[2] === route)) throw new Error(`handoff: no tab ${String(route)}`)
608}
609
610export async function handoffGet(_$: $, field: string): Promise<number | boolean> {
611  return current(field as Field)
612}
613export async function handoffSet($: $, field: string, raw: unknown): Promise<SetResult> {
614  if (!isField(field)) return { deny: `junkd: no setting ${keyOf('handoff', field)}` }
615  const r = await setOption($, field, raw as string | number | boolean)
616  return r.deny !== undefined ? { deny: r.deny } : { value: r.value }
617}
618// A route while the pane is up switches its tab; otherwise the call toggles the pane, as /junkd:handoff panel does.
619export async function handoffOpen($: $, input: OpenInput = {}): Promise<void> {
620  const route = input.route
621  checkRoute(route)
622  if (route && S.paneOpen) {
623    S.route = route
624    await sync($)
625    detach($, 'load', load($))
626  } else await toggle($, route)
627}
628// show never closes: the pane opens (or, up already, is asked again: retitled, its tab switched, the keys asked
629// for), on `route` when given; `owner` marks it as that caller's, for hide.
630export async function handoffShow($: $, input: ShowInput = {}): Promise<void> {
631  checkRoute(input.route)
632  const opened = openPane($, input.route, input.focus === true)
633  owner = input.owner
634  await opened
635}
636// hide closes the pane; with `owner`, only while the pane is still the one that owner's show opened.
637export async function handoffHide($: $, input: HideInput = {}): Promise<void> {
638  if (input.owner !== undefined && input.owner !== owner) return
639  await closePane($)
640}
641export function handoffStatus() {
642  return status()
643}
644
645// A reload of the module (a change in the /config menu, /reload-plugins, a save in a watched plugin folder) runs
646// register again with S back at its defaults and the loop disarmed, while $.state still holds what the session's loop
647// was doing. Carry the loop on from there; a new process finds nothing and stays as its settings say.
648async function restore($: $) {
649  if (S.armed || !S.enabled) return
650  const kept = await read($, view).catch(() => undefined)
651  if (!kept?.on) return
652  S.armed = true
653  S.armedAt ??= Date.now()
654  S.prompt = kept.prompt || S.prompt
655  S.count = kept.count
656  S.lastId = kept.lastId || null
657  S.base = kept.base ?? 0
658  S.fresh = kept.fresh ?? false
659  await measured($, 'session.start', S.percent, 'skip:restored', 'reload')
660  $.ui.toast(`handoff: the plugin reloaded — auto handoff still on, threshold ${thresholdText()}`)
661  if (kept.threshold !== undefined && kept.threshold !== S.threshold) await rearmed($, 'session.start') // the reload carried a new threshold
662}
663
664export async function handoffStart($: $): Promise<void> {
665  // A phase other than idle at a session start the loop's own chain is not running (seen live: a `resume` with nothing
666  // behind it, carried through a /clear by hand, S being the same module object): that cycle is over. The loop stays
667  // armed and starts idle; a chain still waiting to start stops (resetCycle).
668  if (S.phase !== 'idle' && !S.inFlight) {
669    const from = S.phase
670    resetCycle()
671    await measured($, 'session.start', S.percent, 'session:reset-phase', `from=${from}`)
672  }
673  const saved = (await $.store.get(STORE)) as { on?: boolean; prompt?: string | null; pending?: { id: string }; resumeAt?: number } | undefined
674  if (saved?.pending) $.ui.toast(`handoff: pending handoff ${saved.pending.id} — run /junkd:handon ${saved.pending.id}`)
675  if (saved?.resumeAt && saved.resumeAt > Date.now()) {
676    const how = S.enabled ? 're-arm with /junkd:ahandoff' : 'auto handoff is disabled: turn the enabled setting on (/config, or the pane\'s Settings tab), then /junkd:ahandoff'
677    $.ui.toast(`handoff: a 5h-limit resume was due at ${hhmm(saved.resumeAt)} — ${how}`)
678  }
679  if (saved?.resumeAt) await $.store.set(STORE, { ...saved, resumeAt: undefined })
680  await restore($)
681  await sync($)
682}
683
684export async function handoffToolCall($: $, e: ToolCallInput, next: Next<'tool.call'>) {
685  if (e.agentId) return next(e)
686  const isHandoffWrite = e.tool === 'Write' && HANDOFF_FILE.test(String(e.file_path))
687  if (!S.armed) {
688    if (!isHandoffWrite) return next(e)
689    const r = await next(e)
690    record(String(e.file_path))
691    await sync($)
692    return r
693  }
694  if (S.fresh || S.phase !== 'idle') {
695    await measured($, 'tool.call', S.percent, S.fresh ? 'skip:fresh' : `skip:phase-${S.phase}`)
696  } else if (++S.calls % S.checkEvery === 0) {
697    const { percent, used } = await readUsage($)
698    const fire = used >= limit()
699    await measured($, 'tool.call', percent, fire ? 'fire' : 'skip:below-limit', '', used)
700    if (fire) {
701      setPhase('handoff')
702      S.asks = 1 // the deny text is the first ask, as the turn-end fire counts it
703    }
704    await sync($, percent)
705  }
706  if (S.phase !== 'handoff') {
707    if (!isHandoffWrite || S.phase !== 'idle') return next(e)
708    // A handoff this session's main loop writes while the loop is idle (the person's /junkd:handoff by hand) continues the
709    // chain at the turn end like its own. The handoffs folder is global: other sessions' files are not this loop's.
710    const r = await next(e)
711    if (r.deny === undefined && !r.isError) S.external = String(e.file_path)
712    return r
713  }
714  const isHandoffPath = e.tool === 'Bash' && /handoff-path/.test(String(e.command))
715  const isHandoffSkill = e.tool === 'Skill' && /handoff/.test(String((e as { skill?: unknown }).skill))
716  if (isHandoffWrite) {
717    // Only a Write the tool reports done is the cycle's file (seen live: a Write that failed on a full disk, to a path the
718    // model made up when bin/handoff-path could not run, was taken as written); the turn end checks it is on disk too.
719    const path = String(e.file_path)
720    S.candidate = path
721    let done = false
722    try {
723      const r = await next(e)
724      done = r.deny === undefined && !r.isError
725      return r
726    } finally {
727      if (S.candidate === path) S.candidate = null
728      if (done) S.written = path
729      else await measured($, 'tool.call', S.percent, 'cycle:written-missing', `path=${token(path)},write-failed`)
730    }
731  }
732  if (isHandoffPath || isHandoffSkill) return next(e)
733  return {
734    deny: 'ahandoff: context limit reached. Stop the current work now. Call the Skill tool with skill "junkd:handoff" and follow it (run its bin/handoff-path, then Write the handoff markdown to the path it prints — include task ids, branch, worktree, round and what to do next). Then end the turn with one line. Every other tool is denied until the handoff is written.',
735  }
736}
737
738// The status-line segment: 🪃 <used>k/<limit>k (green, yellow within 10% of the limit, red past it). undefined while
739// handoff is off or the segment setting is.
740export function handoffSegment($: $, e: RenderInput<'PromptHint'>) {
741  if (!S.enabled || !S.segment) return undefined
742  const { Button, Text } = $.ui.resolve(e)
743  const label = !S.armed
744    ? '🪃 off'
745    : S.blocked && S.resumeAt ? `🪃 limit → ${hhmm(S.resumeAt)}`
746    : S.phase === 'handoff' ? '🪃 handoff…' : S.phase === 'idle' ? `🪃 ${kTokens(S.tokens)}/${kTokens(limit())}` : '🪃 resume…'
747  const color = !S.armed || S.phase !== 'idle'
748    ? P.secondary
749    : S.blocked ? P.warn
750    : S.tokens >= limit() ? P.bad : S.tokens >= limit() * 0.9 ? P.warn : P.ok
751  return (
752    <Button key="handoff-segment" plain label={label} onPress={() => detach($, 'segment', toggle($))}>
753      <Text color={color}>{label}</Text>
754    </Button>
755  )
756}
757
758// The loop's turn end, without the next(e) that its hook runs in a finally; `at.stage` names the step it is on.
759async function turnEnd($: $, e: { agentId?: string; reason?: string; answer?: unknown }, at: { stage: string }): Promise<void> {
760  toolsTurnEnd($)
761  at.stage = 'dashboard'
762  detach($, 'dashboard', dashboardTurnEnd($)) // the orch dashboard's status.js, while it is open; never throws
763  at.stage = 'lessons'
764  await lessonsTurnEnd($, e) // the lessons stores again, and the dialog when the answer prints BACKLOG:; never throws
765  if (e.agentId) return
766  // One loop turn end at a time (seen in a probe: two in flight both read the written file, both logged cycle:continue and
767  // both followed; two at resume spent two retries). Taken before the loop's first await, so a second one that comes
768  // while the first runs decides nothing: it logs skip:turn-busy, and the next turn end sees what the first one left.
769  if (S.turnBusy) return measured($, 'turn.complete', S.percent, 'skip:turn-busy')
770  S.turnBusy = true
771  try {
772    await loopTurnEnd($, e, at)
773  } finally {
774    S.turnBusy = false
775  }
776}
777
778// The loop's part of a turn end, under S.turnBusy (turnEnd).
779async function loopTurnEnd($: $, e: { reason?: string }, at: { stage: string }): Promise<void> {
780  at.stage = 'disarmed'
781  if (!S.armed) return measured($, 'turn.complete', S.percent, 'skip:disarmed')
782  // ponytail: the host names no error kind here — an error with the 5h window at 99%+ is the limit.
783  if (e.reason === 'error' && S.resumeAfterLimit && S.fiveHour >= 99) {
784    at.stage = 'five-hour-limit'
785    await measured($, 'turn.complete', S.percent, 'skip:five-hour-limit')
786    return scheduleResume($)
787  }
788  at.stage = 'usage'
789  const { percent, used } = await readUsage($)
790  const m = (decision: string, note = '') => measured($, 'turn.complete', percent, decision, note, used)
791  at.stage = 'written'
792  // The loop's own handoff (its Write reported done) and an external one must be in the handoffs folder, on disk under a
793  // handoff name, before a cycle goes on from them (notHandoff); one that is not is dropped, and for the loop's own the turn counts as one more ask.
794  const own = S.phase === 'handoff' && S.written ? await notHandoff($, S.written) : undefined
795  if (own !== undefined) {
796    await m('cycle:written-missing', `path=${token(S.written)},${own}`)
797    S.written = null
798  }
799  const other = S.phase === 'idle' && S.external ? await notHandoff($, S.external) : undefined
800  if (other !== undefined) {
801    await m('cycle:written-missing', `path=${token(S.external)},${other}`)
802    S.external = null
803  }
804  // A handoff this session's main loop wrote while armed that the loop did not ask for (handoffToolCall noted it).
805  const external = S.phase === 'idle' ? S.external : null
806  at.stage = 'decide'
807  if (S.fresh) {
808    // nothing: the first measurement of the new window decides (session.measure)
809    await m('skip:fresh')
810  } else if (S.phase === 'handoff' && !S.written && S.asks >= MAX_ASKS) {
811    // asked MAX_ASKS times, no file: stop here instead of asking every turn for ever
812    at.stage = 'give-up-asks'
813    await m(`give-up:${MAX_ASKS}-tries`)
814    await disarm($)
815    $.ui.toast(`ahandoff: handoff not written after ${MAX_ASKS} tries — disarmed`)
816  } else if (S.phase === 'clear') {
817    // the cycle's /clear was lost (a clear in flight has fresh on, and the branch above skips): ask again, MAX_CLEAR_RETRIES times
818    at.stage = 'retry-clear'
819    if (S.clearTries >= MAX_CLEAR_RETRIES) {
820      await m('give-up:clear')
821      await disarm($)
822      $.ui.toast(`ahandoff: /clear failed ${MAX_CLEAR_RETRIES + 1} times — disarmed`)
823    } else {
824      S.clearTries++
825      await m('cycle:retry-clear')
826      continueFrom($, S.lastId!)
827    }
828  } else if (S.phase === 'resume' && !S.inFlight) {
829    // the cycle's resume has nothing running it (seen live: the chain was never started): start it again,
830    // MAX_RESUME_RETRIES times, then back to idle, still armed. A handon in flight (S.inFlight) is never retried over.
831    at.stage = 'retry-resume'
832    if (S.resumeTries >= MAX_RESUME_RETRIES || !S.lastId) {
833      const tries = S.resumeTries + 1
834      await m('give-up:resume', `n=${tries}`)
835      resetCycle()
836      await persist($)
837      $.ui.toast(`auto-handoff: resume failed ${tries} times, loop reset to idle; still armed`)
838    } else {
839      S.resumeTries++
840      await m('cycle:retry-resume', `n=${S.resumeTries}`)
841      continueFrom($, S.lastId)
842    }
843  } else if (S.phase === 'handoff' && !S.written) {
844    at.stage = 'ask-handoff'
845    await m('cycle:ask-handoff')
846    S.asks++
847    queueHandoff($) // model never wrote it mid-turn: ask /junkd:handoff as the next turn
848  } else if (external) {
849    at.stage = 'external'
850    await m('fire:external-handoff')
851    follow($, external)
852  } else if (S.phase === 'idle' && used >= limit()) {
853    at.stage = 'fire'
854    await m('fire')
855    setPhase('handoff')
856    S.asks = 1
857    queueHandoff($)
858  } else if (S.phase === 'handoff' && S.written) {
859    at.stage = 'continue'
860    const path = S.written
861    await m('cycle:continue', `path=${token(path)}`)
862    S.written = null
863    follow($, path)
864  } else {
865    await m(S.phase === 'idle' ? 'skip:below-limit' : `skip:phase-${S.phase}`)
866  }
867  at.stage = 'sync'
868  await sync($, percent)
869}
870
871export function registerHandoff(on: On, options: Record<string, unknown>): void {
872  loadOptions(groupOptions(options, 'handoff'))
873
874  // /junkd:handoff panel | auto [prompt] | off — anything else is the plain skill.
875  on('command.run', { command: 'junkd:handoff' }, async ($, e, next) => {
876    const [sub = '', ...rest] = e.args.trim().split(/\s+/)
877    if (sub === 'panel') return panel($)
878    if (sub === 'auto') return auto($, rest.join(' '))
879    if (sub === 'off') return off($)
880    return next(e)
881  })
882
883  // /junkd:ahandoff [prompt] | off | panel — the short form: anything else is the prompt. Its skill's text runs
884  // only when this module is not loaded.
885  on('command.run', { command: 'junkd:ahandoff' }, async ($, e) => {
886    const args = e.args.trim()
887    if (args === 'panel') return panel($)
888    if (args === 'off') return off($)
889    if (args === 'status') return { text: await statusText($) }
890    return auto($, args, false) // ponytail: the short form arms and runs the prompt; the pane is /junkd:ahandoff panel
891  })
892
893  // The /config menu's own writes to every handoff row pass here (a hook never sees its plugin's own
894  // `$.config.set`): read as `set` reads it, refused where `set` refuses, and applied live once the row took it.
895  on('config.set', { key: configKeys('handoff') }, async ($, e, next) => {
896    const field = fieldOf('handoff', e.key)
897    if (!isField(field)) return next(e)
898    const c = coerce(field, e.value)
899    if (c.error !== undefined) return { deny: c.error }
900    const r = await next({ ...e, value: c.value })
901    if (r.deny === undefined) await apply($, field, c.value)
902    return r
903  })
904
905  // The module's one turn.complete hook: the tools pane reads its tree again at every turn's end, first. Whatever a stage
906  // throws is logged (`hook:error stage=<s>`) and the turn still passes on: sync and next(e) run whatever happened
907  // (seen live: a rejection here skipped both and nothing said so).
908  on('turn.complete', async ($, e, next) => {
909    const at = { stage: 'tools-pane' }
910    let r!: ReturnType<typeof next>
911    try {
912      await turnEnd($, e, at)
913    } catch (err) {
914      await hookError($, 'turn.complete', at.stage, err)
915      try {
916        await sync($)
917      } catch {}
918    } finally {
919      r = next(e)
920    }
921    return r
922  })
923
924  on('session.measure', async ($, e, next) => {
925    const fiveHour = e.rateLimits.find(r => r.kind === 'five_hour')
926    if (fiveHour) {
927      S.fiveHour = fiveHour.percentUsed
928      S.resetsAt = fiveHour.resetsAt ?? S.resetsAt
929    }
930    if (e.changed.includes('context')) {
931      S.tokens = e.context.tokens ?? S.tokens
932      const was = S.window
933      S.window = e.context.window ?? S.window
934      if (S.armed && !S.fresh && S.window !== was) warnUnreachable($) // the window is known only now
935      const percent = e.context.percent ?? S.percent
936      const used = e.context.tokens ?? S.tokens
937      let note = e.context.percent === undefined ? 'no-percent' : ''
938      // The baseline is the fill of a window THIS loop's handoff started (system prompt + handon): a limit that
939      // sits at or under it counts growth from it. Any other window (a fresh session, the one the loop was armed
940      // in) has none: its limit is the threshold itself, whatever the first turns already filled.
941      if (e.context.tokens !== undefined && S.fresh) {
942        S.base = used
943        note = 'base-set'
944        if (S.armed && S.fresh) {
945          note = 'base-set,fresh-cleared'
946          S.fresh = false
947          if (S.window > 0 && limit() >= S.window) {
948            // ponytail: a limit no window can reach would never hand off — disarm instead.
949            S.armed = false
950            setPhase('idle')
951            note = 'base-set,fresh-cleared,auto-disarmed'
952            await persist($)
953            $.ui.toast(`handoff: fresh window at ${kTokens(used)}, ${thresholdText()} more can't fit — auto disarmed`)
954          } else if (thresholdTokens() <= S.base) {
955            $.ui.toast(`handoff: fresh window at ${kTokens(used)}, next handoff at ${kTokens(limit())}`)
956          }
957        }
958      }
959      await measured($, 'session.measure', percent, 'skip:measure-only', note, used)
960      await sync($, percent)
961    }
962    return next(e)
963  })
964
965  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
966    const r = await next(e)
967    if (!r.deny) S.ring = e.element ?? null
968    return r
969  })
970
971  // The person's closes (q, ✕) pass here; ours go through closePane.
972  on('ui.close', { id: PANE }, async ($, e, next) => {
973    const isSet = isSets(owner)
974    S.paneOpen = false
975    owner = undefined
976    const r = await next(e)
977    if (isSet) await shutSet($)
978    return r
979  })
980
981  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e, next) => {
982    if (e.surface === 'mobile') return next(e)
983    S.cols = Math.max(20, (e.props.bodyColumns ?? 100) - EDGE)
984    const a = api($)
985    const ui = fields($.ui.resolve(e) as Elements, a, e.surface, key => detach($, 'focus', $.ui.focus({ requestId: PANE, key })))
986    const c: Ctx = { api: a, ui, surface: e.surface }
987    const screen = VIEWS[S.route](c)
988    // Docked, the pane is floor to ceiling: the body takes what is left, the footer sits at the bottom.
989    const rows = e.props.placement === 'dock' ? e.props.scroll?.bodyRows : undefined
990    return (
991      <ui.Box flexDirection="column" paddingRight={EDGE} minHeight={rows}>
992        <Tabs c={c} close={() => detach($, 'close', isSets(owner) ? shutSet($) : closePane($))} />
993        <Rule c={c} />
994        <ui.Box flexDirection="column" flexGrow={1} marginTop={1}>
995          {screen.body}
996        </ui.Box>
997        <Footer c={c} note={S.note || screen.note} hints={screen.keys} />
998      </ui.Box>
999    )
1000  })
1001}
1002
hooks/orch.tsx 389 lines
1// orch: the main-loop guard of orchestrator mode (tool.call, agent.spawn), the settings pane (/junkd:orch panel)
2// and the `orch` settings group. The `$`-free parts are orch-lib.ts (settings, the guard's rules) and orch-views.tsx
3// (the pane). index.tsx runs orchToolDeny inside the one tool.call hook and orchStart at session start.
4// `enabled` off switches the guard off whole (tool.call, agent.spawn and their .catch pass, guardState is inactive)
5// and, through the flag file junkd/orch-off, the rule sheet scripts/orch-context.sh prints; the pane still works.
6import type { EngineInterface, On, RenderInput, ToolCallInput } from 'claude-code'
7import type { JunkdOrchField as OrchField, JunkdOrchGuard as OrchGuard } from '../types'
8import type { HideInput, OpenInput, SetResult, ShowInput } from './feature'
9import { isSets, shutSet } from './set'
10import { O, PREFIX, applyOrchSettings, coerce, guardActive, isField, orchGuard, orchSpawn, recipient, transcriptTokens, type AgentRecord, type OrchState } from './orch-lib'
11import { orchView, type Els } from './orch-views'
12import { configKey, configKeys, fieldOf, groupOptions, keyOf } from './settings'
13import { toolsSpawned } from './tools-pane'
14import { dashboardAgentStop, dashboardClose, dashboardOpen, dashboardRetime, dashboardSpawned } from './dashboard.tsx'
15import { watchSpawned, watchStopped, watchdogApplied, watchdogReport } from './watchdog.tsx'
16import { lessonsApplied, lessonsRunStart } from './lessons.tsx'
17
18type $ = EngineInterface
19
20const PANE = 'orch'
21
22let home = ''
23async function homeDir($: $): Promise<string> {
24  if (home) return home
25  home = (await $.env.get('HOME')) ?? ''
26  if (!home) home = (await $.process.run(['sh', '-c', 'echo $HOME'])).stdout.trim()
27  return home
28}
29// Paths follow CLAUDE_CONFIG_DIR like Claude Code itself; tests point it at a fake tree.
30async function configDir($: $) {
31  return (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await homeDir($)}/.claude`
32}
33
34// ------------------------------------------------------------------ the guard's inputs
35
36// The agents that stopped this session, by id and by name: their type and the context tokens of their transcript's
37// last usage record (classic.SubagentStop below). SendMessage reaches a stopped agent, and this is what tells
38// whether it is a reviewer or over the context cap. Lost with the module (a /reload-plugins): the record on disk
39// comes first.
40const records = new Map<string, AgentRecord>()
41
42// The record scripts/orch-agent-stop.sh wrote at an agent's SubagentStop: ${CLAUDE_CONFIG_DIR}/junkd/orch/_agents/
43// <session id>/<agent id or name>.json, {type, tokens}. undefined when there is none, it does not parse, it holds
44// neither field, or the session id or the key is no plain id (never read as a path).
45const PLAIN_ID = /^[A-Za-z0-9_-]+$/
46async function savedRecord($: $, key: string): Promise<AgentRecord | undefined> {
47  const sid = await $.session.id().catch(() => '')
48  if (!PLAIN_ID.test(sid) || !PLAIN_ID.test(key)) return undefined
49  const text = await $.fs.read(`${await configDir($)}/junkd/orch/_agents/${sid}/${key}.json`).catch(() => undefined)
50  let saved: unknown
51  try {
52    saved = text === undefined ? undefined : JSON.parse(text)
53  } catch {
54    return undefined
55  }
56  if (typeof saved !== 'object' || saved === null) return undefined
57  const { type, tokens } = saved as Record<string, unknown>
58  const record: AgentRecord = { type: typeof type === 'string' ? type : undefined, tokens: typeof tokens === 'number' ? tokens : undefined }
59  return record.type === undefined && record.tokens === undefined ? undefined : record
60}
61
62// SendMessage's target: its type from the engine's agent list (by id, or by name, " [ref]" dropped), else from its
63// SubagentStop record (by id, and by name for a named agent), which also holds its context tokens: the one on disk,
64// else the one in memory. null: none of them knows it.
65async function orchAgent($: $, to: string): Promise<AgentRecord | null> {
66  const key = recipient(to)
67  const low = key.toLowerCase() // ids are hex: their case does not tell agents apart
68  const listed = (await $.agent.list().catch(() => [])).find(a => a.id.toLowerCase() === low || a.name === key)
69  const id = listed?.id ?? (/^a[0-9a-f]+$/.test(low) ? low : key)
70  const saved = (await savedRecord($, id)) ?? records.get(id) ?? records.get(key)
71  if (!listed && !saved) return null
72  return { type: listed?.type ?? saved?.type, tokens: saved?.tokens }
73}
74
75// Where a path lands, the d.ts `placed` pattern: the file's real path when it exists, else its folder's plus the
76// name; undefined when neither resolves. Only absolute paths reach it (the guard refuses any other spelling first).
77// A link that leads nowhere is undefined too: Write would follow it and create its target, wherever that is. stat
78// follows a link, so the folder's listing (each entry as it stands, an lstat) tells; an unreadable folder is
79// undefined. Names compare case-blind, as macOS resolves them.
80async function realPath($: $, path: string): Promise<string | undefined> {
81  return (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath
82}
83async function placed($: $, path: string): Promise<string | undefined> {
84  const cut = path.lastIndexOf('/')
85  const name = path.slice(cut + 1)
86  if (cut < 0 || name === '' || name === '.' || name === '..') return undefined
87  const own = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
88  if (own) return own.realPath
89  const dir = (await realPath($, path.slice(0, cut + 1)))?.replace(/\/$/, '')
90  if (dir === undefined) return undefined
91  const entries = await $.fs.list(dir || '/').catch(() => undefined)
92  const lower = name.toLowerCase()
93  return entries && !entries.some(x => x.isLink && x.name.toLowerCase() === lower) ? `${dir}/${name}` : undefined
94}
95
96// A Read or Write path as it lands, with the config dir, the plugin's bin/ and the repo resolved alike (a dir that
97// doesn't resolve keeps its spelling). ponytail: the dirs are resolved on every call; cache them if it ever shows.
98async function orchPlace($: $, path: string, cfg: string, repo: string): Promise<OrchState['placed']> {
99  const to = path.startsWith('/') ? await placed($, path) : undefined
100  if (to === undefined) return null
101  const root = (await realPath($, $.plugin.root)) ?? $.plugin.root
102  return {
103    path: to,
104    configDir: (await realPath($, cfg)) ?? cfg,
105    binDir: `${root.replace(/\/$/, '')}/bin/`,
106    repo: repo && ((await realPath($, repo)) ?? repo),
107  }
108}
109
110// The session's repo (git's toplevel of its cwd), whose .orch/ is the task store /junkd:orch reads first; '' when the
111// cwd is in no repo or git fails. Cached per cwd: a `/cd` moves it.
112const repos = new Map<string, string>()
113async function sessionRepo($: $): Promise<string> {
114  const cwd = await $.session.cwd().catch(() => '')
115  if (!cwd) return ''
116  let repo = repos.get(cwd)
117  if (repo === undefined) {
118    const r = await $.process.run(['git', '-C', cwd, 'rev-parse', '--show-toplevel']).catch(() => undefined)
119    repo = r?.exitCode === 0 ? r.stdout.trim() : ''
120    repos.set(cwd, repo)
121  }
122  return repo
123}
124
125// The main loop's model, read live so a /model switch counts at once. ponytail: a model that can't be read is no
126// Fable: the guard fails open rather than block a session, and says so once per session.
127let warned = false
128async function liveModel($: $): Promise<string> {
129  const model = await $.session.model().catch(() => '')
130  if (!model && !warned) {
131    warned = true
132    $.ui.toast('orch: the main-loop model could not be read, so the fable guard is off this session')
133  }
134  return model
135}
136
137// The guard's inputs, awaited here so orchGuard stays pure.
138async function orchCheck($: $, e: { tool: string; agentId?: string; [k: string]: unknown }) {
139  if (!O.enabled || e.agentId || O.guard === 'off') return undefined
140  const model = O.guard === 'fable' ? await liveModel($) : ''
141  if (!guardActive(model)) return undefined
142  const cfg = await configDir($)
143  const exists = e.tool === 'Write' ? await $.fs.exists(String(e.file_path ?? '')).catch(() => true) : undefined
144  const agent = e.tool === 'SendMessage' ? await orchAgent($, String(e.to)) : undefined
145  const repo = e.tool === 'Read' || e.tool === 'Write' ? await sessionRepo($) : ''
146  const where = e.tool === 'Read' || e.tool === 'Write' ? await orchPlace($, String(e.file_path ?? ''), cfg, repo) : undefined
147  return orchGuard(e, { model, binDir: `${$.plugin.root}/bin/`, configDir: cfg, repo, exists, agent, placed: where })
148}
149
150// ------------------------------------------------------------------ settings
151
152let note = ''
153let paneOpen = false
154function redraw($: $) {
155  return $.ui.invalidate('ui.render')
156}
157
158// enabled off, inject_rules off and lessons off are the flag files scripts/orch-context.sh falls back to when
159// settings.json holds no value it can read (a shell hook reads no `$.config`): ${CLAUDE_CONFIG_DIR}/junkd/orch-off,
160// .../junkd/orch-rules-off and .../junkd/orch-lessons-off, each written at every change of its row and at session start.
161const FLAGS = { enabled: 'orch-off', inject_rules: 'orch-rules-off', lessons: 'orch-lessons-off' } as const
162async function writeFlag($: $, key: keyof typeof FLAGS): Promise<void> {
163  const flag = `${await configDir($)}/junkd/${FLAGS[key]}`
164  // Something that is no file under the flag's name (a directory) is not ours: the hook reads only a file (`-f`), so
165  // it is left alone and said once, never written over or removed.
166  if ((await $.fs.exists(flag)) && (await $.fs.stat(flag)).kind !== 'file') {
167    $.ui.toast(`orch: ${flag} is not a file, so the ${key} setting is not mirrored there; remove it`)
168    return
169  }
170  if (O[key]) await $.process.run(['rm', '-f', '--', flag])
171  else await $.fs.write(flag, '')
172}
173
174// claude_md: scripts/orch-claude-md.sh on|off over ${CLAUDE_CONFIG_DIR}/CLAUDE.md, at once (scripts/setup.sh runs it
175// again at every session start). Not while orch is disabled, as at session start. Its `updated` line, or the reason
176// it refused, is a toast; a run that cannot start is one too, and never fails the write.
177async function runClaudeMd($: $): Promise<void> {
178  if (!O.enabled) return
179  const mode = O.claude_md ? 'on' : 'off'
180  try {
181    const r = await $.process.run([`${$.plugin.root}/scripts/orch-claude-md.sh`, mode], { env: { CLAUDE_CONFIG_DIR: await configDir($) } })
182    const line = r.exitCode === 0 ? r.stdout.split('\n').find(l => l.startsWith('updated ')) : r.stderr.trim().split('\n').at(-1) || `exit ${r.exitCode}`
183    if (line) $.ui.toast(`orch: ${line}`)
184  } catch (err) {
185    $.ui.toast(`orch: orch-claude-md.sh ${mode} did not run: ${String((err as Error).message ?? err)}`)
186  }
187}
188
189// A value the row took lands in O (and, for enabled and inject_rules, in the flag file; claude_md runs its script).
190// Idempotent: a write of ours passes the other plugins' config.set hooks and skips our own, so setOption applies it;
191// the menu's write reaches the hook.
192async function apply($: $, key: OrchField, value: OrchGuard | number | boolean): Promise<void> {
193  ;(O as Record<string, unknown>)[key] = value
194  if (key === 'enabled' || key === 'inject_rules' || key === 'lessons') await writeFlag($, key)
195  if (key === 'claude_md') await runClaudeMd($)
196  if (key === 'dashboard_refresh') dashboardRetime($)
197  await watchdogApplied($) // the watchdog's timer follows enabled and watchdog
198  if (key === 'enabled' || key === 'lessons') await lessonsApplied($) // the tab, the segment and the dialog follow them
199  redraw($)
200}
201
202// Writes one field to its `/config` row (`junkd.orch_<field>`) and mirrors it into O. `{ deny }` when the value does not
203// fit or a hook refused; the pane's note says which either way.
204async function setOption($: $, key: OrchField, raw: unknown) {
205  const c = coerce(key, raw)
206  if (c.error !== undefined) {
207    note = `✗ ${c.error}`
208    redraw($)
209    return { deny: c.error }
210  }
211  const { deny } = await $.config.set({ key: configKey(keyOf('orch', key)), value: c.value })
212  if (deny !== undefined) {
213    note = `✗ ${deny}`
214    redraw($)
215    return { deny }
216  }
217  await apply($, key, c.value)
218  note = `✓ ${key} = ${String(c.value)}`
219  redraw($)
220  return { value: O[key] }
221}
222
223// Who opened the pane through orchShow({ owner }) (the /junkd set of panes); any other open or close clears it,
224// so `hide({ owner })` closes only a pane that owner opened and nobody reopened since.
225let owner: string | undefined
226
227// `$.ui.open` before any await: only an open made while the press or command runs counts as asked.
228async function openPane($: $, focus = true) {
229  const opened = $.ui.open({ id: PANE, title: 'orch', focus: focus || undefined })
230  owner = undefined
231  note = ''
232  paneOpen = (await opened).isPlaced
233  redraw($)
234}
235// The engine does not raise our own `$.ui.close` to our `ui.close` hook, so the flag is cleared here.
236async function closePane($: $) {
237  paneOpen = false
238  owner = undefined
239  await $.ui.close({ id: PANE })
240}
241function togglePane($: $) {
242  return (paneOpen ? closePane($) : openPane($))
243}
244
245// ------------------------------------------------------------------ what index.tsx and dispatch.ts call
246
247export async function orchGet(_$: $, field: string): Promise<string | number | boolean> {
248  return (O as Record<string, string | number | boolean>)[field]!
249}
250export async function orchSet($: $, field: string, raw: unknown): Promise<SetResult> {
251  if (!isField(field)) return { deny: `junkd: no setting ${keyOf('orch', field)}` }
252  const r = await setOption($, field, raw)
253  return r.deny !== undefined ? { deny: r.deny } : { value: r.value }
254}
255// open toggles the pane (orch has no tabs: a route is refused).
256export async function orchOpen($: $, input: OpenInput = {}): Promise<void> {
257  if (input.route !== undefined) throw new Error(`junkd: the orch pane has no tab ${String(input.route)}`)
258  await togglePane($)
259}
260// show never closes: the pane opens (or, up already, is asked again: retitled, the keys asked for); `owner` marks
261// it as that caller's, for hide.
262export async function orchShow($: $, input: ShowInput = {}): Promise<void> {
263  const opened = openPane($, input.focus === true)
264  owner = input.owner
265  await opened
266}
267// hide closes the pane; with `owner`, only while the pane is still the one that owner's show opened.
268export async function orchHide($: $, input: HideInput = {}): Promise<void> {
269  if (input.owner !== undefined && input.owner !== owner) return
270  await closePane($)
271}
272// Each flag on its own: one that fails leaves the other written.
273export async function orchStart($: $): Promise<void> {
274  await Promise.all([writeFlag($, 'enabled').catch(() => undefined), writeFlag($, 'inject_rules').catch(() => undefined), writeFlag($, 'lessons').catch(() => undefined)])
275}
276// Inactive while orch is disabled; the model is read either way (only the fable guard's read toasts).
277export async function orchGuardState($: $): Promise<{ active: boolean; model: string }> {
278  const model = O.enabled && O.guard === 'fable' ? await liveModel($) : await $.session.model().catch(() => '')
279  return { active: guardActive(model), model }
280}
281// The guard: the model's own calls on the main loop (index.tsx asks only for those). A deny's reason, or undefined.
282export async function orchToolDeny($: $, e: ToolCallInput): Promise<string | undefined> {
283  return orchCheck($, e as unknown as { tool: string; agentId?: string; [k: string]: unknown })
284}
285// tool.call's and agent.spawn's .catch: a hook that throws is skipped and the call runs, so a main loop the guard may
286// hold (orch enabled, guard not off) refuses instead; a subagent's call (agentId, parentAgentId) passes.
287export function orchCatchDeny(agentId?: string): string | undefined {
288  return O.enabled && O.guard !== 'off' && !agentId ? `${PREFIX} guard error` : undefined
289}
290
291// /junkd:orch watchdog: the verdict table (watchdog.tsx); `on` and `off` flip orch_watchdog as the pane does.
292async function orchWatchdog($: $, arg: string): Promise<{ text?: string }> {
293  const mode = arg.toLowerCase()
294  if (mode === '') {
295    await watchdogReport($)
296    return {}
297  }
298  if (mode !== 'on' && mode !== 'off') return { text: `junkd: /junkd:orch watchdog takes on, off or nothing, not "${arg}"` }
299  const r = await setOption($, 'watchdog', mode === 'on')
300  return { text: r.deny !== undefined ? `✗ ${r.deny}` : `watchdog ${mode}` }
301}
302
303export function registerOrch(on: On, options: Record<string, unknown>): void {
304  applyOrchSettings(groupOptions(options, 'orch'))
305
306  // The /config menu's own writes to an orch row pass here (a hook never sees its plugin's own `$.config.set`): a
307  // value `set` would refuse is refused the same way, and one the row took lands in O through `apply`.
308  on('config.set', { key: configKeys('orch') }, async ($, e, next) => {
309    const key = fieldOf('orch', e.key)
310    if (!isField(key)) return next(e)
311    const c = coerce(key, e.value)
312    if (c.error !== undefined) return { deny: c.error }
313    const r = await next({ ...e, value: c.value })
314    if (r.deny === undefined) await apply($, key, c.value)
315    return r
316  })
317
318  // The spawn as the engine resolved it (fork, model), behind the Agent rule of the guard; the model's own spawns
319  // only, as for tool.call. The module's one agent.spawn hook: the tools pane records each started agent's model and
320  // directory here too (toolsSpawned never throws).
321  on('agent.spawn', async ($, e, next) => {
322    const deny = next.origin.plugin === 'engine' ? orchSpawn(e) : undefined
323    if (deny) return { deny }
324    const r = await next(e)
325    await toolsSpawned($, e, r)
326    await dashboardSpawned($, e, r)
327    await watchSpawned($, e, r) // the watchdog: when, where and by whom (never throws)
328    return r
329  }).catch(($, e, next) => {
330    if (next.called) return next(e)
331    const deny = orchCatchDeny(e.parentAgentId)
332    return deny ? { deny } : next(e)
333  })
334
335  // A stopped agent's type and context, for the SendMessage rule: by its id, and by its name when it has one.
336  // ponytail: the transcript is read whole; tail it through the shell if it ever shows.
337  on('classic.SubagentStop', async ($, e, next) => {
338    const text = await $.fs.read(e.agent_transcript_path).catch(() => '')
339    const record: AgentRecord = { type: e.agent_type, tokens: transcriptTokens(text) }
340    records.set(e.agent_id, record)
341    const name = (await $.agent.list().catch(() => [])).find(a => a.id === e.agent_id)?.name
342    if (name) records.set(name, record)
343    await dashboardAgentStop($, e.agent_id, record.tokens)
344    await watchStopped($, e.agent_id) // the watchdog: this agent reported
345    return next(e)
346  })
347
348  // /junkd:orch panel toggles the pane; anything else is the plain skill.
349  on('command.run', { command: 'junkd:orch' }, async ($, e, next) => {
350    // `dashboard` and `dashboard close`: /junkd:dashboard's own path (dashboard.tsx).
351    const dash = /^dashboard(?:\s+(open|close))?$/i.exec(e.args.trim())
352    if (dash) {
353      const text = dash[1]?.toLowerCase() === 'close' ? await dashboardClose($) : await dashboardOpen($)
354      return text ? { text } : {}
355    }
356    if (/^watchdog(\s|$)/.test(e.args.trim())) return orchWatchdog($, e.args.trim().slice('watchdog'.length).trim())
357    if (e.args.trim() !== 'panel') {
358      await lessonsRunStart($) // a run: lessons pending open the dialog before the skill selects a task
359      return next(e)
360    }
361    await togglePane($)
362    return {}
363  })
364
365  // The person's closes (q, ×) pass here; ours go through closePane.
366  on('ui.close', { id: PANE }, async ($, e, next) => {
367    const isSet = isSets(owner)
368    paneOpen = false
369    owner = undefined
370    const r = await next(e)
371    if (isSet) await shutSet($)
372    return r
373  })
374
375  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e: RenderInput<'Pane'>, next) => {
376    if (e.surface === 'mobile') return next(e)
377    return orchView($.ui.resolve(e) as Els, e.props, { ...O }, note, {
378      close: () => void (isSets(owner) ? shutSet($) : closePane($)),
379      set: (key, value) => void setOption($, key, value),
380      focus: key => void $.ui.focus({ requestId: PANE, key }).catch(() => undefined), // a refused ring is no error
381      refuse: text => {
382        note = text
383        redraw($)
384      },
385      dashboard: () => void dashboardOpen($).then(text => { if (text) { note = text; redraw($) } }),
386    })
387  })
388}
389
hooks/panel.tsx 281 lines
1// The /junkd frame: /junkd, ✚ junkd and q open and close junkd, handoff, orch, statusline, tools and lessons together as
2// tabs (tools only while tools_enabled is on, lessons while orch_enabled and orch_lessons are); the junkd pane has the marketplace's plugins (on/off at user scope), every
3// settings group of this plugin (core, handoff, orch, tldr, ctx, statusline, tools) and the commands. The drawing is
4// junkd-views.tsx, fed closures over `$`.
5// - Plugins: the marketplace's plugins, each on or off in the user's settings.json; junkd is this pane. handoff,
6//   orch, tldr, ctx and statusline are no plugins of their own: each is switched by its group's `enabled` row.
7// - The set: each feature pane through dispatch.ts's showPane/hidePane, marked as the set's (SET_OWNER), so closing
8//   the set leaves a feature pane opened on its own alone. set.ts holds whether the set is up and closeSet: the ×
9//   and q of any pane still the set's, and the engine's close of its tab, call it, so one press closes the whole set.
10// - Settings: the `main` rows of each group in settings.ts order (`enabled` first), read and written through
11//   dispatch.ts; a refusal is the note. `settings ▸` opens the group's pane (handoff on its Settings tab, orch,
12//   statusline, tools on its Agents tab).
13// - Commands: the enabled plugins' slash commands (junkd's own /junkd:* among them) as $.command.list() names them.
14import type { JunkdGroup } from '../types'
15import type { On, RenderInput } from 'claude-code'
16import { getSetting, hidePane, setSetting, showPane, type FeaturePane } from './dispatch'
17import { SET_OWNER, type $ } from './feature'
18import { PANE, SET, closeSet, hideSet, setIsOpen, setOpen } from './set'
19import { panelView, type Command, type Els, type Field, type PluginRow, type Section } from './junkd-views'
20import { GROUPS, isKey, keyOf, rowsOf } from './settings'
21import { T as Tools } from './tools'
22import { O as Orch } from './orch-lib'
23
24type Json = Record<string, any>
25
26const MARKETPLACE = 'junk-drawer'
27// The groups with a pane of their own, which `settings ▸` opens (the feature panes of the set, set.ts).
28const hasPane = (group: string): group is FeaturePane => (SET as readonly string[]).includes(group)
29// The feature panes the set opens now: tools only while tools_enabled is on, lessons while orch and orch_lessons are.
30const opensNow = (): FeaturePane[] => SET.filter(p => (p !== 'tools' || Tools.enabled) && (p !== 'lessons' || (Orch.enabled && Orch.lessons)))
31
32let isChanged = false
33let note = ''
34let marketplace = MARKETPLACE
35let version = '' // the marketplace checkout's short sha; '' when it is no git checkout
36let settingsShown = '~/.claude/settings.json' // the user-scope settings file as the Plugins title names it
37let plugins: PluginRow[] = []
38let sections: Section[] = []
39let commands: Command[] = []
40function redraw($: $) {
41  return $.ui.invalidate('ui.render')
42}
43const reason = (err: unknown) => (err as Error)?.message ?? String(err)
44
45let home = ''
46async function homeDir($: $): Promise<string> {
47  if (home) return home
48  home = (await $.env.get('HOME')) ?? ''
49  if (!home) home = (await $.process.run(['sh', '-c', 'echo $HOME'])).stdout.trim()
50  return home
51}
52async function configDir($: $) {
53  return (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await homeDir($)}/.claude`
54}
55// User-scope settings: ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json, the file `enabledPlugins` lives in.
56async function settingsPath($: $) {
57  return `${await configDir($)}/settings.json`
58}
59// A path under HOME spelled from ~, as the person reads it.
60const tilde = (path: string, h: string) => (h && path.startsWith(`${h}/`) ? `~${path.slice(h.length)}` : path)
61
62// Missing file = {}. A file that does not parse throws, so a later write never replaces it with {}.
63async function readJson($: $, path: string): Promise<Json> {
64  return ((await $.fs.exists(path)) ? JSON.parse(await $.fs.read(path)) : {})
65}
66
67// Temp + rename, so Claude Code never reads a half-written settings.json. Writes through a symlink.
68async function writeJson($: $, path: string, data: Json) {
69  const real = (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath ?? path
70  const tmp = `${real}.junkd-tmp`
71  await $.fs.write(tmp, `${JSON.stringify(data, null, 2)}\n`)
72  const mv = await $.process.run(['/bin/mv', '-f', tmp, real])
73  if (mv.exitCode !== 0) throw new Error(mv.stderr.trim() || `mv exited ${mv.exitCode}`)
74}
75
76// A checkout (--plugin-dir, or the repo itself) has the marketplace two levels up; an installed copy lives in the
77// plugin cache under the config dir.
78async function loadMarketplace($: $, cfg: string): Promise<{ dir: string; data: Json }> {
79  for (const dir of [`${$.plugin.root}/../..`, `${cfg}/plugins/marketplaces/${MARKETPLACE}`]) {
80    const text = await $.fs.read(`${dir}/.claude-plugin/marketplace.json`).catch(() => undefined)
81    if (text !== undefined) return { dir, data: JSON.parse(text) as Json }
82  }
83  throw new Error(`${MARKETPLACE} marketplace.json not found`)
84}
85// The header's sha: `git rev-parse --short HEAD` in the marketplace's folder; none where git fails or is missing.
86async function shortSha($: $, dir: string): Promise<string> {
87  const git = await $.process.run(['git', '-C', dir, 'rev-parse', '--short', 'HEAD']).catch(() => undefined)
88  return git?.exitCode === 0 ? git.stdout.trim() : ''
89}
90
91// ------------------------------------------------------------------ the pane's data
92
93// Each group's `main` rows in settings.ts order (`enabled` first), with the value its feature answers now; a read
94// that fails draws the row's default. They are the module's own: no marketplace and no other plugin is needed.
95async function readGroups($: $): Promise<Section[]> {
96  const groups: Section[] = []
97  for (const group of GROUPS) {
98    const fields: Field[] = []
99    for (const f of rowsOf(group).filter(f => f.main)) {
100      const value = await getSetting($, f.key).catch(() => f.default)
101      fields.push({ key: f.field, type: f.type, title: f.title, ...(f.options ? { options: [...f.options] } : {}), value })
102    }
103    groups.push({ group, fields, hasPane: hasPane(group) })
104  }
105  return groups
106}
107
108// Every press refreshes, so two refreshes can run side by side: each builds its rows apart and assigns them once at
109// the end, and only the newest one started assigns at all (an older one, a step behind, is dropped). A marketplace
110// or settings.json that cannot be read is the note; the Settings section is drawn all the same.
111let refreshes = 0
112async function refresh($: $) {
113  const gen = ++refreshes
114  let failed = ''
115  let read: { shown: string; sha: string; name: string; rows: PluginRow[]; listed: Command[] } | undefined
116  try {
117    const cfg = await configDir($)
118    const { dir, data } = await loadMarketplace($, cfg)
119    const user = await readJson($, `${cfg}/settings.json`)
120    const merged = (await $.settings.read()) as Json
121    const shown = tilde(`${cfg}/settings.json`, await homeDir($))
122    const sha = await shortSha($, dir)
123    const name = String(data.name ?? MARKETPLACE)
124    const rows: PluginRow[] = ((data.plugins as Json[] | undefined) ?? []).map(entry => {
125      const id = `${entry.name}@${name}`
126      const isSelf = entry.name === $.plugin.name
127      return {
128        name: String(entry.name),
129        description: String(entry.description ?? ''),
130        isSelf,
131        isUserOn: user.enabledPlugins?.[id] === true,
132        isActive: isSelf || merged.enabledPlugins?.[id] === true,
133      }
134    })
135    const active = rows.filter(p => p.isActive)
136    const listed = (await $.command.list().catch(() => []))
137      .filter(c => c.source === 'plugin' && active.some(p => c.plugin === p.name || c.name.startsWith(`${p.name}:`)))
138      .map(c => ({ name: c.name, description: c.description }))
139    read = { shown, sha, name, rows, listed }
140  } catch (err) {
141    failed = `✗ ${err}`
142  }
143  const groups = await readGroups($)
144  if (gen !== refreshes) return
145  sections = groups
146  if (read) {
147    settingsShown = read.shown
148    version = read.sha
149    marketplace = read.name
150    plugins = read.rows
151    commands = read.listed
152  } else note = failed
153  redraw($)
154}
155
156// One flip after the other: each reads settings.json, changes one entry and writes it back through one temp name,
157// so two flips at once would lose the first or move a temp file the other already moved.
158let flips: Promise<void> = Promise.resolve()
159function togglePlugin($: $, name: string): Promise<void> {
160  const flip = flips.then(() => flipPlugin($, name))
161  flips = flip.catch(() => undefined)
162  return flip
163}
164async function flipPlugin($: $, name: string) {
165  try {
166    const path = await settingsPath($)
167    const user = await readJson($, path)
168    const id = `${name}@${marketplace}`
169    const isOn = user.enabledPlugins?.[id] !== true
170    user.enabledPlugins = { ...user.enabledPlugins, [id]: isOn }
171    await writeJson($, path, user)
172    isChanged = true
173    note = `✓ ${name} ${isOn ? 'on' : 'off'}: run /reload-plugins`
174  } catch (err) {
175    note = `✗ ${err}`
176  }
177  await refresh($)
178}
179
180// A value typed or flipped in the pane goes to its group's set, which validates it; a refusal is the note.
181async function setValue($: $, group: string, field: string, value: unknown) {
182  const key = keyOf(group as JunkdGroup, field)
183  if (!isKey(key)) return
184  try {
185    const r = await setSetting($, key, value)
186    note = r.deny !== undefined ? `✗ ${r.deny}` : `✓ ${group}.${field} = ${JSON.stringify(r.value)}`
187  } catch (err) {
188    note = `✗ ${reason(err)}`
189  }
190  await refresh($)
191}
192
193// /junkd, ✚ junkd and q: junkd (focused), handoff, orch, statusline and tools as one set of tabs. Tabs go in open order (the
194// engine has no position option), so junkd's `$.ui.open` comes before any await: only an open made while the press or command
195// runs counts as asked. A feature pane already open on its own would keep its tab ahead of junkd's: each closes
196// first, then both open again in the set's order, marked as the set's (ponytail: they open after an await, so where
197// the engine does not count them as asked, below 144 columns they wait undrawn until opened by hand). Closing the
198// set closes junkd and each feature pane only while it is still the set's: one the person closed and opened again
199// on its own stays.
200export async function panelToggle($: $): Promise<void> {
201  if (setIsOpen()) return closeSet($)
202  const opened = $.ui.open({ id: PANE, title: 'junkd', focus: true })
203  setOpen(true)
204  note = ''
205  void refresh($)
206  const panes = opensNow()
207  await Promise.all(panes.map(p => hidePane($, p).catch(() => undefined)))
208  await Promise.all([opened, ...panes.map(p => showPane($, p, { owner: SET_OWNER }).catch(() => undefined))])
209}
210
211// settings ▸: the press leaves junkd holding the keys, and the engine shows and focuses another pane only while no
212// pane holds them (`focus` is a request, refused without a word). So the feature pane opens first (handoff on its
213// Settings tab), junkd closes with the set's other feature pane, and the feature pane asks for the keys again. It
214// is the person's pane from then on. junkd is gone by the time a refusal comes back, so the reason is a toast.
215async function openSettings($: $, group: string) {
216  if (!hasPane(group)) return
217  const route = group === 'handoff' ? ({ route: 'settings' } as const) : group === 'tools' ? ({ route: 'agents' } as const) : {}
218  const shown = showPane($, group, route)
219  setOpen(false)
220  await Promise.all([$.ui.close({ id: PANE }), ...hideSet($, group)])
221  try {
222    await shown
223    await showPane($, group, { ...route, focus: true })
224  } catch (err) {
225    $.ui.toast(`✗ ${reason(err)}`)
226  }
227}
228
229export async function panelStart($: $): Promise<void> {
230  // No $.command.register({ name: 'junkd' }): the engine refuses a plugin's bare name that is also its own
231  // /junkd:junkd, and the throw would kill the rest of session.start. /junkd:junkd (and a bare /junkd where the host
232  // resolves it to the skill) reach the command.run hooks below. A failing panes() only means "not open".
233  setOpen((await $.ui.panes().catch(() => [])).some(pane => pane.id === PANE))
234  if (setIsOpen()) void refresh($)
235}
236
237// ✚ junkd, beside the rest of the prompt hint (index.tsx lays the line out).
238export function panelButton($: $, e: RenderInput<'PromptHint'>) {
239  const { Button, Text } = $.ui.resolve(e)
240  return (
241    <Button key="junkd-toggle" plain label="junkd" onPress={() => void panelToggle($)}>
242      <Text color="claude">✚</Text> junkd
243    </Button>
244  )
245}
246
247export function registerPanel(on: On, _options: Record<string, unknown>): void {
248  // /junkd:junkd (skills/junkd) toggles the pane; a bare /junkd does too where the host resolves it to that skill.
249  on('command.run', { command: 'junkd' }, async $ => {
250    await panelToggle($)
251    return {}
252  })
253  on('command.run', { command: 'junkd:junkd' }, async $ => {
254    await panelToggle($)
255    return {}
256  })
257
258  // The person's closes (×, the tab's close; another plugin's $.ui.close) pass here: the set goes with junkd, each
259  // feature pane only while it is still the set's (a feature pane's own hook does the same for its tab, set.ts). Our
260  // own closes (panelToggle, settings ▸) clear the set's flag first and close the set themselves, so one that comes
261  // back here does nothing more.
262  on('ui.close', { id: PANE }, async ($, e, next) => {
263    const isPersons = setIsOpen()
264    setOpen(false)
265    const r = await next(e)
266    if (isPersons) await Promise.all(hideSet($))
267    return r
268  })
269
270  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e: RenderInput<'Pane'>, next) => {
271    // ponytail: mobile has no Input; it gets the engine's empty pane until it does.
272    if (e.surface === 'mobile') return next(e)
273    return panelView($.ui.resolve(e) as Els, e.props, { marketplace, version, settingsPath: settingsShown, plugins, sections, commands, groups: GROUPS, note, isChanged }, {
274      close: () => void panelToggle($),
275      togglePlugin: name => void togglePlugin($, name),
276      set: (group, field, value) => void setValue($, group, field, value),
277      openPane: group => void openSettings($, group),
278    })
279  })
280}
281
hooks/settings.ts 79 lines
1// junkd's settings: a mirror of plugin.json's `userConfig` (a hooks module cannot import JSON; tests/smoke.sh holds
2// every row to the manifest), and the spelling of a row's key. `$`-free.
3//
4// A row's key is `<group>_<field>` (the host takes only identifier keys in userConfig); its /config key is
5// `junkd.<group>_<field>`; the register options carry it as `<group>_<field>`. Each group's `enabled` comes first.
6import type { JunkdGroup, JunkdKey, JunkdSchemaEntry, JunkdValue } from '../types'
7
8export const PLUGIN = 'junkd'
9export const GROUPS: readonly JunkdGroup[] = ['core', 'handoff', 'orch', 'tldr', 'ctx', 'statusline', 'tools']
10
11export const SCHEMA: readonly JunkdSchemaEntry[] = [
12  { key: 'core_output_style', group: 'core', field: 'output_style', type: 'string', title: 'Output style', default: 'ste', main: true, options: ['ste', 'off'] },
13  { key: 'core_cache_check_sessions', group: 'core', field: 'cache_check_sessions', type: 'number', title: 'Cache check: sessions read', default: 10, main: true },
14  { key: 'handoff_enabled', group: 'handoff', field: 'enabled', type: 'boolean', title: 'Auto handoff: enabled', default: true, main: true },
15  { key: 'handoff_threshold', group: 'handoff', field: 'threshold', type: 'number', title: 'Auto handoff: context threshold (thousand tokens)', default: 300, main: true },
16  { key: 'handoff_checkEvery', group: 'handoff', field: 'checkEvery', type: 'number', title: 'Auto handoff: check every N tool calls', default: 5, main: false },
17  { key: 'handoff_autoOn', group: 'handoff', field: 'autoOn', type: 'boolean', title: 'Auto handoff: armed at session start', default: false, main: true },
18  { key: 'handoff_resumeAfterLimit', group: 'handoff', field: 'resumeAfterLimit', type: 'boolean', title: 'Auto handoff: resume after the 5h rate limit', default: true, main: true },
19  { key: 'handoff_staleDays', group: 'handoff', field: 'staleDays', type: 'number', title: 'Handoffs: mark stale after N days', default: 30, main: false },
20  { key: 'handoff_segment', group: 'handoff', field: 'segment', type: 'boolean', title: 'Status line: show the 🪃 segment', default: true, main: true },
21  { key: 'orch_enabled', group: 'orch', field: 'enabled', type: 'boolean', title: 'Orchestrator: enabled', default: true, main: true },
22  { key: 'orch_guard', group: 'orch', field: 'guard', type: 'string', title: 'Main-loop guard', default: 'fable', main: true, options: ['fable', 'always', 'off'] },
23  { key: 'orch_allow_fable_agents', group: 'orch', field: 'allow_fable_agents', type: 'boolean', title: 'Allow Fable subagents', default: false, main: true },
24  { key: 'orch_implementer_ctx_k', group: 'orch', field: 'implementer_ctx_k', type: 'number', title: 'Implementer context cap (k tokens)', default: 400, main: true },
25  { key: 'orch_ui_review', group: 'orch', field: 'ui_review', type: 'boolean', title: 'UI review', default: true, main: false },
26  { key: 'orch_inject_rules', group: 'orch', field: 'inject_rules', type: 'boolean', title: 'Inject orchestrator rules', default: true, main: false },
27  { key: 'orch_rounds', group: 'orch', field: 'rounds', type: 'number', title: 'Fix rounds', default: 3, main: false },
28  { key: 'orch_claude_md', group: 'orch', field: 'claude_md', type: 'boolean', title: 'CLAUDE.md pointer block', default: false, main: true },
29  { key: 'orch_dashboard_refresh', group: 'orch', field: 'dashboard_refresh', type: 'number', title: 'Dashboard: write every N seconds', default: 30, main: false },
30  { key: 'orch_dashboard_window_h', group: 'orch', field: 'dashboard_window_h', type: 'number', title: 'Dashboard: timeline window (hours)', default: 24, main: false },
31  { key: 'orch_watchdog', group: 'orch', field: 'watchdog', type: 'boolean', title: 'Agent watchdog', default: true, main: false },
32  { key: 'orch_idle_stop', group: 'orch', field: 'idle_stop', type: 'boolean', title: 'Watchdog: stop idle leftovers', default: true, main: false },
33  { key: 'orch_idle_stop_minutes', group: 'orch', field: 'idle_stop_minutes', type: 'number', title: 'Watchdog: idle minutes before a stop', default: 10, main: false },
34  { key: 'orch_stall_minutes', group: 'orch', field: 'stall_minutes', type: 'number', title: 'Watchdog: stall after N minutes', default: 15, main: false },
35  { key: 'orch_lessons', group: 'orch', field: 'lessons', type: 'boolean', title: 'Lessons loop', default: true, main: true },
36  { key: 'tldr_enabled', group: 'tldr', field: 'enabled', type: 'boolean', title: 'TL;DR: enabled', default: true, main: true },
37  { key: 'tldr_terse', group: 'tldr', field: 'terse', type: 'boolean', title: 'Terse mode', default: false, main: true },
38  { key: 'tldr_sentences', group: 'tldr', field: 'sentences', type: 'number', title: 'Sentences in a summary', default: 3, main: true },
39  { key: 'ctx_enabled', group: 'ctx', field: 'enabled', type: 'boolean', title: 'Context limit: enabled', default: false, main: true },
40  { key: 'ctx_threshold', group: 'ctx', field: 'threshold', type: 'number', title: 'Context limit: threshold (tokens)', default: 300000, main: true },
41  { key: 'ctx_action', group: 'ctx', field: 'action', type: 'string', title: 'Context limit: action past it', default: 'block', main: true, options: ['block', 'warn', 'cmd'] },
42  { key: 'ctx_cmd', group: 'ctx', field: 'cmd', type: 'string', title: 'Context limit: command of action cmd', default: '', main: false },
43  { key: 'statusline_enabled', group: 'statusline', field: 'enabled', type: 'boolean', title: 'Status line: wired into settings.json', default: true, main: true },
44  { key: 'statusline_ctx', group: 'statusline', field: 'ctx', type: 'boolean', title: 'Segment: context free/used', default: true, main: false },
45  { key: 'statusline_branch', group: 'statusline', field: 'branch', type: 'boolean', title: 'Segment: git branch', default: true, main: false },
46  { key: 'statusline_worktree', group: 'statusline', field: 'worktree', type: 'boolean', title: 'Segment: linked worktree', default: true, main: false },
47  { key: 'statusline_model', group: 'statusline', field: 'model', type: 'boolean', title: 'Segment: model', default: true, main: false },
48  { key: 'statusline_effort', group: 'statusline', field: 'effort', type: 'boolean', title: 'Segment: effort', default: true, main: false },
49  { key: 'statusline_limits', group: 'statusline', field: 'limits', type: 'boolean', title: 'Segment: 5h/week rate limits', default: true, main: false },
50  { key: 'tools_enabled', group: 'tools', field: 'enabled', type: 'boolean', title: 'Tools pane: enabled', default: true, main: true },
51  { key: 'tools_refresh', group: 'tools', field: 'refresh', type: 'number', title: 'Tools pane: refresh every N seconds', default: 60, main: false },
52  { key: 'tools_agents_only', group: 'tools', field: 'agents_only', type: 'boolean', title: 'Tools pane: only worktrees with an agent', default: false, main: false },
53]
54
55export const isKey = (key: unknown): key is JunkdKey => SCHEMA.some(f => f.key === key)
56export const entryOf = (key: JunkdKey): JunkdSchemaEntry => SCHEMA.find(f => f.key === key)!
57// A group's rows, in manifest order (`enabled` first).
58export const rowsOf = (group: JunkdGroup): JunkdSchemaEntry[] => SCHEMA.filter(f => f.group === group)
59// `handoff`, `threshold` -> `handoff_threshold`.
60export const keyOf = (group: JunkdGroup, field: string): JunkdKey => `${group}_${field}` as JunkdKey
61// The /config row of a key: `junkd.handoff_threshold`.
62export const configKey = (key: JunkdKey): string => `${PLUGIN}.${key}`
63// The filter a feature's config.set hook takes: every /config row of its group, `junkd.<group>_...`.
64export const configKeys = (group: JunkdGroup): RegExp => new RegExp(`^${PLUGIN}\\.${group}_`)
65// A /config key (`junkd.handoff_threshold`) to its group's field (`threshold`); undefined when it is no row of that group.
66export function fieldOf(group: JunkdGroup, key: string): string | undefined {
67  const prefix = `${PLUGIN}.${group}_`
68  if (!key.startsWith(prefix)) return undefined
69  const field = key.slice(prefix.length)
70  return SCHEMA.some(f => f.group === group && f.field === field) ? field : undefined
71}
72// The register options (keyed by row) of one group, by field: `{ handoff_threshold: 70 }` -> `{ threshold: 70 }`.
73export function groupOptions(options: Record<string, unknown>, group: JunkdGroup): Record<string, unknown> {
74  const out: Record<string, unknown> = {}
75  for (const f of rowsOf(group)) if (f.key in options) out[f.field] = options[f.key]
76  return out
77}
78export type { JunkdGroup, JunkdKey, JunkdSchemaEntry, JunkdValue }
79
hooks/statusline.tsx 438 lines
1// statusline: the `statusline` settings group (enabled and the six segments), /junkd:statusline and its pane. The
2// drawing is statusline-views.tsx. The status line itself is bin/statusline.sh, which Claude Code runs from
3// ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/junkd/statusline.sh (a copy: the plugin's own folder is a versioned cache);
4// bin/statusline-setup on|off copies it there and points settings.json's statusLine at it, or takes it out.
5// - enabled: true runs `statusline-setup on --config <key>`, false `statusline-setup off`, before the row is stored; a
6//   run that refuses (a statusLine of the person's own) or fails is the refusal, and the row stays as it was. Then the
7//   row waits until the host has read settings.json again (hostReadsFile: the host writes its own copy of the file back
8//   whole with the row, and would put the line back or drop it), and once it is stored the file is looked at: when it
9//   does not say what the row says, the script runs once more. At session start, while enabled, `on` runs again when
10//   the installed copy is missing or differs from the plugin's (a plugin update, the old plugin's path), or when
11//   settings.json has no statusLine, or one of ours whose command is not `<copy> --config <key>`; what it says is not
12//   shown. While the row is off, `off` runs at the start when settings.json still has junkd's line (the host wrote its
13//   copy back after the row's own `off`; the write reloaded the module, so the copy that stored the row never looked
14//   again). The start waits for the host the same way and looks again VERIFY_MS later (another writer of settings.json
15//   at the session start can drop the line, or put it back).
16// - the segments: plain rows; the renderer reads them from settings.json (pluginConfigs) itself on every draw, from
17//   the entry the statusLine command names (`--config <key>`): the key this module's options are stored under.
18//   The engine does not hand a module its own key, so pluginKey derives it from the plugin's directory: an installed
19//   plugin lives in <config>/plugins/cache/<marketplace>/junkd/<version> (key junkd@<marketplace>), any other
20//   directory is a --plugin-dir session (key junkd@inline). Without --config the renderer would read the first
21//   junkd@ entry, which is the marketplace copy's whenever both are in the file.
22// The pane shows the rows, the key and a warning when a second junkd@ config could be the one the line reads, and a
23// preview: bin/statusline.sh run on a sample session, after every flip.
24// - every write of a row makes the host load this module again (it reads pluginConfigs anew), with the pane still on the
25//   screen: the new copy has nothing in memory, and the copy that wrote the row is cancelled where it waits. The start
26//   takes the pane back (adoptPane): it is among the panes the host has up, the files say what to draw, and the note of the
27//   flip was kept in $.store before the write. The key row is drawn from the first frame, so the rows never move under a
28//   click. A press is served by the `ui.press` hook (the closure of the drawing is gone for a moment after a reload), flips
29//   the row as it stands (not as an older drawing held it), waits for a script that is rewriting settings.json, and is
30//   refused while the wired switch is being applied.
31import type { On, RenderInput } from 'claude-code'
32import type { JunkdStatuslineField as Field } from '../types'
33import type { $, HideInput, OpenInput, SetResult, ShowInput } from './feature'
34import { isSets, shutSet } from './set'
35import { PLUGIN, configKey, configKeys, fieldOf, groupOptions, keyOf, rowsOf } from './settings'
36import { FIELDS, plain, statuslineView, type Els } from './statusline-views'
37
38const PANE = 'statusline'
39// $.store key of what the pane shows that a reload of the module would lose (the last row's note), and how long it holds.
40const KEPT = 'statusline-pane'
41const KEPT_MS = 10_000
42const isField = (key: unknown): key is Field => rowsOf('statusline').some(f => f.field === key)
43const S = Object.fromEntries(FIELDS.map(f => [f, true])) as Record<Field, boolean>
44
45let note = ''
46let preview = ''
47// The key this session's rows are stored under and the second-config warning, read at every refresh.
48let configInfo: ConfigNote = { key: '', warn: '' }
49// Whether settings.json's statusLine is junkd's: the pane's `wired` switch shows this, not the statusline_enabled
50// option (which says only what was asked for; the option can be on while the line is gone).
51let wired = false
52let paneOpen = false
53// Who opened the pane through statuslineShow({ owner }) (the /junkd set); any other open or close clears it.
54let owner: string | undefined
55function redraw($: $) {
56  return $.ui.invalidate('ui.render')
57}
58const reason = (err: unknown) => (err as Error)?.message ?? String(err)
59
60let home = ''
61async function homeDir($: $): Promise<string> {
62  if (home) return home
63  home = (await $.env.get('HOME')) ?? ''
64  if (!home) home = (await $.process.run(['sh', '-c', 'echo $HOME'])).stdout.trim()
65  return home
66}
67async function configDir($: $) {
68  return (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await homeDir($)}/.claude`
69}
70
71const coerce = (raw: unknown) => raw === true || raw === 'true' || raw === 'on'
72// What a press flips: the row as it stands when the press is served (the switch for `enabled` is the wired line, not
73// the option), never the value an older drawing of the pane held.
74const current = (field: Field) => (field === 'enabled' ? S.enabled && wired : S[field])
75// The wired switch takes up to SETTLE_TRIES x SETTLE_MS to apply (it waits for the host's copy of settings.json), and
76// the write that ends it makes the host load this module again and cancel what the copy that wrote it still waits for. A
77// press that comes meanwhile would write beside it from a copy the host has not updated, or be lost with the old copy: it
78// is refused, and the note says so. A segment is written at once and needs no such wait.
79let settling = false
80// The script that the start runs rewrites settings.json from what it read; a press waits for it, or its row is lost in the
81// rewrite.
82let wiring: Promise<unknown> = Promise.resolve()
83
84// ------------------------------------------------------------------ the pluginConfigs key this module's options live under
85
86// `junkd@<marketplace>` when the plugin's directory is <anything>/plugins/cache/<marketplace>/junkd/<version> (an
87// installed copy), `junkd@inline` otherwise (--plugin-dir). Probed on 2.1.303: `$.plugin` has only name and root, a
88// module never sees its own plugin.register (provenance), and register(on, options) carries no key.
89export function pluginKey(root: string): string {
90  const m = /\/plugins\/cache\/([^/]+)\/junkd\/[^/]+\/?$/.exec(root)
91  return `${PLUGIN}@${m?.[1] ?? 'inline'}`
92}
93// The statusLine command that reads `key`: the installed copy and the entry it reads.
94const commandFor = (copy: string, key: string) => `${copy} --config ${key}`
95// A command of junkd's: the copy (with or without --config) or the legacy path statusline-setup migrates.
96const isJunkdCommand = (command: string, cfg: string) =>
97  command === `${cfg}/junkd/statusline.sh` || command.startsWith(`${cfg}/junkd/statusline.sh --config `) || command === `${cfg}/junk-drawer-statusline.sh`
98// The key a junkd command names (`--config <key>`), '' when it names none.
99const keyOfCommand = (command: string) => / --config (\S+)\s*$/.exec(command)?.[1] ?? ''
100
101type Json = Record<string, unknown>
102const isObject = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v)
103// The user's settings.json as the file has it now (the host's own copy may be a moment old); undefined when it is
104// missing or does not parse.
105async function userSettings($: $): Promise<Json | undefined> {
106  try {
107    const parsed: unknown = JSON.parse(await $.fs.read(`${await configDir($)}/settings.json`))
108    return isObject(parsed) ? parsed : undefined
109  } catch {
110    return undefined
111  }
112}
113// The junkd@ keys of pluginConfigs in file order.
114const junkdKeys = (settings: Json | undefined): string[] =>
115  isObject(settings?.pluginConfigs) ? Object.keys(settings.pluginConfigs).filter(k => k.startsWith(`${PLUGIN}@`)) : []
116
117// settings.json's statusLine runs junkd's copy (with or without --config).
118export function isWired(settings: Json | undefined, cfg: string): boolean {
119  const line = settings?.statusLine
120  return isObject(line) && typeof line.command === 'string' && isJunkdCommand(line.command, cfg)
121}
122
123// What the pane shows about the key: `key` is the entry this session writes; `warn` is '' or the second-config warning.
124export type ConfigNote = { key: string; warn: string }
125// The entry the line reads is the statusLine command's --config key; a junkd command with none (installed before the
126// key existed) reads the first junkd@ entry; with no junkd command wired, this session's key is the one that counts.
127export function configNote(settings: Json | undefined, key: string, cfg: string): ConfigNote {
128  const keys = junkdKeys(settings)
129  const line = settings?.statusLine
130  const command = isObject(line) && typeof line.command === 'string' ? line.command : ''
131  const wired = command !== '' && isJunkdCommand(command, cfg)
132  const reads = wired ? keyOfCommand(command) || keys[0] || key : key
133  const distinct = new Set([key, ...keys, reads])
134  if (distinct.size < 2) return { key, warn: '' }
135  return { key, warn: `two junkd configs: ${reads} wins${reads === key ? '' : ` (this session writes ${key})`}` }
136}
137
138// bin/statusline-setup <mode>: `{ line }` (its last line) on exit 0, `{ deny }` (its reason) otherwise. `on` names the
139// entry the line reads: `--config <this module's key>`.
140async function runSetup($: $, mode: 'on' | 'off' | 'status'): Promise<{ line: string; deny?: undefined } | { deny: string; line?: undefined }> {
141  try {
142    const argv = [`${$.plugin.root}/bin/statusline-setup`, mode, ...(mode === 'on' ? ['--config', pluginKey($.plugin.root)] : [])]
143    const r = await $.process.run(argv, { env: { CLAUDE_CONFIG_DIR: await configDir($) } })
144    const last = (text: string) => text.trim().split('\n').at(-1) ?? ''
145    if (r.exitCode === 0) return { line: last(r.stdout) }
146    return { deny: last(r.stdout) || last(r.stderr) || `statusline-setup ${mode} exited ${r.exitCode}` }
147  } catch (err) {
148    return { deny: `statusline-setup ${mode} did not run: ${reason(err)}` }
149  }
150}
151
152// The renderer on a sample session (model "preview", the project dir, 61k of a 200k context used, the rate limits at
153// 12% and 33%), as the segment rows have it now. '' when it cannot run.
154async function readPreview($: $): Promise<string> {
155  const cwd = await $.session.cwd().catch(() => '')
156  const sample = {
157    model: { display_name: 'preview' },
158    cwd,
159    workspace: { current_dir: cwd },
160    context_window: { context_window_size: 200000, total_input_tokens: 61000, total_output_tokens: 0, used_percentage: 30.5 },
161    rate_limits: { five_hour: { used_percentage: 12 }, seven_day: { used_percentage: 33 } },
162  }
163  try {
164    const r = await $.process.run([`${$.plugin.root}/bin/statusline.sh`, '--config', pluginKey($.plugin.root)], { stdin: JSON.stringify(sample), env: { CLAUDE_CONFIG_DIR: await configDir($) } })
165    return r.exitCode === 0 ? plain(r.stdout) : ''
166  } catch {
167    return ''
168  }
169}
170async function refreshPreview($: $) {
171  if (!paneOpen) return
172  preview = await readPreview($)
173  const settings = await userSettings($)
174  const cfg = await configDir($)
175  configInfo = configNote(settings, pluginKey($.plugin.root), cfg)
176  wired = isWired(settings, cfg)
177  redraw($)
178}
179
180// The host keeps settings.json in memory and writes that copy back whole when a /config row changes. A statusLine that
181// statusline-setup has just written or removed is therefore undone by the row's own write (the line comes back after
182// `off`, goes after `on`) unless the host has read the file again, which it does a moment later on its own.
183// `$.settings.read` answers the host's copy: hostReadsFile waits until its statusLine is the file's (it gives up after
184// SETTLE_TRIES x SETTLE_MS and says so by returning false; a host that cannot say is the same as one that does not
185// catch up).
186export const SETTLE_MS = 100
187export const SETTLE_TRIES = 50
188const lineOf = (settings: Json | undefined) => JSON.stringify(settings?.statusLine ?? null)
189async function hostReadsFile($: $): Promise<boolean> {
190  for (let i = 0; i < SETTLE_TRIES; i++) {
191    const host = await $.settings.read({ source: 'user' }).catch(() => undefined)
192    if (lineOf(host) === lineOf(await userSettings($))) return true
193    await $.clock.sleep(SETTLE_MS)
194  }
195  return false
196}
197// Whether settings.json says what the enabled row says: junkd's line wired for on, none of junkd's for off.
198async function lineFollows($: $, value: boolean): Promise<boolean> {
199  return isWired(await userSettings($), await configDir($)) === value
200}
201
202// The note a row's flip leaves on the pane.
203const flipped = (field: Field, value: boolean) => `✓ ${field} ${value ? 'on' : 'off'}`
204
205// A row's write, on either path (`store` writes the row): enabled runs its script first and stores nothing when the
206// script refuses, waits for the host to read the file the script wrote (hostReadsFile), stores the row, and runs the
207// script once more when the file does not say what the row says after all; a refused row runs the other mode to put
208// settings.json back. The note is kept in $.store before the write: the write makes the host load this module again,
209// and the copy that was running does not get to say how it went (adoptPane reads the note back).
210async function storeRow<R extends { deny?: string }>($: $, field: Field, value: boolean, store: () => Promise<R>): Promise<R | { deny: string }> {
211  if (field !== 'enabled') return keeping($, field, value, store)
212  const mode = value ? 'on' : 'off'
213  const run = await runSetup($, mode)
214  if (run.deny !== undefined) return { deny: run.deny }
215  await hostReadsFile($)
216  const r = await keeping($, field, value, store)
217  if (r.deny !== undefined) await runSetup($, value ? 'off' : 'on')
218  else if (!(await lineFollows($, value))) await runSetup($, mode)
219  return r
220}
221async function keeping<R extends { deny?: string }>($: $, field: Field, value: boolean, store: () => Promise<R>): Promise<R> {
222  await $.store.set(KEPT, { note: flipped(field, value), at: Date.now() }).catch(() => undefined)
223  const r = await store()
224  if (r.deny !== undefined) await $.store.delete(KEPT).catch(() => undefined)
225  return r
226}
227
228// Writes one row and applies it; the pane's note says how it went.
229async function applyRow($: $, field: Field, value: boolean): Promise<SetResult> {
230  const { deny } = await storeRow($, field, value, () => $.config.set({ key: configKey(keyOf('statusline', field)), value }))
231  if (deny !== undefined) {
232    note = `✗ ${deny}`
233    redraw($)
234    return { deny }
235  }
236  S[field] = value
237  note = flipped(field, value)
238  await refreshPreview($)
239  redraw($)
240  return { value }
241}
242function setRow($: $, field: Field, raw: unknown) {
243  return applyRow($, field, coerce(raw))
244}
245// A press on a switch: the other value than the row has when the press is served.
246async function toggleRow($: $, field: Field) {
247  if (settling) {
248    note = '✗ the wired switch is still being applied: press again in a moment'
249    redraw($)
250    return
251  }
252  settling = field === 'enabled'
253  try {
254    await wiring
255    // The flag is only filled by the pane's refresh, which a reload's start has not finished yet: a press that comes first
256    // would read the line as unwired and flip an `off` into an `on`. The file says.
257    if (field === 'enabled') wired = isWired(await userSettings($), await configDir($))
258    await applyRow($, field, !current(field))
259  } catch (err) {
260    note = `✗ ${reason(err)}`
261    redraw($)
262  } finally {
263    if (field === 'enabled') settling = false
264  }
265}
266
267// `$.ui.open` before any await: only an open made while the press or command runs counts as asked.
268async function openPane($: $, focus = true) {
269  const opened = $.ui.open({ id: PANE, title: 'statusline', focus: focus || undefined })
270  owner = undefined
271  note = ''
272  paneOpen = (await opened).isPlaced
273  await refreshPreview($)
274}
275// The engine does not raise our own `$.ui.close` to our `ui.close` hook, so the flag is cleared here.
276async function closePane($: $) {
277  paneOpen = false
278  owner = undefined
279  await $.ui.close({ id: PANE })
280}
281function togglePane($: $) {
282  return paneOpen ? closePane($) : openPane($)
283}
284// Every write of a row makes the host load this module again, with the pane still on the screen and nothing in
285// memory: not open, no note, the line not known to be wired, no preview. The start finds the pane among the panes the
286// host has up, reads the note back and draws what the files say.
287// ponytail: the mark that the /junkd set opened the pane (`owner`) is not kept; a pane the set opened closes alone
288// after a flip, until the set opens it again.
289async function adoptPane($: $) {
290  if (!(await $.ui.panes().catch(() => [])).some(p => p.id === PANE)) return
291  paneOpen = true
292  const kept = (await $.store.get(KEPT).catch(() => undefined)) as { note?: unknown; at?: unknown } | undefined
293  if (typeof kept?.note === 'string' && Date.now() - Number(kept.at) < KEPT_MS) note = kept.note
294  await refreshPreview($)
295}
296
297// ------------------------------------------------------------------ what index.tsx and dispatch.ts call
298
299export async function statuslineGet(_$: $, field: string): Promise<boolean> {
300  return S[field as Field]
301}
302export async function statuslineSet($: $, field: string, raw: unknown): Promise<SetResult> {
303  if (!isField(field)) return { deny: `junkd: no setting ${keyOf('statusline', field)}` }
304  return setRow($, field, raw)
305}
306// open toggles the pane (no tabs: a route is refused).
307export async function statuslineOpen($: $, input: OpenInput = {}): Promise<void> {
308  if (input.route !== undefined) throw new Error(`junkd: the statusline pane has no tab ${String(input.route)}`)
309  await togglePane($)
310}
311// show never closes; `owner` marks it as that caller's, for hide.
312export async function statuslineShow($: $, input: ShowInput = {}): Promise<void> {
313  const opened = openPane($, input.focus === true)
314  owner = input.owner
315  await opened
316}
317// hide closes the pane; with `owner`, only while the pane is still the one that owner's show opened.
318export async function statuslineHide($: $, input: HideInput = {}): Promise<void> {
319  if (input.owner !== undefined && input.owner !== owner) return
320  await closePane($)
321}
322// Whether the line needs wiring: the installed copy is missing or is not the plugin's script, or settings.json has no
323// statusLine, or a junkd one that does not read this session's key. A statusLine of the person's own is left alone
324// (on would only be refused).
325async function needsWiring($: $): Promise<boolean> {
326  const cfg = await configDir($)
327  const copy = `${cfg}/junkd/statusline.sh`
328  const installed = await $.fs.read(copy).catch(() => undefined)
329  const ours = await $.fs.read(`${$.plugin.root}/bin/statusline.sh`).catch(() => undefined)
330  const line = (await userSettings($))?.statusLine
331  const command = isObject(line) && typeof line.command === 'string' ? line.command : undefined
332  const settled = command === commandFor(copy, pluginKey($.plugin.root)) || (line !== undefined && line !== null && !(command !== undefined && isJunkdCommand(command, cfg)))
333  return !(ours !== undefined && installed === ours && settled)
334}
335// A session start runs beside the other writers of settings.json (setup.sh's output style, the host's own first
336// writes), each of which reads the file and writes it back whole: one of them can drop the line statusline-setup has
337// just written. So the line is looked at again VERIFY_MS later and wired once more if it is gone.
338export const VERIFY_MS = 3000
339// `statusline-setup on` while enabled when needsWiring, `off` with the row off while junkd's line is wired (wire), then a
340// pane left up by a reload is taken back (adoptPane: after the script, so it draws the line that is there), then the
341// host's read of the file (so its next write of a row keeps the line) and the second look. Silent.
342export async function statuslineStart($: $): Promise<void> {
343  const wires = await wire($)
344  await adoptPane($)
345  if (!wires) return
346  await hostReadsFile($)
347  void $.clock.sleep(VERIFY_MS).then(async () => {
348    if (await wire($)) await refreshPreview($)
349  }).catch(() => undefined)
350}
351// needsWiring and the script behind it, as the one thing a press waits for (`wiring`): true when the script ran. It is set
352// before the look at the files, which takes a moment of its own. With the row off the line is taken out again when the
353// host has written it back (the row's own write reloads the module and cancels the copy that would have looked: this
354// start is the one that does).
355function wire($: $): Promise<boolean> {
356  const done = (async () => {
357    if (!S.enabled) {
358      if (!isWired(await userSettings($), await configDir($))) return false
359      await runSetup($, 'off')
360      return true
361    }
362    if (!(await needsWiring($))) return false
363    await runSetup($, 'on')
364    return true
365  })()
366  wiring = done
367  return done
368}
369
370export function registerStatusline(on: On, options: Record<string, unknown>): void {
371  const o = groupOptions(options, 'statusline')
372  // The engine hands only a boolean (it refuses another type at load), or none: on.
373  for (const f of FIELDS) S[f] = o[f] !== false
374
375  // The /config menu's own writes to a statusline row land here (a hook never sees its plugin's own `$.config.set`):
376  // read as `set` reads it; enabled runs its script first, as `set` does.
377  on('config.set', { key: configKeys('statusline') }, async ($, e, next) => {
378    const field = fieldOf('statusline', e.key)
379    if (!isField(field)) return next(e)
380    const value = coerce(e.value)
381    const r = await storeRow($, field, value, () => next({ ...e, value }))
382    if (r.deny === undefined) {
383      S[field] = value
384      await refreshPreview($)
385    }
386    return r
387  })
388
389  // /junkd:statusline: no argument toggles the pane; on and off set the enabled row; status is a toast.
390  on('command.run', { command: 'junkd:statusline' }, async ($, e) => {
391    const arg = e.args.trim()
392    if (arg === '') {
393      await togglePane($)
394      return {}
395    }
396    if (arg === 'on' || arg === 'off') {
397      const r = await setRow($, 'enabled', arg)
398      $.ui.toast(r.deny !== undefined ? `✗ ${r.deny}` : `✓ statusline ${arg}`)
399      return {}
400    }
401    if (arg === 'status') {
402      const r = await runSetup($, 'status')
403      const off = FIELDS.filter(f => f !== 'enabled' && !S[f])
404      $.ui.toast(r.deny !== undefined ? `✗ ${r.deny}` : `statusline: ${r.line}${off.length ? `; segments off: ${off.join(', ')}` : ''}`)
405      return {}
406    }
407    $.ui.toast('✗ usage: /junkd:statusline [on | off | status]')
408    return {}
409  })
410
411  // A press on a switch (a click, or its key a-g) is served here, not by the closure the drawing holds: that closure belongs to
412  // the copy of the module that drew the pane, and for a moment after a write has made the host load the module again it
413  // is gone, so a press then did nothing. The hook of the new copy is there at once. The other buttons (×) keep their closures.
414  on('ui.press', { component: 'Pane', requestId: PANE }, ($, e, next) => {
415    const field = e.element.startsWith('set:') ? e.element.slice('set:'.length) : undefined
416    if (!isField(field)) return next(e)
417    void toggleRow($, field)
418    return { element: e.element }
419  })
420
421  // The person's closes (q, ×) pass here; ours go through closePane.
422  on('ui.close', { id: PANE }, async ($, e, next) => {
423    const isSet = isSets(owner)
424    paneOpen = false
425    owner = undefined
426    const r = await next(e)
427    if (isSet) await shutSet($)
428    return r
429  })
430
431  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e: RenderInput<'Pane'>, next) => {
432    if (e.surface === 'mobile') return next(e)
433    return statuslineView($.ui.resolve(e) as Els, e.props, { values: { ...S, enabled: S.enabled && wired }, preview, note, config: { ...configInfo, key: configInfo.key || pluginKey($.plugin.root) } }, {
434      close: () => void (isSets(owner) ? shutSet($) : closePane($)),
435    })
436  })
437}
438
hooks/tldr.tsx 120 lines
1// tldr: the `tldr` settings group (enabled, terse, sentences) and /junkd:tldr's default sentence count. bin/tldr-hook
2// (UserPromptSubmit, a shell hook: it reads no `$.config`) decides each turn from two flag files under
3// ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/junkd/: `tldr-on` is terse mode's truth (bin/tldr-flag writes it too, so terse
4// is read from the file), `tldr-off` mirrors the enabled row (there while off). A write to either row, by
5// `$.junkd.set` or by the /config menu, writes its flag file first; enabled's is mirrored again at session start.
6import type { On } from 'claude-code'
7import type { JunkdTldrField } from '../types'
8import type { $, SetResult } from './feature'
9import { configKey, configKeys, fieldOf, groupOptions, keyOf, rowsOf } from './settings'
10
11type Field = JunkdTldrField
12const isField = (key: unknown): key is Field => rowsOf('tldr').some(f => f.field === key)
13const S = { enabled: true, sentences: 3 }
14
15// The same spelling as bin/tldr-flag and bin/tldr-hook: ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/junkd/<name>.
16type Flag = 'tldr-on' | 'tldr-off'
17async function flagPath($: $, name: Flag): Promise<string> {
18  const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await $.env.get('HOME')}/.claude`
19  return `${dir}/junkd/${name}`
20}
21// The flag file a field's row is mirrored into, and whether a value puts it there: terse on -> tldr-on, enabled off
22// -> tldr-off. Sentences has none.
23const FLAG_OF: Partial<Record<Field, { name: Flag; present: (value: boolean) => boolean }>> = {
24  terse: { name: 'tldr-on', present: v => v },
25  enabled: { name: 'tldr-off', present: v => !v },
26}
27
28// The hook sees presence only: the empty file, or none. Answers the reason when it could not.
29async function writeFlag($: $, flag: string, present: boolean): Promise<string | undefined> {
30  try {
31    if (present) await $.fs.write(flag, '')
32    else {
33      const r = await $.process.run(['rm', '-f', '--', flag])
34      if (r.exitCode !== 0) return `tldr: could not remove ${flag}: ${r.stderr.trim() || `rm exited ${r.exitCode}`}`
35    }
36  } catch (err) {
37    return `tldr: could not write ${flag}: ${(err as Error)?.message ?? String(err)}`
38  }
39  return undefined
40}
41
42// A mirrored row, on either path: the flag file first, then the row (`store`). A flag that could not be written
43// stores nothing; a refused row puts the flag back as it was.
44async function writeMirrored<R extends { deny?: string }>($: $, flag: string, present: boolean, store: () => Promise<R>): Promise<R | { deny: string }> {
45  const was = await $.fs.exists(flag)
46  const failed = await writeFlag($, flag, present)
47  if (failed !== undefined) return { deny: failed }
48  const r = await store()
49  if (r.deny !== undefined) await writeFlag($, flag, was)
50  return r
51}
52
53// A row's write, its flag file first where it has one.
54async function storeRow<R extends { deny?: string }>($: $, field: Field, value: number | boolean, store: () => Promise<R>): Promise<R | { deny: string }> {
55  const mirror = FLAG_OF[field]
56  if (mirror === undefined) return store()
57  return writeMirrored($, await flagPath($, mirror.name), mirror.present(value === true), store)
58}
59
60// A raw value to the field's kind; `error` when it does not fit.
61type Coerced = { value: number | boolean; error?: undefined } | { error: string; value?: undefined }
62function coerce(key: Field, raw: unknown): Coerced {
63  if (key === 'terse' || key === 'enabled') return { value: raw === true || raw === 'true' || raw === 'on' }
64  const value = Number(raw)
65  if (!Number.isInteger(value) || value < 1) return { error: `${String(raw)} is not a valid sentences (a whole number, 1 or more)` }
66  return { value }
67}
68
69// What a stored row changes in memory.
70function apply(field: Field, value: number | boolean): void {
71  if (field === 'sentences') S.sentences = value as number
72  if (field === 'enabled') S.enabled = value as boolean
73}
74
75export async function tldrGet($: $, field: string): Promise<number | boolean> {
76  if (field === 'terse') return $.fs.exists(await flagPath($, 'tldr-on'))
77  if (field === 'enabled') return S.enabled
78  return S.sentences
79}
80
81export async function tldrSet($: $, field: string, raw: unknown): Promise<SetResult> {
82  if (!isField(field)) return { deny: `junkd: no setting ${keyOf('tldr', field)}` }
83  const c = coerce(field, raw)
84  if (c.error !== undefined) return { deny: c.error }
85  // The host sends this write through the other plugins' hooks and skips ours below, so the value is applied here.
86  const { deny } = await storeRow($, field, c.value, () => $.config.set({ key: configKey(keyOf('tldr', field)), value: c.value }))
87  if (deny !== undefined) return { deny }
88  apply(field, c.value)
89  return { value: c.value }
90}
91
92// The enabled row into tldr-off, as the options at load (or the last write) have it: a session started after a hand
93// edit of settings.json, or after the flag was lost, puts the file back in step. A failure leaves the other starts be.
94export async function tldrStart($: $): Promise<void> {
95  try {
96    await writeFlag($, await flagPath($, 'tldr-off'), !S.enabled)
97  } catch {}
98}
99
100export function registerTldr(on: On, options: Record<string, unknown>): void {
101  const o = groupOptions(options, 'tldr')
102  S.sentences = coerce('sentences', o.sentences ?? 3).value as number ?? 3
103  S.enabled = o.enabled !== false // the engine hands only a boolean (it refuses another type at load), or none: on
104
105  // The /config menu's own writes to a tldr row land here (a hook never sees its plugin's own `$.config.set`):
106  // read as `set` reads it, refused where `set` refuses; terse and enabled write their flag first as `set` does.
107  on('config.set', { key: configKeys('tldr') }, async ($, e, next) => {
108    const field = fieldOf('tldr', e.key)
109    if (!isField(field)) return next(e)
110    const c = coerce(field, e.value)
111    if (c.error !== undefined) return { deny: c.error }
112    const r = await storeRow($, field, c.value, () => next({ ...e, value: c.value }))
113    if (r.deny === undefined) apply(field, c.value)
114    return r
115  })
116  // `/junkd:tldr` with no argument: the sentences setting is the number the skill reads (its markdown's own "else 3"
117  // is the default on the hosts without this module). A number, on, off, status pass as typed.
118  on('command.run', { command: 'junkd:tldr' }, (_$, e, next) => next(e.args.trim() === '' ? { ...e, args: String(S.sentences) } : e))
119}
120
hooks/tools-pane.tsx 343 lines
1// tools: the `tools` settings group and the tools pane (junkd-tools): this session's agents on top of the repo's git
2// worktrees, and the tools, MCP servers and skills the session used. The `$`-free parts are tools.ts (state, counters,
3// parser, tree) and tools-views.tsx (drawing). index.tsx feeds the counters from its tool.call and command.run hooks,
4// orch.tsx's agent.spawn hook records each spawn, handoff.tsx's turn.complete hook refreshes at the end of a turn.
5// - enabled off: the pane closes, the /junkd set leaves its tab out (panel.tsx), /junkd:tools says it is off and
6//   nothing is counted. refresh (10-600 s, clamped here) times the poll; agents_only hides the empty worktrees.
7// - The poll runs only while the pane is open: it stops on q, on the pane's ×, when a drawing throws, and when a tick
8//   finds the pane gone.
9import type { AgentSpawnInput, AgentSpawnResult, CommandRunInput, On, RenderInput, ToolCallInput, ToolCallResult } from 'claude-code'
10import type { HideInput, SetResult } from './feature'
11import { isSets, shutSet } from './set'
12import type { $ } from './feature'
13import { T, applyToolsSettings, coerce, resetTools, countSkill, tallyTool, isField, isRoute, emptyCounters, readWorktrees, mergeAgents, pickTranscript, projectFolder, revealCommand, type Route } from './tools'
14import { toolsView, type Els } from './tools-views'
15import { configKey, configKeys, fieldOf, groupOptions, keyOf } from './settings'
16import { dashboardSessionEnd } from './dashboard.tsx'
17import { watchdogEnd } from './watchdog.tsx'
18
19export const TOOLS_PANE_ID = 'junkd-tools'
20export const OFF_TEXT = 'junkd: the tools pane is off: /junkd → tools → Tools pane: enabled'
21const NO_TAB = (route: unknown) => `junkd: the tools pane has no tab ${String(route)} (agents, tools, mcp or skills)`
22// The pane toggles themselves are no skill runs of the person's.
23const NOT_COUNTED = new Set(['junkd', 'junkd:junkd', 'junkd:tools'])
24
25type ToolsInput = { route?: string; focus?: boolean; owner?: string }
26
27let timer: { cancel(): void } | null = null
28let owner: string | undefined
29let now = 0 // the clock at the last load or count: the Skills tab's `last` is drawn against it
30const reason = (err: unknown) => (err as Error)?.message ?? String(err)
31
32function redraw($: $) {
33  return $.ui.invalidate('ui.render')
34}
35
36let home = ''
37async function homeDir($: $): Promise<string> {
38  if (home) return home
39  home = (await $.env.get('HOME')) ?? ''
40  if (!home) home = (await $.process.run(['sh', '-c', 'echo $HOME'])).stdout.trim()
41  return home
42}
43// Paths follow CLAUDE_CONFIG_DIR like Claude Code itself.
44async function configDir($: $) {
45  return (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await homeDir($)}/.claude`
46}
47
48// loading -----------------------------------------------------------------------------------------------------------
49
50// The agent list, the worktrees of the session's repo (`git worktree list --porcelain`; outside a repo, or where git
51// fails, the session's directory alone) and the MCP schemas of the usage summary (estimated locally; a read that fails
52// keeps the last). Only the newest of two loads side by side assigns.
53let loads = 0
54async function load($: $): Promise<void> {
55  if (!T.open) return
56  const gen = ++loads
57  const listed = await $.agent.list().catch(() => undefined)
58  const cwd = await $.session.cwd().catch(() => '')
59  const git = cwd ? await $.process.run(['git', '-C', cwd, 'worktree', 'list', '--porcelain']).catch(() => undefined) : undefined
60  const trees = git?.exitCode === 0 ? readWorktrees(git.stdout) : []
61  const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => undefined)
62  const at = await $.clock.now().catch(() => now)
63  if (gen !== loads || !T.open) return
64  if (listed) T.agents = mergeAgents(listed)
65  T.worktrees = trees.length ? trees : [{ path: cwd, branch: '' }]
66  T.mcp = usage?.context.breakdown?.mcpTools ?? T.mcp
67  T.loaded = true
68  now = at
69  redraw($)
70}
71
72// A tick: the pane gone with no ui.close (a drawing that threw, say) ends the poll; else a load.
73async function poll($: $): Promise<void> {
74  const panes = await $.ui.panes().catch(() => undefined)
75  if (panes && !panes.some(p => p.id === TOOLS_PANE_ID)) return closed()
76  await load($)
77}
78
79function startPoll($: $) {
80  timer?.cancel()
81  timer = $.clock.every(T.refresh * 1000, () => void poll($))
82}
83function watch($: $) {
84  T.open = true
85  T.loaded = false
86  startPoll($)
87  void load($)
88}
89// The pane is gone, whoever closed it: no poll, no owner.
90function closed() {
91  T.open = false
92  T.loaded = false
93  owner = undefined
94  timer?.cancel()
95  timer = null
96}
97
98// `$.ui.open` before any await: only an open made while the press or command runs counts as asked. A refused open
99// is no pane; one that waits undrawn (a narrow terminal) is open.
100async function openPane($: $, route: Route | undefined, focus: boolean) {
101  if (route) T.route = route
102  const opened = $.ui.open({ id: TOOLS_PANE_ID, title: 'tools', focus: focus || undefined })
103  owner = undefined
104  watch($)
105  await opened.catch(err => {
106    closed()
107    throw err
108  })
109}
110// The engine does not raise our own `$.ui.close` to our ui.close hook, so the state is cleared here.
111async function closePane($: $) {
112  closed()
113  await $.ui.close({ id: TOOLS_PANE_ID })
114}
115
116// the transcript ----------------------------------------------------------------------------------------------------
117
118// This session's transcript: `$.session` names no path, so it is the `<session id>.jsonl` (else the newest .jsonl) in
119// the project folder the engine keeps for the session's directory, <config dir>/projects/<cwd, each non-alphanumeric
120// a dash>/. macOS shows it in Finder (`open -R`), elsewhere its folder opens (`xdg-open`). The outcome is a toast.
121async function revealTranscript($: $): Promise<void> {
122  let text: string
123  try {
124    const dir = `${await configDir($)}/projects/${projectFolder(await $.session.cwd())}`
125    const name = pickTranscript(await $.fs.list(dir).catch(() => []), await $.session.id().catch(() => ''))
126    if (name === undefined) text = `✗ no transcript in ${dir} yet: send a prompt first`
127    else {
128      const os = (await $.process.run(['uname', '-s']).catch(() => undefined))?.stdout.trim() ?? ''
129      const argv = revealCommand(os, `${dir}/${name}`)
130      const ran = await $.process.run(argv)
131      text = ran.exitCode === 0 ? `✓ ${argv.join(' ')}` : `✗ ${ran.stderr.trim() || `${argv[0]} exited ${ran.exitCode}`}`
132    }
133  } catch (err) {
134    text = `✗ ${reason(err)}`
135  }
136  $.ui.toast(text)
137}
138
139// settings ----------------------------------------------------------------------------------------------------------
140
141// A value the row took lands in T. enabled off closes the pane and drops the counters; a new refresh retimes a
142// running poll.
143async function apply($: $, field: string, value: number | boolean): Promise<void> {
144  applyToolsSettings({ enabled: T.enabled, refresh: T.refresh, agents_only: T.agentsOnly, [field]: value })
145  if (field === 'refresh' && timer) startPoll($)
146  if (field === 'enabled' && !T.enabled) {
147    T.counters = emptyCounters()
148    if (T.open) await closePane($)
149  }
150  redraw($)
151}
152
153// what index.tsx, dispatch.ts and the hooks call --------------------------------------------------------------------
154
155export async function toolsGet(_$: $, field: string): Promise<number | boolean> {
156  if (field === 'refresh') return T.refresh
157  if (field === 'agents_only') return T.agentsOnly
158  return T.enabled
159}
160// The host sends this write through the other plugins' hooks and skips ours below, so the value is applied here.
161export async function toolsSet($: $, field: string, raw: unknown): Promise<SetResult> {
162  if (!isField(field)) return { deny: `junkd: no setting ${keyOf('tools', field)}` }
163  const c = coerce(field, raw)
164  if (c.error !== undefined) return { deny: c.error }
165  const { deny } = await $.config.set({ key: configKey(keyOf('tools', field)), value: c.value })
166  if (deny !== undefined) return { deny }
167  await apply($, field, c.value)
168  return { value: c.value }
169}
170
171// show opens the pane (on `route`) and never closes it; up already, it is asked again (and switches to `route`).
172// `owner` marks it as that caller's, for hide. Off: refused with the reason.
173export async function toolsShow($: $, input: ToolsInput = {}): Promise<void> {
174  if (input.route !== undefined && !isRoute(input.route)) throw new Error(NO_TAB(input.route))
175  if (!T.enabled) throw new Error(OFF_TEXT)
176  const opened = openPane($, input.route, input.focus === true)
177  owner = input.owner
178  await opened
179}
180// hide closes the pane; with `owner`, only while the pane is still the one that owner's show opened.
181export async function toolsHide($: $, input: HideInput = {}): Promise<void> {
182  if (input.owner !== undefined && input.owner !== owner) return
183  await closePane($)
184}
185// open toggles the pane, as /junkd:tools does; a `route` while the pane is up switches its tab instead.
186export async function toolsOpen($: $, input: ToolsInput = {}): Promise<void> {
187  if (input.route !== undefined && !isRoute(input.route)) throw new Error(NO_TAB(input.route))
188  if (!T.enabled) throw new Error(OFF_TEXT)
189  if (input.route !== undefined && T.open) {
190    T.route = input.route
191    redraw($)
192    return
193  }
194  if (T.open) return closePane($)
195  return openPane($, input.route, true)
196}
197
198// The counters start with the session; a pane left open across a /reload-plugins polls again, or closes while off.
199export async function toolsStart($: $): Promise<void> {
200  T.since = await $.clock.now().catch(() => T.since)
201  now = T.since
202  if (!(await $.ui.panes().catch(() => [])).some(p => p.id === TOOLS_PANE_ID)) return
203  if (T.enabled) watch($)
204  else await $.ui.close({ id: TOOLS_PANE_ID })
205}
206
207// index.tsx's tool.call, for the model's own calls only: one more call of the tool, on the main loop or a subagent
208// (`agentId`), with the length of the result the model read; a Skill call is a skill run too. A refused call is none.
209// Never throws: tool.call's .catch would run the tool a second time.
210export async function toolsCount($: $, e: ToolCallInput, r: ToolCallResult): Promise<void> {
211  try {
212    if (!T.enabled || r.deny !== undefined) return
213    tallyTool(T.counters, e.tool, e.agentId !== undefined, r.text?.length ?? 0)
214    const { skill } = e as { skill?: unknown }
215    if (e.tool === 'Skill' && typeof skill === 'string') countSkill(T.counters, skill, 'tool', (now = await $.clock.now()))
216    if (T.open) redraw($)
217  } catch {}
218}
219
220// index.tsx's command.run, after the command ran: a slash run of a plugin's or the person's skill or command (by the
221// command list's source), typed or sent by the person's client; not a plugin's $.command.run, a built-in or an MCP
222// prompt, nor the pane toggles. The list is read again for a name it lacks (a /reload-plugins).
223let sources = new Map<string, string>()
224export async function toolsCommandRan($: $, e: CommandRunInput): Promise<void> {
225  try {
226    if (!T.enabled || e.origin.kind === 'plugin' || NOT_COUNTED.has(e.command)) return
227    if (!sources.has(e.command)) sources = new Map((await $.command.list()).map(c => [c.name, c.source]))
228    const source = sources.get(e.command)
229    const counted = source === 'user' || source === 'plugin'
230    if (!counted) return
231    countSkill(T.counters, e.command, 'slash', (now = await $.clock.now()))
232    if (T.open) redraw($)
233  } catch {}
234}
235
236// orch.tsx's agent.spawn, once the agent started: the model it got and the directory it runs in (the call's cwd,
237// else the session's), which the agent list has neither of; then the tree reads the list again.
238export async function toolsSpawned($: $, e: AgentSpawnInput, r: AgentSpawnResult): Promise<void> {
239  try {
240    if (!T.enabled || r.agentId === undefined) return
241    T.spawned[r.agentId] = { model: r.model, cwd: e.cwd ?? (await $.session.cwd().catch(() => '')), ...(e.name ? { name: e.name } : {}) }
242    void load($)
243  } catch {}
244}
245
246// handoff.tsx's turn.complete: a turn's end (the main loop's or an agent's) reads the tree again.
247export function toolsTurnEnd($: $): void {
248  if (T.open) void load($)
249}
250
251export function registerTools(on: On, options: Record<string, unknown>): void {
252  timer?.cancel()
253  timer = null
254  owner = undefined
255  sources = new Map()
256  resetTools()
257  applyToolsSettings(groupOptions(options, 'tools'))
258
259  // The /config menu's own writes to a tools row land here (a hook never sees its plugin's own `$.config.set`): read as
260  // `set` reads it, refused where `set` refuses, applied once the row took it.
261  on('config.set', { key: configKeys('tools') }, async ($, e, next) => {
262    const field = fieldOf('tools', e.key)
263    if (!isField(field)) return next(e)
264    const c = coerce(field, e.value)
265    if (c.error !== undefined) return { deny: c.error }
266    const r = await next({ ...e, value: c.value })
267    if (r.deny === undefined) await apply($, field, c.value)
268    return r
269  })
270
271  // /junkd:tools toggles the pane; /junkd:tools <tab> opens it on that tab, or switches to it.
272  on('command.run', { command: 'junkd:tools' }, async ($, e) => {
273    if (!T.enabled) return { text: OFF_TEXT }
274    const route = e.args.trim().toLowerCase()
275    if (route !== '' && !isRoute(route)) return { text: NO_TAB(route) }
276    try {
277      if (route === '') await toolsOpen($)
278      else if (T.open) await toolsOpen($, { route })
279      else await openPane($, route, true)
280    } catch (err) {
281      return { text: `✗ ${reason(err)}` }
282    }
283    return {}
284  })
285
286  // A /clear or a /resume ends the session and the process goes on under another, with no session.start: the counters
287  // and the agents seen start over. The folds and the selection stay.
288  on('session.end', async ($, end, next) => {
289    dashboardSessionEnd() // the module's one session.end hook: the orch dashboard's timer stops too
290    if (end.reason === 'resume' || end.reason === 'clear') {
291      T.counters = emptyCounters()
292      T.seen = Object.create(null)
293      T.agents = []
294      T.since = await $.clock.now().catch(() => T.since)
295      if (T.open) {
296        redraw($)
297        void load($)
298      }
299    }
300    await watchdogEnd($, end.reason) // the watchdog: /clear and /resume start over, any other end stops its timer
301    return next(end)
302  })
303
304  // The arrows move the focus ring along the agent rows: the row it lands on is the selection.
305  on('ui.focus', { requestId: TOOLS_PANE_ID }, ($, e, next) => {
306    if (e.element?.startsWith('node:')) {
307      T.selected = e.element.slice('node:'.length)
308      redraw($)
309    }
310    return next(e)
311  })
312
313  // The person's closes (q, ×) pass here; ours go through closePane.
314  on('ui.close', { id: TOOLS_PANE_ID }, async ($, e, next) => {
315    const isSet = isSets(owner)
316    closed()
317    const r = await next(e)
318    if (isSet) await shutSet($)
319    return r
320  })
321
322  // A drawing that throws ends the poll (the engine drops what it could not draw).
323  on('ui.render', { component: 'Pane', requestId: TOOLS_PANE_ID }, ($, e: RenderInput<'Pane'>, next) => {
324    if (e.surface === 'mobile') return next(e)
325    try {
326      return toolsView($.ui.resolve(e) as Els, e.props, now, {
327        close: () => void (isSets(owner) ? shutSet($) : closePane($)),
328        go: route => {
329          T.route = route
330          redraw($)
331        },
332        refresh: () => void load($),
333        reveal: () => void revealTranscript($),
334        redraw: () => void redraw($),
335        focus: key => void $.ui.focus({ requestId: TOOLS_PANE_ID, key }).catch(() => undefined), // a refused ring is no error
336      })
337    } catch (err) {
338      closed()
339      throw err
340    }
341  })
342}
343