config/
├── settings.yaml # Active profile name, hapticFeedback toggle, notifications toggle, sidebar prefs, sorting, sessionGroups (order + collapsed + bookmarked), mcp (enabled/port/token)
├── sessions.yaml # Persisted session state (auto-managed)
├── drafts.yaml # Persisted draft prompts per session (auto-managed)
├── plans/ # Individual per-plan JSON files and incoming plan artifacts
├── plan-dependencies.json # Persisted directory plan dependency registry
├── mcp/
│ ├── claude-mcp.json # Sample MCP config for Claude Code clients
│ └── copilot-mcp.json # Sample MCP config for Copilot CLI clients
└── profiles/
└── default.yaml # Self-contained: tools + workingDirectories + bindings + sticks + dpad
Release builds ship sanitised configs. prepareDeploy.py creates a transient config-deploy/ directory with profiles stripped of workingDirectories, default-only settings.yaml, and empty sessions.yaml. This is overlaid onto config/ by electron-builder during packaging so personal paths never ship.
A CLI type has two separate handles, and confusing them is the bug this split exists to prevent:
| What it is | Who sees it | |
|---|---|---|
id |
UUID v4, minted once at creation. Also the map key in cli-types.yaml, and the key everything else joins on. |
Nobody. Never rendered. |
displayName |
Free-text label the user edits. name is a deprecated alias kept in sync for legacy readers. |
Everywhere — sidebar, spawn grid, settings, Telegram, session default names. |
Renaming writes displayName and nothing else. No key changes, so bindings, live sessions, recycle-bin entries and scheduled tasks all keep resolving.
graph LR
ID[id · uuid] --> B[bindings.yaml key]
ID --> S[Session.cliType]
ID --> RB[recycle bin]
ID --> ST[scheduled tasks]
DN[displayName] --> UI[UI · Telegram · MCP]
DN -.resolve.-> ID
LK[legacyKey · old slug] -.resolve.-> ID
ConfigLoader.resolveCliType(ref) is the single choke point. Every cliType reference supplied by a human or an agent — MCP session_create, scheduled tasks, Telegram, IPC — goes through it. It resolves in order:
- exact
id(uuid) legacyKey— the pre-UUID slug, e.g.claude-code. Kept indefinitely; agents and docs in the wild still use it.displayName, trimmed and case-insensitive
An ambiguous displayName throws AmbiguousCliTypeError naming the conflicting ids rather than silently picking one — the settings UI blocks duplicate names on add, clone and rename, so this only fires on a hand-edited YAML. An unknown reference returns null; nothing falls back to treating the reference as an executable name.
Resolve when the reference is accepted, not when it is used. A scheduled task can sit pending for weeks, so ScheduledTaskManager resolves cliType in createTask/updateTask and stores the canonical uuid — an unknown or ambiguous reference fails the person or agent setting the schedule up, instead of failing unattended at 3am. Tasks written before this (or before the uuid migration) are healed once in start(); one that still will not resolve is left alone rather than blocking startup, and fails loudly when it next fires.
ConfigLoader.getCliTypeLabel(ref) is the display counterpart: never throws, never returns a uuid. Telegram gets it via setCliLabelResolver (wired once in initTelegramModules) because the notifier, topic manager and keyboard builders hold no ConfigLoader.
src/config/cli-type-migration.ts runs from ConfigLoader.load(). A cli-types.yaml whose entries have no id is pre-UUID: each entry gets a minted uuid, displayName from the old name, and legacyKey set to the old slug, preserving order. The slug→uuid map is then applied to bindings.yaml, persisted sessions, recycle-bin entries, and scheduled tasks plus their history. Staged write, verify, then swap — a failure leaves the originals untouched. Running it again is a no-op.
Profiles are self-contained— each profile YAML includes tools (CLI definitions), working directories, button bindings, stick config, and dpad config. Switching profiles changes everything. Profile switch shows a confirmation dialog when terminals are open (keep sessions / close all). createProfile(name) creates an empty profile; createProfile(name, copyFrom) clones from an existing profile.
Auto-migration: On first load, if legacy config/tools.yaml and config/directories.yaml exist, their contents are merged into all profiles and the old files are deleted.
CLI-specific bindings are used. Each profile defines different button behaviours per CLI type.
{ action: 'keyboard', sequence: '{Wait 500}some text{Enter}{Ctrl+C}' } — sequence parser syntax string sent to PTY stdin as escape codes. The sequence format is the only input mode for keyboard bindings.
{ action: 'voice', key: 'F1', mode: 'tap', target?: 'terminal' } — key simulation for voice activation triggers.
OS-default routing: voice bindings default to OS-level robotjs simulation (for external apps like OpenWhisper). Only routes through PTY when target: 'terminal' is explicitly set — converts key to terminal escape sequence via keyToPtyEscape() and writes to PTY via ptyWrite(). Falls back to OS-level robotjs when no terminal is active or target is not 'terminal'.
mode: 'tap'sends a single key eventmode: 'hold'sends the escape sequence once on press (PTY has no key-up concept) or holds/releases via robotjs for OS-targeted bindings- Key supports single keys (
F1,Space) and combos (Ctrl+Alt) - Supports F1-F12 (VT220 escape sequences), navigation keys, and modifier combos
{ action: 'scroll', direction: 'up'|'down', lines?: 5 } — Scroll active terminal buffer (or overview grid when visible).
{ action: 'context-menu' } — Opens the context menu overlay. Gamepad binding centers the menu in the viewport (mode: 'gamepad'). Right-click on any terminal pane shows at mouse position (mode: 'mouse').
Menu items: Copy, Paste, Compose in Editor ✏️, New Session, New Session with Selection, Prompts ⚡, Drafts ►, Cancel.
- Copy and "New Session with Selection" are disabled when no text is selected
- "New Session" / "New Session with Selection" open a quick-spawn CLI type picker (pre-selects active session's type), then the directory picker (pre-selects active session's working directory), then spawns
- "⚡ Prompts…" is enabled when there is an active session — opens the global prompt-template picker tree (
PromptTreeModal), then prefills the in-app Prompt Editor with the chosen template (seeprompt-treebelow) - "Drafts ►" opens a submenu listing New Draft + existing drafts with per-draft Apply/Edit/Delete actions
{ action: 'prompt-tree' } (renamed from the removed sequence-list action)
Opens the global prompt-template picker tree (PromptTreeModal.vue) — a progressive-disclosure tree of folders + template leaves backed by PromptTemplateManager and persisted to %APPDATA%/Helm/config/prompt-templates.yaml. Navigation: D-pad up/down cycles visible nodes, left/right expand/collapse folders, A picks, B cancels; keyboard accelerators index visible nodes 1-9,0 then a-z.
Picking a template does NOT send directly — it opens the in-app Prompt Editor (EditorPopup.vue) prefilled with the template body (caret at end) so the user can amend it. Only Ctrl+Enter / Send delivers the composed text to the active PTY via deliverPromptSequence(). Template bodies use the sequence-parser syntax ({Enter}, {Wait 500}, plain text). The apply flow is shared by the main window and the popout via the usePromptApplyFlow composable. (The legacy per-CLI sequences groups, SequencePicker modal, and sequence-list action were removed in PT-7; the executor/delivery layer that parses the syntax is retained.)
{ action: 'new-draft' } — Opens the draft editor for the active session, allowing the user to compose a draft prompt memo while the CLI is busy. Drafts can be applied (sent to PTY) via the Apply button in the editor or through the Drafts submenu in the context menu.
Quick-action buttons configured at the profile root (not per-CLI-type — same buttons appear regardless of which CLI is active).
chipActions:
- label: "💾 Save Plan"
sequence: >-
Create a plan item for what you just described...{Enter}
- label: "📋 My Action"
sequence: some text to send to PTY {Enter}Each entry: { label: string, sequence: string }.
- Buttons render right-aligned in the draft strip (same horizontal bar as draft pills and plan chips), via
margin-left: autoon.chip-action-bar. sequenceuses the same Sequence Parser Syntax as bindings, plus four template variables resolved at click time from the active session:
| Variable | Resolves to |
|---|---|
{cwd} |
Active session's working directory |
{cliType} |
Active session's CLI type key |
{sessionName} |
Active session's display name |
{plansDir} |
config/plans/incoming/ absolute path |
{inboxDir} |
config/plans/incoming/ absolute path |
- Actions are cached per page load.
invalidateChipActionCache()(exported fromdraft-strip.ts) forces a re-fetch on the next strip render. - IPC:
configGetChipbarActionsbridge →config:getChipbarActionshandler →ConfigLoader.getChipbarActions()→ returns{ actions, inboxDir }. - Omit
chipActions(or leave it empty) to show no action buttons.
In profile YAML tools section:
claude-code:
name: Claude Code
renameCommand: "/rename {cliSessionName}" # Optional: rename CLI-internal session (sent to PTY stdin)
spawnCommand: "claude --session-id {cliSessionName}" # Fresh spawn: set session UUID (written as-is to shell stdin)
resumeCommand: "claude --resume={cliSessionName}" # Resume: reload specific session by UUID
continueCommand: "claude --continue" # Fallback when resumeCommand is not configured
initialPrompt: # Array of sequence items sent to PTY sequentially after spawn
- sequence: "/init{Enter}"
initialPromptDelay: 2000 # ms to wait before sending first item (default 2000 for AI CLIs, 0 for generic)No terminal field — all CLIs run as embedded PTY sessions (no external window config). initialPrompt items are sent in order; use {Wait N} within sequences for inter-item timing.
Note: The legacy per-CLI
sequences:block (named groups of sequence items) was removed in PT-7 and replaced by the global prompt-template library (%APPDATA%/Helm/config/prompt-templates.yaml, see theprompt-treeaction). Anysequences:block still present in an old profile is read once as migration input and folded into the prompt-template tree.
Per-CLI array of regex rules stored under each CLI type in the profile YAML. PatternMatcher scans every PTY output chunk against all rules for that CLI type.
claude-code:
patterns:
- regex: "try again at (\\d{1,2}(?::\\d{2})?(?:am|pm))"
action: wait-until
timeGroup: 1 # Capture group index whose text is parsed as a time
onResume: "{Enter}" # Sequence sent to PTY when the scheduled time arrives
cooldownMs: 300000 # 5 min — suppresses re-triggering for same session
- regex: "Are you sure"
action: send-text
sequence: "y{Enter}" # Sequence sent to PTY immediately on match
cooldownMs: 10000| Action | Trigger | Required fields | Optional fields |
|---|---|---|---|
wait-until |
Parses a time from the matched capture group (or uses waitMs as fixed delay), then sends onResume to PTY at that time |
onResume |
timeGroup (default 0), waitMs (fallback fixed delay) |
send-text |
Sends sequence to PTY immediately on match |
sequence |
— |
Both action types use sequence / onResume strings in Sequence Parser Syntax.
Each rule carries an optional cooldownMs. After a rule fires for a session, it is suppressed for that session for cooldownMs milliseconds — preventing rapid re-triggering from repeated output lines. Cooldown is tracked per session per rule (not globally), so two concurrent sessions can each trigger the same rule independently.
When a wait-until fires and a scheduled send is pending, the session card shows a ⏰ HH:mm [×] chip. Clicking × cancels the pending send via the pattern:cancelSchedule IPC channel.
| Channel | Purpose |
|---|---|
tools:addPattern(cliType, rule) |
Append a pattern rule to a CLI type |
tools:updatePattern(cliType, index, rule) |
Replace pattern rule at index |
tools:removePattern(cliType, index) |
Delete pattern rule at index |
tools:getPatterns(cliType) |
Return all pattern rules for a CLI type |
pattern:cancelSchedule(sessionId) |
Cancel the pending wait-until for a session |
Used by both sequence bindings and initialPrompt:
| Token | Effect |
|---|---|
| Plain text | Sent as literal characters |
{Enter} |
Newline / carriage return |
{Tab}, {Escape}, {Delete}, etc. |
Named keys |
{Ctrl+C}, {Ctrl+Z}, etc. |
Modifier + key combos |
{Wait 500} |
Pause N ms (max 30000) |
{Ctrl Down}, {Ctrl Up} |
Hold/release modifier |
{{, }} |
Literal { and } |
In profile YAML:
sticks:
left:
mode: cursor # cursor | scroll | disabled
deadzone: 0.25
repeatRate: 60
right:
mode: scroll
deadzone: 0.25
repeatRate: 60
dpad:
initialDelay: 400
repeatRate: 120Plan storage is folder-level and shared across profiles. Current plan items are individual JSON files under config/plans/, dependency edges live in config/plan-dependencies.json, and inbox imports use config/plans/incoming/. config/plans.yaml is legacy migration input only; if present at startup it is migrated and renamed to plans.yaml.bak.
plans:
"C:/projects/my-app":
dirPath: "C:/projects/my-app"
items:
- id: "a1b2c3d4-..."
dirPath: "C:/projects/my-app"
title: "Setup auth"
description: "Implement JWT authentication"
status: ready # planning | ready | coding | review | blocked | done
createdAt: 1700000000000
updatedAt: 1700000000000
- id: "e5f6g7h8-..."
dirPath: "C:/projects/my-app"
title: "Build API routes"
description: "REST endpoints for user CRUD"
status: planning
sessionId: null # Set when status is 'coding' or 'review'
stateInfo: null # Required when status is 'blocked'
createdAt: 1700000000000
updatedAt: 1700000000000
dependencies:
- fromId: "a1b2c3d4-..." # Blocker (must be done first)
toId: "e5f6g7h8-..." # Blocked (can't start until blocker is done)Status transitions: new items start as planning and become ready when their dependencies are satisfied. Ready work is claimed with plan_set_state status=coding (the legacy plan:apply IPC still maps ready to coding), can move through review or blocked, and is finished with plan_complete/plan:complete. Completing an item triggers ready-state recomputation, which may promote dependency-unblocked planning items to ready.
Helm exposes a Model Context Protocol (MCP) HTTP endpoint on 127.0.0.1 so external AI CLIs (e.g. Claude Code, Copilot CLI) can query and control Helm remotely. The server is disabled by default.
Stored in config/settings.yaml under the mcp key:
mcp:
enabled: false # Toggle the MCP server on/off
port: 47373 # TCP port bound to 127.0.0.1 (1-65535)
authToken: "" # Bearer token required for all requests- enabled — Starts the HTTP server when Helm launches. The server only runs while Helm is open.
- port — Bound strictly to
127.0.0.1; never exposed to the network. - authToken — Random string sent as
Authorization: Bearer <token>. Generated via the settings UI or manually entered. Helm also mints session-scoped bearer tokens for Helm-spawned CLIs so the MCP server can infer sender identity for inter-LLM relay without callers manually passing sender fields.
Environment variable overrides (optional):
| Variable | Effect |
|---|---|
HELM_MCP_ENABLED |
1 to enable, anything else to disable |
HELM_MCP_HOST |
Override bind address (default 127.0.0.1) |
HELM_MCP_PORT |
Override port (default 47373) |
HELM_MCP_TOKEN |
Override auth token |
When session_send_text is called through a Helm-spawned CLI session, the localhost MCP server can infer the sender from that session-scoped bearer token. A bare external HTTP client that only knows the shared root token is still authenticated, but it is not automatically tied to a specific Helm session.
Add to claude_desktop_config.json (macOS) or %APPDATA%\Claude\settings.json (Windows):
{
"mcpServers": {
"helm": {
"type": "http",
"url": "http://127.0.0.1:47373/mcp",
"headers": {
"Authorization": "Bearer YOUR_TOKEN_HERE"
}
}
}
}Add to .mcp.json or VS Code MCP settings:
{
"mcpServers": {
"helm": {
"type": "http",
"url": "http://127.0.0.1:47373/mcp",
"tools": ["*"],
"headers": {
"Authorization": "Bearer YOUR_TOKEN_HERE"
}
}
}
}The MCP server exposes the following tools for external clients:
| Tool | Description |
|---|---|
tools_list |
List configured CLI types and their supported working directories |
plans_list |
List all plan items for a directory, including human-readable P-00xx IDs |
plans_summary |
List compact plan status, canonical IDs, human-readable IDs, and dependency relationships |
plan_get |
Get a single plan item by UUID or P-00xx human-readable ID |
plan_create |
Create a durable plan with Problem Statement, User POV, Done Statement, Files / Classes Affected, TDD Suggestions, and Acceptance Criteria sections |
plan_update |
Update a plan item title, description, and/or type by UUID or P-00xx ID |
plan_delete |
Delete a plan item by UUID or P-00xx ID |
plan_set_state |
Set plan state (planning/ready/coding/review/blocked) by UUID or P-00xx ID |
plan_complete |
Mark a plan item as done by UUID or P-00xx ID with implementation, file, test/review, and risk notes |
plan_nextplan_link |
Link one plan as a prerequisite for another by UUID or P-00xx ID, including QUESTION: ... blocker links to original plans |
plan_nextplan_unlink |
Remove a prerequisite link between two plan items by UUID or P-00xx ID |
directories_list |
List all known working directories |
session_create |
Spawn a new CLI session in a working directory |
sessions_list |
List active Helm sessions |
session_get |
Get a session by ID or exact display name |
session_send_text |
Send text to a running session's PTY |
session_read_terminal |
Read the recent terminal tail for any known session, with raw, stripped, or both output modes |
session_plan_claim |
Claim a plan: records ownership, transitions ready→coding, shows plan badge on session row |
session_set_aiagent_state |
Update the session AIAGENT state icon |
session_info |
Return MCP endpoint, AIAGENT state registry, directories, tool descriptions, and agent plan guidance |
All tools return JSON via MCP's tools/call endpoint. Errors are returned as JSON-RPC error responses with descriptive messages.