inject-steer 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "inject",
3
+ "owner": { "name": "inject" },
4
+ "plugins": [
5
+ {
6
+ "name": "inject",
7
+ "source": "./mod",
8
+ "description": "Adds /inject <message>: send Claude a note while it works, delivered at its next tool call"
9
+ }
10
+ ]
11
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 inject contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,254 @@
1
- # Temporary Holding Version
1
+ # inject
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <img width="800" height="500" alt="ezgif-825eae4d6b687d00" src="https://github.com/user-attachments/assets/ca05823c-99ae-4c70-b605-27cf69f3743c" />
4
+
5
+
6
+ Steer Claude Code while it works, in the same terminal window.
7
+
8
+ Run `inject` instead of `claude`. While Claude is mid-task, type:
9
+
10
+ ```
11
+ /inject use pnpm, not npm
12
+ /inject! stop, don't touch the v1 routes
13
+ ```
14
+
15
+ The line never reaches Claude Code's input box. It goes into a local queue,
16
+ and a Claude Code hook delivers it at the next tool boundary, without stopping
17
+ the agent. Every message is logged to `.steer/fixes.jsonl`.
18
+
19
+ `/inject <msg>` also works if you start plain `claude` instead of `inject`
20
+ (`inject --install` adds a small Claude Code plugin for that), without urgent
21
+ mode. See [Plain `claude` sessions](#plain-claude-sessions).
22
+
23
+ | You type | Urgency | Delivered | What Claude sees | `inject` | plain `claude` |
24
+ | --- | --- | --- | --- | :-: | :-: |
25
+ | `/inject <msg>` | normal | after the current tool call finishes | extra context next to the tool result | yes | yes |
26
+ | `/inject! <msg>` | urgent | before the next tool call runs | that call is **blocked** and the message is the reason | yes | no |
27
+
28
+ ## Quickstart
29
+
30
+ 1. **Install** (Node 18+, macOS, Linux, or Windows via WSL2):
31
+
32
+ ```bash
33
+ npm install -g .
34
+ ```
35
+
36
+ Run this from a clone of this repository. The package isn't on npm yet.
37
+ Once it is published it will be `npm install -g inject-steer`.
38
+
39
+ 2. **Register the hooks and the `/inject` command** (one time, and again after
40
+ upgrading inject). It edits `~/.claude/settings.json` (keeping a backup) and
41
+ installs a small Claude Code plugin with `claude plugin install`, so the
42
+ `claude` command must be on your PATH:
43
+
44
+ ```bash
45
+ inject --install
46
+ ```
47
+
48
+ 3. **Use `inject` wherever you'd use `claude`.** All arguments pass through
49
+ (`inject --resume`, `inject --model opus`, ...). While Claude works, type
50
+ `/inject <message>` or `/inject! <message>` and press Enter.
51
+
52
+ Prefer to keep typing `claude`? After step 2, start a new `claude` session and
53
+ `/inject <message>` works there too, just without urgent mode.
54
+
55
+ ## What you'll see
56
+
57
+ - While typing a message, a bar at the bottom of the terminal shows what
58
+ you're writing. **Enter** queues it and **Esc** cancels. Claude never sees
59
+ the keystrokes.
60
+ - After Enter: `queued · delivers at next step` (normal) or
61
+ `queued · will block the next tool call` (urgent). A small
62
+ `N queued` marker stays until the message is picked up.
63
+ - On delivery: `✓ inject: delivered (just ran Bash: npm test)`.
64
+ - If Claude Code exits with messages still queued, inject prints them and
65
+ logs them with `"delivered_at": null`.
66
+
67
+ ## How it works
68
+
69
+ ```
70
+ keyboard ──► inject (node-pty) ──────────────► claude
71
+ │ "/inject ..." lines ▲ hooks inherit INJECT_DIR
72
+ ▼ are held back │
73
+ queue + unix socket ◄──── claim ──── hook (PreToolUse / PostToolUse)
74
+ $INJECT_DIR/sock │
75
+ │ └─► JSON back to Claude Code
76
+ └─► .steer/fixes.jsonl
77
+ ```
78
+
79
+ - **Wrapper** (`src/wrapper/`): starts the agent in a pseudo-terminal and
80
+ passes every byte through unchanged. It keeps a shadow copy of what you've
81
+ typed since the last Enter. When that copy is empty and you start typing
82
+ `/inject`, the keys are held back. If what you type turns out to be
83
+ something else, like `/init`, the held keys are forwarded exactly as typed.
84
+ - **Queue** (`src/core/`): lives in the wrapper process and listens on a unix
85
+ socket in a private per-session directory (mode 0700). The path reaches the
86
+ agent's hooks through the `INJECT_DIR` environment variable, so several
87
+ `inject` sessions in different terminals never cross. Claims are atomic, so
88
+ parallel tool calls can't deliver a message twice.
89
+ - **Hooks** (`src/adapters/claude-code/`): the tool hooks are shell one-liners
90
+ that exit right away unless a message of the right kind is queued, so tool
91
+ calls with nothing queued never start Node. The `UserPromptSubmit` hook is
92
+ the exception: it runs Node once per prompt (see
93
+ [Plain `claude` sessions](#plain-claude-sessions)).
94
+
95
+ ### Claude Code hook events used
96
+
97
+ Based on the [hooks reference](https://code.claude.com/docs/en/hooks):
98
+
99
+ | Event | Used for | Output |
100
+ | --- | --- | --- |
101
+ | `PostToolUse`, `PostToolUseFailure` | normal notes | `hookSpecificOutput.additionalContext`. Claude Code wraps it in a system reminder that Claude reads on its next request |
102
+ | `PreToolUse` | urgent messages | `hookSpecificOutput.permissionDecision: "deny"` with `permissionDecisionReason`, which is shown to Claude. Any notes still queued ride along in the same reason |
103
+ | `UserPromptSubmit` | logging messages typed into Claude Code's own queue | none; it only appends to `.steer/fixes.jsonl` |
104
+
105
+ Hook changes take effect in Claude Code sessions started after
106
+ `inject --install`.
107
+
108
+ ## `.steer/fixes.jsonl`
109
+
110
+ One line per message, written in the project directory (`$CLAUDE_PROJECT_DIR`):
111
+
112
+ ```json
113
+ {"ts":"2026-10-16T15:38:04.772Z","text":"stop, don't touch the v1 routes","urgency":"urgent","agent":"claude-code","delivered_at":"2026-10-16T15:38:08.889Z","delivered_via":"PreToolUse","doing":"about to run Edit: src/v1/routes.ts","session_id":"f6afb1c7-..."}
114
+ ```
115
+
116
+ `ts` is when you queued the message. `doing` is the tool call Claude was about
117
+ to make, or had just made, when the message was delivered.
118
+
119
+ `delivered_via` says how the message reached Claude:
120
+
121
+ | `delivered_via` | Meaning |
122
+ | --- | --- |
123
+ | `PostToolUse`, `PostToolUseFailure` | an `/inject` note, added as context after a tool call (wrapper or plain `claude`, via the mod) |
124
+ | `PreToolUse` | an `/inject!` message that blocked a tool call |
125
+ | `claude-code-queue` | a message typed into Claude Code's own queue mid-turn. Claude Code delivers it itself once the running tool calls finish, so `delivered_at` is `null` |
126
+ | `null` | an `/inject` message still queued when Claude Code exited |
127
+
128
+ ## Plain `claude` sessions
129
+
130
+ After `inject --install`, `/inject <message>` also works in a session started with
131
+ plain `claude`, even while Claude is working. There is no wrapper and no urgent mode:
132
+
133
+ | | `inject` | plain `claude` |
134
+ | --- | --- | --- |
135
+ | `/inject <msg>` | yes | yes |
136
+ | `/inject! <msg>` | yes, blocks the next tool call | no (Claude Code says "Unknown command") |
137
+ | Live "queued" bar at the bottom of the terminal | yes | a short toast: "queued, delivers at the next step" |
138
+ | Message reaches Claude | next tool boundary | next tool boundary |
139
+ | Logged to `.steer/fixes.jsonl` | yes | yes |
140
+
141
+ **Why a plugin and not just a hook.** Claude Code answers `Unknown command: /inject`
142
+ by itself, before any hook runs, and it holds a command typed while Claude is working
143
+ until the turn ends. A hook therefore can't catch `/inject`. A Claude Code
144
+ [mod](https://code.claude.com/docs/en/plugins/mods/overview) can register a real
145
+ command with `immediate: true`, which runs at once even mid-turn. That is all the
146
+ `mod/` folder is: one command, about 60 lines.
147
+
148
+ How it works:
149
+
150
+ 1. `/inject use pnpm` runs the mod's command. It appends the message to a small file,
151
+ `~/.claude/inject-pending/<session id>.jsonl` (or under `$CLAUDE_CONFIG_DIR`), and
152
+ shows a toast. Nothing is sent to Claude yet, and the command prints nothing into
153
+ the conversation.
154
+ 2. After Claude's next tool call, the same `PostToolUse` hook that the wrapper uses
155
+ claims that file and returns the message as `additionalContext`. It is delivered
156
+ once, and only to the session that queued it. The hook also logs it to
157
+ `.steer/fixes.jsonl`.
158
+
159
+ `inject --install` runs `claude plugin marketplace add <this repo>` and
160
+ `claude plugin install inject@inject`. Because it is a directory marketplace, the
161
+ plugin loads in place: edits under `mod/` apply at the next session start.
162
+ `inject --uninstall` removes it again. If `claude` isn't on your PATH, `inject --install`
163
+ prints the two commands to run yourself.
164
+
165
+ Other messages you type mid-turn (no `/inject` prefix) go into Claude Code's own queue
166
+ as usual. The `UserPromptSubmit` hook logs those to `.steer/fixes.jsonl` too, with
167
+ `delivered_via: "claude-code-queue"`. It keeps the last `prompt_id` per session: a
168
+ queued message carries the running turn's `prompt_id`, and a new turn gets a new one.
169
+ That hook prints nothing and always exits 0, so it never changes or blocks a prompt. It
170
+ runs once per prompt in every Claude Code session that reads `~/.claude/settings.json`,
171
+ including the desktop app's.
172
+
173
+ Limits:
174
+
175
+ - **No urgent mode.** Only a wrapper that holds the queue can make the next tool
176
+ call fail.
177
+ - **A tool call is needed.** A message queued while Claude writes its final
178
+ answer waits for Claude's next tool call, which may be in your next turn.
179
+ - **New sessions only.** Already-running `claude` sessions don't have the command until
180
+ you restart them (or run `/reload-plugins`).
181
+ - **Folder trust.** Claude Code loads plugins only in folders you've trusted.
182
+ - **Desktop app: type plain text, not `/inject`.** The desktop app holds every slash
183
+ command until the turn ends, so `/inject` only runs after Claude has finished. A
184
+ plain message typed mid-turn is delivered after the current tool call and logged to
185
+ `.steer/fixes.jsonl` as `claude-code-queue`.
186
+ - **Inside the wrapper** `/inject` lines are held back before Claude Code sees them,
187
+ so the mod isn't involved.
188
+ - A message you take back out of Claude Code's own queue with `Up` has already
189
+ been logged.
190
+
191
+ ## Things to know
192
+
193
+ - **Claude Code's own queue.** Recent Claude Code versions already queue a
194
+ normal message you type while Claude works, and pass it to Claude once the
195
+ current tool calls finish. inject adds `/inject`, the urgent mode, the log,
196
+ and an agent-neutral core that other agents can plug into. Messages sent that
197
+ way are logged too.
198
+ - **Delivery needs a tool boundary.** A message queued while Claude writes its
199
+ final answer stays queued until Claude's next tool call, which may be in
200
+ your next turn. The `N queued` marker shows that it is waiting.
201
+ - **Parallel tool calls.** An urgent message blocks the first tool call that
202
+ reaches the hook. Other calls in the same parallel batch still run.
203
+ - **Detection is conservative.** After cursor keys, Tab completion, or `Esc`
204
+ while typing a prompt, inject can't be sure Claude's input box is empty. It
205
+ stops looking for `/inject` until the next Enter or Ctrl+C. Pressing Enter on
206
+ an empty prompt resets it.
207
+ - **`/` is held for one keystroke.** Claude's slash-command menu appears when
208
+ you type the second character instead of the first.
209
+ - **Windows:** use WSL2. Native Windows isn't supported, because the hooks are
210
+ POSIX shell one-liners and use unix sockets.
211
+ - **Linux / WSL2:** node-pty has no prebuilt binary there, so it compiles
212
+ during install. That needs `python3`, `make` and a C++ compiler. If npm
213
+ skipped node-pty's install script, `inject` tells you which
214
+ `npm rebuild node-pty` to run.
215
+
216
+ ## Uninstall
217
+
218
+ ```bash
219
+ inject --uninstall
220
+ npm uninstall -g inject-steer
221
+ ```
222
+
223
+ `--uninstall` removes only inject's hooks (they're tagged `# inject-hook`) and the
224
+ `inject@inject` plugin, and leaves the rest of your settings alone.
225
+
226
+ ## Adding another agent
227
+
228
+ Everything except `src/adapters/claude-code/` is agent-neutral. An adapter
229
+ provides:
230
+
231
+ - the command to start (`claude`),
232
+ - `install` / `uninstall` for its hook configuration,
233
+ - a hook entry point. It reads the agent's hook input, sends
234
+ `{op:"claim", boundary:"before-tool"|"after-tool", ...}` to `$INJECT_DIR/sock`,
235
+ and turns the reply into that agent's output: block the call for
236
+ `before-tool`, add context for `after-tool`.
237
+
238
+ See `src/adapters/types.js` for the contract and `src/adapters/claude-code/`
239
+ for a reference implementation.
240
+
241
+ ## Development
242
+
243
+ ```bash
244
+ npm install
245
+ npm test
246
+ ```
247
+
248
+ The tests drive the real wrapper in a pseudo-terminal with a fake agent. They
249
+ run the exact hook commands that `inject --install` writes, the same way
250
+ Claude Code would.
251
+
252
+ ## License
253
+
254
+ MIT
package/bin/inject.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ main(process.argv.slice(2));
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "inject",
3
+ "version": "0.1.0",
4
+ "description": "Adds /inject <message> to plain Claude Code sessions: the note reaches Claude at its next tool call, without interrupting it",
5
+ "author": { "name": "inject" }
6
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "description": "The inject /inject command",
3
+ "modules": ["./register.js"]
4
+ }
@@ -0,0 +1,69 @@
1
+ // inject, inside Claude Code: adds /inject <message> to plain `claude` sessions.
2
+ //
3
+ // A hook can't do this: Claude Code answers "Unknown command: /inject" itself,
4
+ // before UserPromptSubmit fires, and holds a command typed mid-turn until the
5
+ // turn ends. A mod can register a real command with immediate: true, which runs
6
+ // at once even while Claude is working.
7
+ //
8
+ // The command only queues the message: it appends a line to
9
+ // <config dir>/inject-pending/<session id>.jsonl. inject's PostToolUse hook
10
+ // (src/adapters/claude-code/hook.js) claims that file after Claude's next tool
11
+ // call, adds the message to Claude's context and logs it to .steer/fixes.jsonl.
12
+ // Urgent mode (/inject! blocking the next tool call) needs the `inject` wrapper.
13
+
14
+ const MAX_LENGTH = 2000
15
+
16
+ // Keep in sync with pendingDir() in src/adapters/claude-code/plain.js and the
17
+ // shell guard in install.js.
18
+ async function pendingDir($) {
19
+ const config = await $.env.get('CLAUDE_CONFIG_DIR')
20
+ if (config) return config + '/inject-pending'
21
+ const home = await $.env.get('HOME')
22
+ return (home || '') + '/.claude/inject-pending'
23
+ }
24
+
25
+ async function queue($, text) {
26
+ const id = await $.session.id()
27
+ const cwd = await $.session.cwd()
28
+ if (!id || !/^[\w-]+$/.test(id)) throw new Error('no session id')
29
+ const file = (await pendingDir($)) + '/' + id + '.jsonl'
30
+ let before = ''
31
+ try {
32
+ before = await $.fs.read(file)
33
+ } catch {
34
+ // First message of this session.
35
+ }
36
+ const record = { text, queuedAt: new Date().toISOString(), projectDir: cwd || null }
37
+ await $.fs.write(file, before + JSON.stringify(record) + '\n')
38
+ }
39
+
40
+ export function register(on) {
41
+ on('session.start', async ($, e, next) => {
42
+ try {
43
+ await $.command.register({
44
+ name: 'inject',
45
+ description: 'Send Claude a note; it reads it at its next tool call, without being interrupted',
46
+ argumentHint: '<message>',
47
+ immediate: true,
48
+ })
49
+ } catch {
50
+ // Name taken: leave the session alone.
51
+ }
52
+ return next(e)
53
+ })
54
+
55
+ on('command.run', { command: 'inject' }, async ($, e) => {
56
+ const text = (e.args || '').replace(/\s+/g, ' ').trim().slice(0, MAX_LENGTH)
57
+ if (!text) {
58
+ $.ui.toast('inject: usage is /inject <message>')
59
+ return {}
60
+ }
61
+ try {
62
+ await queue($, text)
63
+ $.ui.toast('inject: queued, delivers at the next step')
64
+ } catch (err) {
65
+ $.ui.toast('inject: could not queue the message (' + (err && err.message ? err.message : err) + ')')
66
+ }
67
+ return {}
68
+ })
69
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "./.claude-plugin/types/tsconfig.json"
3
+ }
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "inject-steer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Type /inject <message> while Claude Code is working and it reaches the running agent at the next tool boundary, without stopping it.",
5
+ "type": "module",
6
+ "bin": {
7
+ "inject": "bin/inject.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "mod",
13
+ ".claude-plugin"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test test/"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "os": [
22
+ "darwin",
23
+ "linux"
24
+ ],
25
+ "keywords": [
26
+ "claude-code",
27
+ "hooks",
28
+ "pty",
29
+ "agent",
30
+ "steering"
31
+ ],
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "node-pty": "^1.1.0"
35
+ }
36
+ }
@@ -0,0 +1,78 @@
1
+ // Claude Code hook entry point: node hook.js <HookEventName>
2
+ //
3
+ // Registered for PreToolUse (urgent: deny the call, reason goes to Claude)
4
+ // and PostToolUse / PostToolUseFailure (normal: additionalContext).
5
+ // With the wrapper, messages come from its socket ($INJECT_DIR); without it,
6
+ // from the pending file written by the UserPromptSubmit hook (plain.js).
7
+ // See https://code.claude.com/docs/en/hooks
8
+ //
9
+ // Any failure exits 0 with no output, so a missing or crashed wrapper never
10
+ // gets in Claude's way.
11
+
12
+ import { request } from '../../core/ipc.js';
13
+ import { appendFix } from '../../core/log.js';
14
+ import { ENV_DIR, socketPath } from '../../core/session.js';
15
+ import { BOUNDARY, buildOutput, describeTool } from './output.js';
16
+ import { claim } from './plain.js';
17
+
18
+ async function readStdin() {
19
+ let data = '';
20
+ process.stdin.setEncoding('utf8');
21
+ for await (const chunk of process.stdin) data += chunk;
22
+ return data;
23
+ }
24
+
25
+ async function main() {
26
+ const event = process.argv[2];
27
+ const dir = process.env[ENV_DIR];
28
+ if (!BOUNDARY[event]) return;
29
+ if (!dir && BOUNDARY[event] === 'before-tool') return; // urgent needs the wrapper
30
+
31
+ let input = {};
32
+ try {
33
+ input = JSON.parse(await readStdin());
34
+ } catch {
35
+ // Claim anyway; we just lose the "doing" detail.
36
+ }
37
+
38
+ const boundary = BOUNDARY[event];
39
+ const tool = describeTool(input.tool_name, input.tool_input);
40
+ const doing = tool ? `${boundary === 'before-tool' ? 'about to run' : 'just ran'} ${tool}` : null;
41
+
42
+ if (!dir) return deliverPlain(event, input, doing);
43
+
44
+ const reply = await request(socketPath(dir), {
45
+ op: 'claim',
46
+ boundary,
47
+ agent: 'claude-code',
48
+ event,
49
+ doing,
50
+ projectDir: process.env.CLAUDE_PROJECT_DIR || input.cwd || null,
51
+ sessionId: input.session_id || null,
52
+ });
53
+
54
+ const out = buildOutput(event, reply.messages || []);
55
+ if (out) process.stdout.write(JSON.stringify(out));
56
+ }
57
+
58
+ // Plain `claude` (no wrapper): messages queued by the UserPromptSubmit hook.
59
+ function deliverPlain(event, input, doing) {
60
+ const messages = claim(input.session_id);
61
+ if (!messages.length) return;
62
+ const deliveredAt = new Date().toISOString();
63
+ for (const m of messages) {
64
+ appendFix(m.projectDir || process.env.CLAUDE_PROJECT_DIR || input.cwd, m, {
65
+ agent: 'claude-code',
66
+ deliveredAt,
67
+ event,
68
+ doing,
69
+ sessionId: input.session_id,
70
+ });
71
+ }
72
+ process.stdout.write(JSON.stringify(buildOutput(event, messages)));
73
+ }
74
+
75
+ main().then(
76
+ () => process.exit(0),
77
+ () => process.exit(0),
78
+ );
@@ -0,0 +1,14 @@
1
+ import { install, installMod, isInstalled, modInstalled, uninstall, uninstallMod } from './install.js';
2
+
3
+ /** @type {import('../types.js').Adapter} */
4
+ export default {
5
+ id: 'claude-code',
6
+ name: 'Claude Code',
7
+ command: 'claude',
8
+ install,
9
+ uninstall,
10
+ isInstalled,
11
+ installMod,
12
+ uninstallMod,
13
+ modInstalled,
14
+ };