Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/release-theclaudehub.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,14 @@ jobs:

# --onedir, not --onefile: Velopack swaps an app folder in place, and a onefile exe would
# unpack itself on every start. velopack is imported lazily, so it is named explicitly; the
# speech scripts are data files.
# speech scripts are data files. --collect-binaries wx brings wx's WebView2Loader.dll, which
# PyInstaller otherwise leaves out, and without it full messages can't open as a formatted
# page (#190; the smoke test checks for it).
- name: Build
run: |
python tools/make_version_info.py
python -m PyInstaller --noconfirm --clean --noconsole --onedir --name TheClaudeHub `
--version-file build/version_info.txt --hidden-import velopack `
--version-file build/version_info.txt --hidden-import velopack --collect-binaries wx `
--add-data "theclaudehub/speech;theclaudehub/speech" TheClaudeHub.pyw
if ($LASTEXITCODE -ne 0) { exit 1 }

Expand Down
93 changes: 68 additions & 25 deletions TheClaudeHub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,14 @@ session list is always there.
- **Messages:** newest last, with focus on the newest. Each reads "You:" or "Claude:" and its
first line, and the list's name says the session's state and whether it's read-only. **Enter**
(or the context menu's Read Full Message, with the Applications key or Shift+F10) shows the
whole message in a read-only text box, to read by line, word and character; Escape closes it,
back on the same message. Question cards read as "Claude asked: Which version?" with the
whole message as a formatted page, so your screen reader's browse mode moves by heading (H),
table (T, then its cell commands), list (L) and code block (each is a region, R, named like
"Code block, Python, 14 lines"). Links open in your browser. **Read as Plain Text** (Alt+P)
switches to a read-only text box, to read by line, word and character, and Settings can make
that the default. Escape closes either, back on the same message. The page needs Microsoft's
Edge WebView2 runtime, which comes with Windows 11; without it, the text box opens. Nothing in
the page is fetched from the internet, and HTML in a message is shown as text, never run.
Question cards read as "Claude asked: Which version?" with the
options in the full text, then "You answered: ...". Refused tools read "Permission denied:
...". Tool calls and tool results are hidden unless you turn on Show Tool Activity (Ctrl+T).
New messages arrive at the end without moving you.
Expand All @@ -41,7 +47,12 @@ session list is always there.
session list, on the same session. Enter there on the session that's already loaded takes you
back to its messages where you left them, without reloading.
- **Open in Claude** (Ctrl+O) switches the desktop app to the session, for approving a permission
prompt or answering a question card there.
prompt or answering a question card in a desktop app session.
- **Continue Here** (Ctrl+Shift+N, or the button beside Open in Claude) carries a desktop app
session on in TheClaudeHub, as a copy: a new TheClaudeHub session in the same folder, with the
whole conversation so far in its messages, that you reply to here. You type its first message
in the same dialog as New Session; the title starts as "<title> (continued)". The desktop app
session isn't changed, and what you do in the copy doesn't appear in it.
- **New Session** (Ctrl+N) starts a session of TheClaudeHub's own: choose a folder, a title,
the model (Alt+D), a permission mode (auto by default; accept edits, manual and plan are
offered) and the first message. The models are Default (your Claude Code setting), Opus,
Expand All @@ -50,12 +61,30 @@ session list is always there.
Opus)"). Fable isn't offered: on some plans it bills to usage credits, and in the headless
mode TheClaudeHub uses, Claude Code does that without asking. If that first message never reaches Claude (Claude Code not signed in, say), it goes
back into the reply box and Send starts the session again.
- **Claude asks, you answer.** In TheClaudeHub's own sessions, when Claude needs permission
for something the permission mode doesn't allow, asks you a question, or has a plan for you
to approve, the turn waits for you. It's announced ("Build needs you. Claude wants to run
git push. Ctrl+Shift+A answers."), and the session shows as needing you in the list.
**Ctrl+Shift+A** opens the answer:
- **Permission:** the whole request (the command, or the file and what would change) to
read by line, then **Allow**, **Allow for this session** (shown when Claude Code suggests
a rule, and named in full, such as "don't ask again this session for Bash(git push:*)"),
or **Deny**, which is the default button and can carry a reason Claude reads.
- **Questions:** each question is a group of options with their descriptions, with Other
and a box to type your own answer; Send Answers, or Don't Answer.
- **Plan:** the plan to read (Read Formatted shows it as a page, by heading), then **Approve**, choosing the mode to carry on in (accept
edits, auto or manual), or **Keep Planning** with what to change, which is the default.

Escape in any of them answers later; nothing is approved or refused by waiting. "For this
session" lasts for the session, not just the turn: TheClaudeHub keeps the rule with the
session and gives it to every later turn. It never writes Claude Code's settings files.
- **Announcements.** When the open session gets a new reply, or one of TheClaudeHub's sessions
finishes a turn, or any listed session stops working, it's announced through your screen reader
(or a system voice) and put on the status bar. Settings (Ctrl+Comma) chooses full (the whole
reply), summary (the session's name and the first sentence) or silent (status bar only), whether
every listed session is announced or just the open one, and the speech route. Ctrl+Shift+R
repeats the last announcement.
every listed session is announced or just the open one, and the speech route; its Reading
messages group chooses whether full messages open as a formatted page or plain text.
Ctrl+Shift+R repeats the last announcement.
- **Answers to what you do are spoken too**, briefly and without cutting off your screen reader:
"Tool activity shown.", "Message copied.", and so on (unless announcements are set to silent).
- **Your own message is read back when it's sent**, so you hear what actually went to Claude,
Expand Down Expand Up @@ -150,6 +179,7 @@ The same list is in the app under Help, Keyboard Shortcuts (F1).
| Session list | Enter | Load that session and move to its messages |
| Session list | Ctrl+O | Open the selected session in the Claude desktop app |
| Session list | Ctrl+N | New TheClaudeHub session |
| Anywhere | Ctrl+Shift+N | Continue the selected (or loaded) desktop app session here, as a copy |
| Session list | F5 | Refresh the list now and put it in order |
| Session list | Delete | Forget the selected TheClaudeHub session (asks first; its transcript is kept) |
| Messages | Enter, or Applications key then Read Full Message | Read the whole message; Escape comes back to it |
Expand All @@ -159,6 +189,7 @@ The same list is in the app under Help, Keyboard Shortcuts (F1).
| Reply box | Ctrl+Enter | Send (TheClaudeHub sessions only); you stay in the reply box |
| Reply box | Ctrl+Period | Stop the running turn |
| Reply box | Ctrl+Shift+T | Turn status: how long it has been working, and on what |
| Anywhere | Ctrl+Shift+A | Answer Claude: a permission request, a question or a plan |
| Anywhere | F1 | Keyboard shortcuts |
| Anywhere | Ctrl+Comma | Settings |
| Anywhere | Ctrl+Shift+R | Repeat the last announcement |
Expand All @@ -174,7 +205,7 @@ Everything it reads is on your own PC, so reading costs nothing.

| What | Where |
|---|---|
| The desktop app's sessions | `%APPDATA%\Claude\claude-code-sessions\<id>\<org>\local_<id>.json`: title, folder, last activity, archived, and sometimes a summary of the last turn that says whether it needs you. Archived sessions are left out. |
| The desktop app's sessions | `%APPDATA%\Claude\claude-code-sessions\<id>\<org>\local_<id>.json`: title, folder, last activity, archived, and sometimes a summary of the last turn that says whether it needs you. Archived sessions are left out. The Microsoft Store version of the desktop app keeps the same files in `%LOCALAPPDATA%\Packages\Claude_<id>\LocalCache\Roaming\Claude\claude-code-sessions`; both places are read, and a session in both is read from the newer copy. |
| Whether a session is working | `%USERPROFILE%\.claude\sessions\<pid>.json`, which says busy or idle while Claude Code runs it. Files whose process has gone are ignored. |
| The conversation | `%USERPROFILE%\.claude\projects\<folder>\<session>.jsonl`, where `<folder>` is the session's folder with every character that isn't a letter or digit turned into `-`. This was checked against every transcript on Kelly's PC; if it ever misses, the app searches all the project folders for the session id instead. |
| TheClaudeHub's own sessions | `%APPDATA%\TheClaudeHub\sessions.json` (and `speech.json` for settings). If `sessions.json` can't be read, it's renamed to `sessions.json.bad-<date>` rather than overwritten, and the app says so. |
Expand All @@ -191,11 +222,14 @@ brings the first to the front.

### Its own sessions, and why desktop sessions are read-only

TheClaudeHub drives its own sessions with the `claude` command in print mode:
`claude -p --output-format stream-json --verbose --permission-mode <mode> --permission-prompts none`,
with `--session-id` and `--name` for the first message and `--resume <id>` for each reply. The
message goes in on standard input, byte for byte. That runs under the same login as the desktop
app, which is why it costs nothing extra.
TheClaudeHub drives its own sessions with the `claude` command in print (headless) mode:
`claude -p --input-format stream-json --output-format stream-json --verbose --permission-mode <mode>
--permission-prompts host --permission-prompt-tool stdio`, with `--session-id` and `--name` for the
first message and `--resume <id>` for each reply, plus `--allowedTools` with any rules you chose
"for this session". The message goes in on standard input as a stream-json message, and standard
input stays open for the turn so TheClaudeHub can answer what Claude asks; it's closed when the
turn's result arrives. That runs under the same login as the desktop app, which is why it costs
nothing extra.

- It never uses `--bare`, which needs an API key.
- It needs the native `claude.exe`. The npm install's `claude.cmd` is refused, because Windows runs
Expand All @@ -216,7 +250,11 @@ the same transcript at once and tangle it. Only the desktop app writes to its se
TheClaudeHub writes to its own. The rule is enforced in code: TheClaudeHub refuses to build a
`--resume` command for any session it didn't start, any session the desktop app knows about
(archived ones included), or any `local_` id, and there are tests for each. It also won't send
into one of its own sessions while that session is running somewhere else. One turn runs at a time
into one of its own sessions while that session is running somewhere else. Continue Here is the
one exception that reads a desktop session through `claude`, and it doesn't write to it:
`--resume <desktop id> --fork-session --session-id <new id>` copies the history into a new
session. Checked with Claude Code 2.1.286: the desktop session's transcript was byte for byte the
same afterwards, and Claude knew the earlier conversation. One turn runs at a time
per session. Send during a turn queues the message: TheClaudeHub says "Queued", and once the
turn's reply has been announced, it sends the message. More messages sent while one is waiting
join it, and they go together as one message. Turn status (Ctrl+Shift+T) says when a message is
Expand All @@ -241,19 +279,23 @@ slip out first.

### What headless sessions do with questions and permissions

Checked with Claude Code 2.1.289:

- **Question cards don't happen.** In print mode Claude Code doesn't offer the AskUserQuestion
tool at all, so Claude asks its question in ordinary words and you answer in the reply box.
Question cards in desktop app sessions are shown as text in the chat ("Claude asked: ...,
Options: ...", then "You answered: ...").
- **Nothing can approve a permission prompt**, so `--permission-prompts none` refuses anything
that would ask, straight away; the turn carries on rather than waiting. (Checked in manual mode:
the refused Write came back at once and Claude finished the turn explaining it.) Claude is
told and usually says what it couldn't do. TheClaudeHub adds the refused tools to the
announcement ("1 tool was refused: Write was refused: C:\...\probe.txt"), marks the session
"needs you", and the chat shows a "Permission denied" line. In auto mode, Claude's safety check
decides most of what would otherwise ask, so refusals are rarer there.
Checked with Claude Code 2.1.286 (issues #187 and #188):

- **Permission prompts, questions and plans come to TheClaudeHub.** With
`--permission-prompts host --permission-prompt-tool stdio`, anything that would ask arrives on
the turn's output as a `can_use_tool` request, the same control protocol the Agent SDK uses,
and the turn waits for the answer on its input. AskUserQuestion and ExitPlanMode come the same
way: a question is answered by allowing the tool with the answers added to its input, a plan
is approved by allowing it with a switch of permission mode, and Keep Planning denies it with
your note. Each was checked against the real CLI: an allowed Write wrote its file; a denied
one didn't, and Claude quoted the reason it was given; an approved plan carried on in accept
edits without asking again.
- **Refusals still happen without asking** where Claude Code's own rules say so (a write outside
the session's folder, say, or auto mode's safety check). TheClaudeHub adds those to the
announcement ("1 tool was refused: ..."), marks the session "needs you", and the chat shows a
"Permission denied" line.
- Question cards in desktop app sessions are shown as text in the chat ("Claude asked: ...,
Options: ...", then "You answered: ...") and answered in Claude.

## Limitations

Expand Down Expand Up @@ -281,6 +323,7 @@ Checked with Claude Code 2.1.289:
| `theclaudehub/claude_cli.py` | `claude` commands, the `--resume` guard, the environment, stream-json events, running a turn |
| `theclaudehub/hub.py` | Gathering the list, noticing finished turns |
| `theclaudehub/announce.py` | What gets announced |
| `theclaudehub/rendering.py` | A message's markdown as a safe HTML page, for the formatted view |
| `theclaudehub/speech.py`, `theclaudehub/speech/` | Speech, adapted from Image Description Toolkit (ClaudeSpeak's engine scripts) |
| `theclaudehub/platform_paths.py` | Every path and OS call |
| `theclaudehub/ui/` | The wxPython window and dialogs |
Expand Down
2 changes: 2 additions & 0 deletions TheClaudeHub/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
wxPython>=4.2
# Full messages as formatted pages (rendering.py, issue #190).
markdown==3.11
# Automatic updates (installed copies only; a source run ignores it).
velopack==1.2.161
# Certificate checks through Windows, for the update check (see updater._ssl_context).
Expand Down
56 changes: 41 additions & 15 deletions TheClaudeHub/tests/fake_claude.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,63 @@
"""A stand-in for `claude -p --output-format stream-json`, for tests.
"""A stand-in for `claude -p --input-format stream-json --output-format stream-json`,
for tests.

Reads the prompt from stdin as raw bytes, records what it got, and writes
stream-json to stdout as raw UTF-8 (with a raw U+2028 inside one string, as
JSON writers leave it). FAKE_CLAUDE_MODE=hang starts a grandchild process and
waits, so a test can check that cancelling kills the whole tree.
Reads stream-json lines from stdin as raw bytes until the user's message,
records what it got, and writes stream-json to stdout as raw UTF-8 (with a raw
U+2028 inside one string, as JSON writers leave it). FAKE_CLAUDE_MODE=hang
starts a grandchild process and waits, so a test can check that cancelling
kills the whole tree. FAKE_CLAUDE_MODE=ask sends a permission request and
waits for the answer, which goes in the result. After the result it waits for
stdin to close, as the real CLI does.
"""
import json
import os
import subprocess
import sys
import time

prompt = sys.stdin.buffer.read().decode("utf-8")
log = os.environ.get("FAKE_CLAUDE_LOG")
if log:
with open(log, "w", encoding="utf-8") as handle:
json.dump({"prompt": prompt, "args": sys.argv[1:],
"claude_env": sorted(k for k in os.environ
if k.upper() in ("CLAUDECODE", "ANTHROPIC_API_KEY"))},
handle)
received = []
prompt = None
for raw in iter(sys.stdin.buffer.readline, b""):
message = json.loads(raw.decode("utf-8"))
received.append(message)
if message.get("type") == "user":
prompt = message["message"]["content"]
break


def log(**extra):
path = os.environ.get("FAKE_CLAUDE_LOG")
if path:
with open(path, "w", encoding="utf-8") as handle:
json.dump({"prompt": prompt, "args": sys.argv[1:],
"received": [m.get("type") for m in received],
"claude_env": sorted(k for k in os.environ
if k.upper() in ("CLAUDECODE", "ANTHROPIC_API_KEY")),
**extra}, handle)


def out(**data):
sys.stdout.buffer.write(json.dumps(data, ensure_ascii=False).encode("utf-8") + b"\n")
sys.stdout.buffer.flush()


log()
out(type="system", subtype="init", session_id="abc-1", apiKeySource="none")
if os.environ.get("FAKE_CLAUDE_MODE") == "hang":
mode = os.environ.get("FAKE_CLAUDE_MODE")
if mode == "hang":
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)"])
with open(os.path.join(os.getcwd(), "grandchild.pid"), "w") as handle:
handle.write(str(child.pid))
time.sleep(120)
result = "done"
if mode == "ask":
out(type="control_request", request_id="req-1",
request={"subtype": "can_use_tool", "tool_name": "Bash",
"input": {"command": "git push"}, "tool_use_id": "t1"})
answer = json.loads(sys.stdin.buffer.readline().decode("utf-8"))
log(answer=answer)
result = answer["response"]["response"]["behavior"]
out(type="assistant", parent_tool_use_id=None,
message={"content": [{"type": "text", "text": "reply
with a line separator"}]})
out(type="result", subtype="success", is_error=False, result="done", session_id="abc-1")
out(type="result", subtype="success", is_error=False, result=result, session_id="abc-1")
sys.stdin.buffer.read() # until TheClaudeHub closes stdin
Loading
Loading