Skip to content

Commit 63857be

Browse files
author
lowcache
committed
Release v1.6.0
1 parent 11f805e commit 63857be

6 files changed

Lines changed: 111 additions & 11 deletions

File tree

‎DEVELOPMENT.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@ one bug that ever took this plugin down on load was a syntax-level mistake that
66
amount of live testing would have caught, because the shell simply refused to load the
77
service and said nothing.
88

9+
## How it fits together
10+
11+
- `pulse-svc.luau` is a headless service and the single source of truth. Hook events
12+
arrive over IPC; every 5 s it also reads Claude Code's session files (hookless
13+
detection, liveness, Esc-interrupt correction). It publishes a rollup to the
14+
`claude.pulse` shared-state key.
15+
- `pulse.luau` (bar) and `orb.luau` (desktop) only render `claude.pulse`. The panels
16+
(`sessions`, `answer`, `ask`, `consent`) render state and send IPC back; none holds
17+
logic of its own.
18+
- `claude.luau` is the one place that talks to a model: the `/claude` launcher, and a
19+
second `[[service]]` registration (`claude-ask`) so the ask panel's poke has an
20+
IPC-addressable receiver.
21+
- `hooks/pulse.py` turns Claude Code hooks into events; `hooks/consent.py` is the
22+
approval gate; `hooks/install.py` wires both into `~/.claude/settings.json`;
23+
`hooks/pulse-emit` is the agent-agnostic emitter. The event format is in
24+
[PROTOCOL.md](PROTOCOL.md).
25+
- `shim/noctalia-mcp.py` is the MCP server that gives `/claude` sessions desktop tools.
26+
927
## The shell
1028

1129
```sh
@@ -90,6 +108,7 @@ The gates cannot tell you whether a panel renders or an IPC event lands. For tha
90108
```sh
91109
ln -s "$PWD" ~/.local/share/noctalia/plugins/claude-companion
92110
noctalia msg plugins enable lowcache/claude-companion
111+
python3 ~/.local/share/noctalia/plugins/claude-companion/hooks/install.py # hooks point at the symlink
93112

94113
# reload after an edit
95114
noctalia msg plugins disable lowcache/claude-companion

‎PROTOCOL.md‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,86 @@ pulse-emit tool_start - < hook.json # session id from hook JSON
156156
PULSE_PID=$PPID pulse-emit turn_end aider-$PPID # retire when that process exits
157157
```
158158

159+
## Ready-made adapters
160+
161+
These setups call `pulse-emit` by name, so put it on your PATH once (catalog path shown; use your dev symlink if that's how you installed):
162+
163+
```sh
164+
ln -s ~/.local/state/noctalia/plugins/materialized/community/claude-companion/hooks/pulse-emit ~/.local/bin/pulse-emit
165+
```
166+
167+
Each one was checked against that project's current source on 2026-09-19; the opencode plugin was also run live, end to end. What you get differs by agent, because each exposes different hooks.
168+
169+
### Gemini CLI
170+
171+
`~/.gemini/settings.json`. Hooks are on by default, and Gemini expands `$GEMINI_SESSION_ID` (shell-escaped) before running the command. Keep these in your user settings: project-level hooks are blocked in untrusted folders.
172+
173+
```json
174+
{
175+
"hooks": {
176+
"BeforeAgent": [{ "hooks": [{ "type": "command", "command": "pulse-emit turn_start $GEMINI_SESSION_ID" }] }],
177+
"BeforeTool": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "pulse-emit tool_start $GEMINI_SESSION_ID" }] }],
178+
"Notification": [{ "hooks": [{ "type": "command", "command": "pulse-emit needs_attention $GEMINI_SESSION_ID" }] }],
179+
"AfterAgent": [{ "hooks": [{ "type": "command", "command": "pulse-emit turn_end $GEMINI_SESSION_ID" }] }],
180+
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "pulse-emit session_end $GEMINI_SESSION_ID" }] }]
181+
}
182+
}
183+
```
184+
185+
`Notification` only fires for tool-permission prompts, which is exactly the needs-you case.
186+
187+
### Codex CLI
188+
189+
`~/.codex/hooks.json` (Codex's Claude-style hooks, enabled by default in current builds). The session id arrives as JSON on stdin, which is what `pulse-emit`'s `-` session argument reads. Two things to know: Codex shows a **Hooks need review** prompt the first time and runs nothing until you trust them, and this file rejects unknown keys, so don't add comments.
190+
191+
```json
192+
{
193+
"hooks": {
194+
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "pulse-emit turn_start -" }] }],
195+
"PreToolUse": [{ "hooks": [{ "type": "command", "command": "pulse-emit tool_start -" }] }],
196+
"PermissionRequest": [{ "hooks": [{ "type": "command", "command": "pulse-emit needs_attention -" }] }],
197+
"Stop": [{ "hooks": [{ "type": "command", "command": "pulse-emit turn_end -" }] }],
198+
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "pulse-emit session_end -" }] }]
199+
}
200+
}
201+
```
202+
203+
The older `notify = [...]` setting in `config.toml` still works, but it only fires at the end of a turn and passes JSON as an argument, so the hooks above are the better fit.
204+
205+
### opencode
206+
207+
a plugin at `~/.config/opencode/plugin/pulse.ts`. opencode has no hook for quitting, so the plugin hands over its own process id and the pulse retires the session when opencode exits.
208+
209+
```ts
210+
// Drives the Noctalia pulse from opencode. pulse-emit must be on PATH.
211+
export const Pulse = async ({ $ }) => {
212+
const emit = (event, sid) =>
213+
$`pulse-emit ${event} ${sid}`.env({ ...process.env, PULSE_PID: String(process.pid) }).quiet().nothrow()
214+
return {
215+
"chat.message": async (input) => { await emit("turn_start", input.sessionID) },
216+
"tool.execute.before": async (input) => { await emit("tool_start", input.sessionID) },
217+
"permission.ask": async (input) => { await emit("needs_attention", input.sessionID) },
218+
event: async ({ event }) => {
219+
const p = event.properties
220+
if (event.type === "session.idle") await emit("turn_end", p.sessionID)
221+
else if (event.type === "session.error" && p.sessionID) await emit("error", p.sessionID)
222+
else if (event.type === "session.deleted") await emit("session_end", p.info.id)
223+
},
224+
}
225+
}
226+
```
227+
228+
### aider
229+
230+
`~/.aider.conf.yml`. aider has one hook: a command it runs whenever it's your turn again (a reply finished, or it's asking you to confirm something). It passes no session id, but the command runs as a direct child of aider, so `$PPID` is aider itself: one session per aider, retired when it exits.
231+
232+
```yaml
233+
notifications: true
234+
notifications-command: "PULSE_PID=$PPID pulse-emit turn_end aider-$PPID"
235+
```
236+
237+
Expect less here than elsewhere: a session appears once the first reply lands, there's no working state in between, and that one command can't tell "done" from "please confirm", so it reports done.
238+
159239
## Control events (not lifecycle)
160240
161241
Everything above describes the eight **lifecycle** events, which say what an agent is

‎claude-companion‎

Lines changed: 0 additions & 1 deletion
This file was deleted.

‎claude.luau‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ local ASK_NOTE = table.concat({
5050
}, " ")
5151

5252
-- claude only; this is the single backend seam.
53-
-- A quick-ask is promised as read-only (README "Usage"), so it must NOT inherit
53+
-- A quick-ask is promised as read-only (README "Use"), so it must NOT inherit
5454
-- the user's Claude config, where pre-authorized permissions would let a plain
5555
-- question run commands or edit files: --tools "" strips every built-in tool,
5656
-- --strict-mcp-config (with no --mcp-config) strips inherited MCP servers, and

‎nix/flake.nix‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,18 @@
9090
runtimeInputs = [
9191
pkgs.luau
9292
pkgs.python3
93+
pkgs.coreutils
94+
pkgs.gnugrep
95+
pkgs.gnused
9396
];
97+
# Suite list comes from scripts/run-widget-specs.sh (D15); a copy here drifted
98+
# and silently skipped the consent and ask suites.
9499
text = ''
95100
root="''${1:-$PWD}"
96-
echo "── pulse-svc ──"; "${pulseSvcRunner pkgs}/bin/pulse-svc-test" "$root"
97-
echo "── pulse ──"; "${pulseRunner pkgs}/bin/pulse-test" "$root"
98-
echo "── orb ──"; "${orbRunner pkgs}/bin/orb-test" "$root"
99-
echo "── answer ──"; "${answerRunner pkgs}/bin/answer-test" "$root"
100-
echo "── sessions ──"; "${sessionsRunner pkgs}/bin/sessions-test" "$root"
101-
echo "── shim (compositor abstraction) ──"; python3 "$root/tests/shim_spec.py"
102-
echo "── manifest (settings contract) ──"; PLUGIN_ROOT="$root" python3 "$root/tests/manifest_spec.py"
101+
echo "── widget specs ──"; bash "$root/scripts/run-widget-specs.sh" "$root"
102+
for spec in "$root"/tests/*_spec.py; do
103+
echo "── $(basename "$spec") ──"; PLUGIN_ROOT="$root" python3 "$spec"
104+
done
103105
'';
104106
};
105107
in

‎plugin.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# Claude Companion — a Claude Code companion for Noctalia v5.
22
# Not a chat client: the terminal does the agentic work; this is the shell-side
3-
# body — senses, hands, and an attention pulse. See README.md for the design.
3+
# body — senses, hands, and an attention pulse. See DEVELOPMENT.md for the design.
44

55
id = "lowcache/claude-companion"
66
name = "Claude Companion"
7-
version = "1.5.1"
7+
version = "1.6.0"
88
# Plugin API level this manifest targets — mandatory as of the Noctalia 5 beta
99
# manifest parser (replaces the older min_noctalia gate). 3 is the oldest-supported
1010
# level and covers every feature this plugin uses ([[panel]], ui controls, plugin IPC

0 commit comments

Comments
 (0)