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…

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>:
| Command | Does | |||||
|---|---|---|---|---|---|---|
/junkd:handoff | Save 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-init | Write 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-tokens | How 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.
/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.
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:
✚ 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.
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):
| Host | Detected by | Handoffs | Terse flag |
|---|---|---|---|
| Claude Code | CLAUDECODE, CLAUDE_PLUGIN_ROOT, else CLAUDE_CONFIG_DIR | ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/handoffs/ | ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/junkd/tldr-on |
| Codex | CODEX_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 Code | KIMI_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 |
| OpenCode | OPENCODE, 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).
/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:
tools_enabled is on.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):
.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.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.$.command.list(), filtered to the plugins on in this session), junkd's /junkd:* among them.| Key | Does |
|---|---|
1–9 | Flip 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) |
tab | Move between the number fields and the choice buttons |
enter | Save 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 |
q | Close the set (/junkd or the ✚ junkd segment toggles it; closing junkd's tab closes it too) |
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:
| Group | Rows | Details |
|---|---|---|
| core | core_output_style, core_cache_check_sessions | Output style, prompt-cache check |
| handoff | handoff_enabled, handoff_threshold, handoff_checkEvery, handoff_autoOn, handoff_resumeAfterLimit, handoff_staleDays, handoff_segment | handoff settings |
| orch | orch_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_lessons | orch settings, dashboard, watchdog settings, lessons settings |
| tldr | tldr_enabled, tldr_terse, tldr_sentences | tldr settings |
| ctx | ctx_enabled, ctx_threshold, ctx_action, ctx_cmd | ctx-limit settings |
| statusline | statusline_enabled, statusline_ctx, statusline_branch, statusline_worktree, statusline_model, statusline_effort, statusline_limits | statusline settings |
| tools | tools_enabled, tools_refresh, tools_agents_only | tools 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.
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.
| Before | Now |
|---|---|
/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, segment | pluginConfigs["junkd@junk-drawer"].options.handoff_<field> |
pluginConfigs["orch@junk-drawer"].options.<field>: guard, allow_fable_agents, implementer_ctx_k, ui_review, inject_rules, rounds | pluginConfigs["junkd@junk-drawer"].options.orch_<field> |
pluginConfigs["tldr@junk-drawer"].options.<field>: terse, sentences | pluginConfigs["junkd@junk-drawer"].options.tldr_<field> |
pluginConfigs["junkd@junk-drawer"].options.output_style | pluginConfigs["junkd@junk-drawer"].options.core_output_style |
/config rows handoff.<field>, orch.<field>, tldr.<field>, junkd.output_style | junkd.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/.
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.
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):
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"}.outputStyle key with any value → nothing is written, nothing is added to the context.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
hooks/index.tsx 187 lines1// 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}
187hooks/cache.tsx 67 lines1// 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}
67hooks/core.ts 97 lines1// 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}
97hooks/ctx.ts 219 lines1// 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}
219hooks/dispatch.ts 69 lines1// 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}
69hooks/handoff.tsx 1002 lines1// 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}
1002hooks/orch.tsx 389 lines1// 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}
389hooks/panel.tsx 281 lines1// 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}
281hooks/settings.ts 79 lines1// 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 }
79hooks/statusline.tsx 438 lines1// 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}
438hooks/tldr.tsx 120 lines1// 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}
120hooks/tools-pane.tsx 343 lines1// 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