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.
- package/.claude-plugin/marketplace.json +11 -0
- package/LICENSE +21 -0
- package/README.md +253 -2
- package/bin/inject.js +4 -0
- package/mod/.claude-plugin/plugin.json +6 -0
- package/mod/hooks/hooks.json +4 -0
- package/mod/hooks/register.js +69 -0
- package/mod/tsconfig.json +3 -0
- package/package.json +34 -4
- package/src/adapters/claude-code/hook.js +78 -0
- package/src/adapters/claude-code/index.js +14 -0
- package/src/adapters/claude-code/install.js +211 -0
- package/src/adapters/claude-code/output.js +39 -0
- package/src/adapters/claude-code/plain.js +47 -0
- package/src/adapters/claude-code/prompt-hook.js +20 -0
- package/src/adapters/claude-code/prompt.js +14 -0
- package/src/adapters/claude-code/steering.js +98 -0
- package/src/adapters/index.js +8 -0
- package/src/adapters/types.js +28 -0
- package/src/cli.js +40 -0
- package/src/core/format.js +32 -0
- package/src/core/ipc.js +61 -0
- package/src/core/log.js +34 -0
- package/src/core/queue.js +56 -0
- package/src/core/session.js +50 -0
- package/src/wrapper/intercept.js +349 -0
- package/src/wrapper/overlay.js +158 -0
- package/src/wrapper/pty.js +43 -0
- package/src/wrapper/run.js +130 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Registers inject's hooks in Claude Code's user settings
|
|
2
|
+
// (~/.claude/settings.json, or $CLAUDE_CONFIG_DIR/settings.json).
|
|
3
|
+
//
|
|
4
|
+
// The tool hooks are shell one-liners that exit immediately unless the wrapper
|
|
5
|
+
// has a message of the right kind queued, so Claude sessions started without
|
|
6
|
+
// `inject`, and tool calls with nothing queued, never start Node.
|
|
7
|
+
//
|
|
8
|
+
// The UserPromptSubmit hook runs in every session (once per prompt you send)
|
|
9
|
+
// to log messages typed into Claude Code's own queue mid-turn.
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const MARKER = '# inject-hook';
|
|
18
|
+
const HOOK_JS = fileURLToPath(new URL('./hook.js', import.meta.url));
|
|
19
|
+
const PROMPT_HOOK_JS = fileURLToPath(new URL('./prompt-hook.js', import.meta.url));
|
|
20
|
+
|
|
21
|
+
export function settingsPath() {
|
|
22
|
+
const base = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
23
|
+
return path.join(base, 'settings.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function shellQuote(s) {
|
|
27
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function hookCommand(event, flag, nodePath = process.execPath) {
|
|
31
|
+
if (flag === 'normal') return normalHookCommand(event, nodePath);
|
|
32
|
+
return `[ -e "$INJECT_DIR/${flag}" ] || exit 0; exec "$INJECT_NODE" ${shellQuote(HOOK_JS)} ${event} ${MARKER}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Tool-boundary delivery of normal notes. Under the wrapper ($INJECT_DIR set)
|
|
36
|
+
// it checks the wrapper's flag file. In a plain `claude` session it checks for
|
|
37
|
+
// a pending file from the /inject mod (src/adapters/claude-code/plain.js,
|
|
38
|
+
// pendingDir()). Either way it stays in the shell unless there is something
|
|
39
|
+
// to deliver.
|
|
40
|
+
function normalHookCommand(event, nodePath) {
|
|
41
|
+
return [
|
|
42
|
+
'if [ -n "$INJECT_DIR" ]; then [ -e "$INJECT_DIR/normal" ] || exit 0;',
|
|
43
|
+
'else set -- "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/inject-pending/*.jsonl; [ -e "$1" ] || exit 0; fi;',
|
|
44
|
+
`N="\${INJECT_NODE:-${nodePath.replace(/[\\"$`]/g, '\\$&')}}"; [ -x "$N" ] || N=node;`,
|
|
45
|
+
`exec "$N" ${shellQuote(HOOK_JS)} ${event} ${MARKER}`,
|
|
46
|
+
].join(' ');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Plain `claude` sessions don't have $INJECT_NODE, and a GUI-launched session
|
|
50
|
+
// may not have Node on PATH, so prefer the Node that ran the install.
|
|
51
|
+
export function promptHookCommand(nodePath = process.execPath) {
|
|
52
|
+
return `N=${shellQuote(nodePath)}; [ -x "$N" ] || N=node; exec "$N" ${shellQuote(PROMPT_HOOK_JS)} ${MARKER}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hooks() {
|
|
56
|
+
return [
|
|
57
|
+
{ event: 'PreToolUse', command: hookCommand('PreToolUse', 'urgent'), timeout: 10 },
|
|
58
|
+
{ event: 'PostToolUse', command: hookCommand('PostToolUse', 'normal'), timeout: 10 },
|
|
59
|
+
{ event: 'PostToolUseFailure', command: hookCommand('PostToolUseFailure', 'normal'), timeout: 10 },
|
|
60
|
+
{ event: 'UserPromptSubmit', command: promptHookCommand(), timeout: 5 },
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isOurs(handler) {
|
|
65
|
+
return typeof handler?.command === 'string' && handler.command.includes(MARKER);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readSettings(file) {
|
|
69
|
+
let raw;
|
|
70
|
+
try {
|
|
71
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err.code === 'ENOENT') return {};
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
if (!raw.trim()) return {};
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(raw);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
throw new Error(`${file} is not valid JSON (${err.message}). Fix it, then run inject --install again.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function writeSettings(file, settings) {
|
|
85
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
86
|
+
if (fs.existsSync(file)) fs.copyFileSync(file, `${file}.inject-backup`);
|
|
87
|
+
const tmp = `${file}.inject-tmp`;
|
|
88
|
+
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
|
89
|
+
fs.renameSync(tmp, file);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Removes our handlers; returns true if anything was removed. */
|
|
93
|
+
function strip(settings) {
|
|
94
|
+
const hooks = settings.hooks;
|
|
95
|
+
if (!hooks || typeof hooks !== 'object') return false;
|
|
96
|
+
let removed = false;
|
|
97
|
+
for (const event of Object.keys(hooks)) {
|
|
98
|
+
if (!Array.isArray(hooks[event])) continue;
|
|
99
|
+
hooks[event] = hooks[event]
|
|
100
|
+
.map((group) => {
|
|
101
|
+
if (!Array.isArray(group?.hooks)) return group;
|
|
102
|
+
const kept = group.hooks.filter((h) => !isOurs(h));
|
|
103
|
+
if (kept.length !== group.hooks.length) removed = true;
|
|
104
|
+
return { ...group, hooks: kept };
|
|
105
|
+
})
|
|
106
|
+
.filter((group) => !Array.isArray(group?.hooks) || group.hooks.length > 0);
|
|
107
|
+
if (hooks[event].length === 0) delete hooks[event];
|
|
108
|
+
}
|
|
109
|
+
if (Object.keys(hooks).length === 0) delete settings.hooks;
|
|
110
|
+
return removed;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function countOurs(settings) {
|
|
114
|
+
let n = 0;
|
|
115
|
+
for (const groups of Object.values(settings.hooks || {})) {
|
|
116
|
+
for (const g of Array.isArray(groups) ? groups : []) n += (g?.hooks || []).filter(isOurs).length;
|
|
117
|
+
}
|
|
118
|
+
return n;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function install() {
|
|
122
|
+
const file = settingsPath();
|
|
123
|
+
const settings = readSettings(file);
|
|
124
|
+
// Already exactly what we'd write: leave the file (and its key order) alone.
|
|
125
|
+
const wanted = hooks();
|
|
126
|
+
const present = (settings.hooks && wanted.every(({ event, command }) => (settings.hooks[event] || []).some((g) => (g.hooks || []).some((h) => h.command === command)))) || false;
|
|
127
|
+
if (present && countOurs(settings) === wanted.length) return { path: file, changed: false };
|
|
128
|
+
const before = JSON.stringify(settings);
|
|
129
|
+
strip(settings);
|
|
130
|
+
settings.hooks ??= {};
|
|
131
|
+
for (const { event, command, timeout } of hooks()) {
|
|
132
|
+
settings.hooks[event] ??= [];
|
|
133
|
+
settings.hooks[event].push({ hooks: [{ type: 'command', command, timeout }] });
|
|
134
|
+
}
|
|
135
|
+
const changed = JSON.stringify(settings) !== before;
|
|
136
|
+
if (changed) writeSettings(file, settings);
|
|
137
|
+
return { path: file, changed };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function uninstall() {
|
|
141
|
+
const file = settingsPath();
|
|
142
|
+
const settings = readSettings(file);
|
|
143
|
+
const changed = strip(settings);
|
|
144
|
+
if (changed) writeSettings(file, settings);
|
|
145
|
+
return { path: file, changed };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function isInstalled() {
|
|
149
|
+
try {
|
|
150
|
+
const installed = readSettings(settingsPath()).hooks || {};
|
|
151
|
+
return hooks().every(({ event, command }) =>
|
|
152
|
+
(installed[event] || []).some((g) => (g.hooks || []).some((h) => h.command === command)),
|
|
153
|
+
);
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// The /inject command for plain `claude` sessions is a mod (mod/), which Claude
|
|
161
|
+
// Code loads as a plugin. This repo is also a one-plugin marketplace, so
|
|
162
|
+
// installing it is two `claude plugin` commands. A directory marketplace loads
|
|
163
|
+
// in place, so edits under mod/ apply at the next session start.
|
|
164
|
+
|
|
165
|
+
const PLUGIN = 'inject@inject';
|
|
166
|
+
const PACKAGE_ROOT = fileURLToPath(new URL('../../../', import.meta.url));
|
|
167
|
+
|
|
168
|
+
function claude(args) {
|
|
169
|
+
const bin = process.env.INJECT_COMMAND || 'claude';
|
|
170
|
+
const env = { ...process.env };
|
|
171
|
+
delete env.CLAUDE_CODE_CHILD_SESSION;
|
|
172
|
+
const r = spawnSync(bin, ['plugin', ...args], { encoding: 'utf8', timeout: 60_000, env });
|
|
173
|
+
return { ok: r.status === 0, missing: r.error?.code === 'ENOENT', out: `${r.stdout || ''}${r.stderr || ''}`.trim() };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const manual = (cmds) => cmds.map((c) => ` claude plugin ${c}`).join('\n');
|
|
177
|
+
|
|
178
|
+
/** @returns {{ok:boolean, message:string}} */
|
|
179
|
+
export function installMod() {
|
|
180
|
+
const steps = [['marketplace', 'add', PACKAGE_ROOT], ['install', PLUGIN]];
|
|
181
|
+
for (const args of steps) {
|
|
182
|
+
const r = claude(args);
|
|
183
|
+
if (!r.ok) {
|
|
184
|
+
const why = r.missing ? "couldn't find the `claude` command on your PATH" : r.out.split('\n').pop();
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
message: `could not install the /inject command for plain claude (${why}). Run these yourself:\n${manual(steps.map((a) => a.map((x) => (x === PACKAGE_ROOT ? `"${PACKAGE_ROOT}"` : x)).join(' ')))}`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { ok: true, message: `/inject command installed for plain claude (plugin ${PLUGIN}). Start a new claude session to use it.` };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** @returns {{ok:boolean, message:string}} */
|
|
195
|
+
export function uninstallMod() {
|
|
196
|
+
if (!modInstalled()) return { ok: true, message: '' };
|
|
197
|
+
const a = claude(['uninstall', PLUGIN]);
|
|
198
|
+
const b = claude(['marketplace', 'remove', 'inject']);
|
|
199
|
+
if (a.ok && b.ok) return { ok: true, message: `/inject command removed (plugin ${PLUGIN})` };
|
|
200
|
+
return { ok: false, message: `could not remove the /inject command; run:\n${manual([`uninstall ${PLUGIN}`, 'marketplace remove inject'])}` };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function modInstalled() {
|
|
204
|
+
try {
|
|
205
|
+
const base = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
206
|
+
const plugins = JSON.parse(fs.readFileSync(path.join(base, 'plugins', 'installed_plugins.json'), 'utf8')).plugins;
|
|
207
|
+
return Array.isArray(plugins?.[PLUGIN]) && plugins[PLUGIN].length > 0;
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Turns claimed messages into Claude Code hook output.
|
|
2
|
+
|
|
3
|
+
import { formatNotes, formatUrgent } from '../../core/format.js';
|
|
4
|
+
|
|
5
|
+
export const BOUNDARY = {
|
|
6
|
+
PreToolUse: 'before-tool',
|
|
7
|
+
PostToolUse: 'after-tool',
|
|
8
|
+
PostToolUseFailure: 'after-tool',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Short description of a tool call, e.g. "Bash: npm test". */
|
|
12
|
+
export function describeTool(name, input) {
|
|
13
|
+
if (!name) return null;
|
|
14
|
+
const i = input || {};
|
|
15
|
+
const detail = i.command ?? i.file_path ?? i.notebook_path ?? i.path ?? i.pattern ?? i.url ?? i.query ?? i.description;
|
|
16
|
+
if (detail == null) return name;
|
|
17
|
+
const s = String(detail).replace(/\s+/g, ' ').trim();
|
|
18
|
+
return `${name}: ${s.length > 120 ? s.slice(0, 119) + '…' : s}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Turn claimed messages into Claude Code hook output (null = no output). */
|
|
22
|
+
export function buildOutput(event, messages) {
|
|
23
|
+
if (!messages.length) return null;
|
|
24
|
+
if (BOUNDARY[event] === 'before-tool') {
|
|
25
|
+
return {
|
|
26
|
+
hookSpecificOutput: {
|
|
27
|
+
hookEventName: event,
|
|
28
|
+
permissionDecision: 'deny',
|
|
29
|
+
permissionDecisionReason: formatUrgent(messages),
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
hookSpecificOutput: {
|
|
35
|
+
hookEventName: event,
|
|
36
|
+
additionalContext: formatNotes(messages),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Plain-`claude` mode: `/inject <msg>` in a session that was not started with
|
|
2
|
+
// the inject wrapper (normal urgency only).
|
|
3
|
+
//
|
|
4
|
+
// There is no wrapper process to hold a queue. The /inject command, registered
|
|
5
|
+
// by the mod in mod/ with immediate: true, appends each message to a per-session
|
|
6
|
+
// file in pendingDir(). The PostToolUse hooks claim that file at the next tool
|
|
7
|
+
// boundary. Claiming renames the file first, which is atomic, so parallel tool
|
|
8
|
+
// calls can't deliver a message twice.
|
|
9
|
+
//
|
|
10
|
+
// (A hook can't catch "/inject": in an interactive session Claude Code answers
|
|
11
|
+
// "Unknown command" before UserPromptSubmit fires.)
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
// Under the user's Claude config dir, not /tmp: another local user must not be
|
|
18
|
+
// able to plant messages for this user's sessions. Keep in sync with the mod
|
|
19
|
+
// (mod/hooks/register.js) and plainGuard() in install.js.
|
|
20
|
+
export function pendingDir() {
|
|
21
|
+
return path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'inject-pending');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const validId = (id) => typeof id === 'string' && /^[\w-]+$/.test(id);
|
|
25
|
+
|
|
26
|
+
/** Takes all pending messages for a session. */
|
|
27
|
+
export function claim(sessionId, { dir = pendingDir() } = {}) {
|
|
28
|
+
if (!validId(sessionId)) return [];
|
|
29
|
+
const file = path.join(dir, `${sessionId}.jsonl`);
|
|
30
|
+
const mine = `${file}.claimed-${process.pid}`;
|
|
31
|
+
try {
|
|
32
|
+
fs.renameSync(file, mine);
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
return fs
|
|
38
|
+
.readFileSync(mine, 'utf8')
|
|
39
|
+
.split('\n')
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
.map((l) => ({ ...JSON.parse(l), urgency: 'normal' }));
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
} finally {
|
|
45
|
+
fs.rmSync(mine, { force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Claude Code UserPromptSubmit hook. Runs in every session, with or without
|
|
2
|
+
// the inject wrapper, once per prompt you send.
|
|
3
|
+
//
|
|
4
|
+
// Logs prompts typed while Claude is working (they go into Claude Code's own
|
|
5
|
+
// queue) to .steer/fixes.jsonl. Prints nothing; never blocks or changes a prompt.
|
|
6
|
+
// Never throws; any failure exits 0.
|
|
7
|
+
|
|
8
|
+
import { handlePrompt } from './prompt.js';
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
let data = '';
|
|
12
|
+
process.stdin.setEncoding('utf8');
|
|
13
|
+
for await (const chunk of process.stdin) data += chunk;
|
|
14
|
+
handlePrompt(JSON.parse(data), { projectDir: process.env.CLAUDE_PROJECT_DIR });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
main().then(
|
|
18
|
+
() => process.exit(0),
|
|
19
|
+
() => process.exit(0),
|
|
20
|
+
);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Decides what the UserPromptSubmit hook does with a prompt. See prompt-hook.js.
|
|
2
|
+
//
|
|
3
|
+
// It only logs messages typed into Claude Code's own queue mid-turn. It never
|
|
4
|
+
// blocks or changes a prompt. (/inject is handled by the mod, not here.)
|
|
5
|
+
|
|
6
|
+
import { recordPrompt } from './steering.js';
|
|
7
|
+
|
|
8
|
+
/** @returns {null} the hook prints nothing */
|
|
9
|
+
export function handlePrompt(input, { projectDir } = {}) {
|
|
10
|
+
// /inject commands are queued and logged by the mod and the tool hooks.
|
|
11
|
+
if (typeof input.prompt === 'string' && /^\s*\/inject\b/.test(input.prompt)) return null;
|
|
12
|
+
recordPrompt(input, { projectDir });
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Logs messages typed into Claude Code's own queue mid-turn, so plain
|
|
2
|
+
// `claude` sessions (not started with inject) still record steering in
|
|
3
|
+
// .steer/fixes.jsonl.
|
|
4
|
+
//
|
|
5
|
+
// Claude Code fires UserPromptSubmit as soon as you press Enter, including on
|
|
6
|
+
// a message it queues while Claude is working. A queued message carries the
|
|
7
|
+
// running turn's prompt_id; a new turn gets a new one. So we remember the last
|
|
8
|
+
// prompt_id per session and log a prompt that repeats it.
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { appendFix } from '../../core/log.js';
|
|
14
|
+
import { describeTool } from './output.js';
|
|
15
|
+
|
|
16
|
+
const TAIL_BYTES = 512 * 1024;
|
|
17
|
+
|
|
18
|
+
export function stateDir() {
|
|
19
|
+
return path.join(process.env.XDG_RUNTIME_DIR || os.tmpdir(), 'inject-turns');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{session_id?:string, prompt_id?:string, prompt?:string,
|
|
24
|
+
* transcript_path?:string, cwd?:string}} input UserPromptSubmit input
|
|
25
|
+
* @returns {boolean} whether a mid-turn message was logged
|
|
26
|
+
*/
|
|
27
|
+
export function recordPrompt(input, { dir = stateDir(), projectDir, now = new Date() } = {}) {
|
|
28
|
+
const { session_id: sessionId, prompt_id: promptId, prompt } = input;
|
|
29
|
+
if (!sessionId || !promptId || typeof prompt !== 'string') return false;
|
|
30
|
+
if (!/^[\w-]+$/.test(sessionId)) return false;
|
|
31
|
+
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
33
|
+
const file = path.join(dir, sessionId);
|
|
34
|
+
let current = null;
|
|
35
|
+
try {
|
|
36
|
+
current = fs.readFileSync(file, 'utf8');
|
|
37
|
+
} catch {
|
|
38
|
+
// First prompt of this session.
|
|
39
|
+
}
|
|
40
|
+
if (current !== promptId) {
|
|
41
|
+
fs.writeFileSync(file, promptId);
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const text = prompt.trim();
|
|
46
|
+
if (!text) return false;
|
|
47
|
+
const ts = now.toISOString();
|
|
48
|
+
return appendFix(projectDir || input.cwd || process.cwd(), { text, urgency: 'normal', queuedAt: ts }, {
|
|
49
|
+
agent: 'claude-code',
|
|
50
|
+
deliveredAt: null,
|
|
51
|
+
event: 'claude-code-queue',
|
|
52
|
+
doing: lastToolActivity(input.transcript_path),
|
|
53
|
+
sessionId,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** "running Bash: npm test" / "just ran Edit: src/a.ts", from the transcript tail. */
|
|
58
|
+
export function lastToolActivity(transcriptPath) {
|
|
59
|
+
if (!transcriptPath) return null;
|
|
60
|
+
let lines;
|
|
61
|
+
try {
|
|
62
|
+
const fd = fs.openSync(transcriptPath, 'r');
|
|
63
|
+
try {
|
|
64
|
+
const { size } = fs.fstatSync(fd);
|
|
65
|
+
const len = Math.min(size, TAIL_BYTES);
|
|
66
|
+
const buf = Buffer.alloc(len);
|
|
67
|
+
fs.readSync(fd, buf, 0, len, size - len);
|
|
68
|
+
lines = buf.toString('utf8').split('\n');
|
|
69
|
+
if (len < size) lines.shift(); // partial first line
|
|
70
|
+
} finally {
|
|
71
|
+
fs.closeSync(fd);
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const finished = new Set();
|
|
78
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
79
|
+
let entry;
|
|
80
|
+
try {
|
|
81
|
+
entry = JSON.parse(lines[i]);
|
|
82
|
+
} catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const content = entry?.message?.content;
|
|
86
|
+
if (!Array.isArray(content)) continue;
|
|
87
|
+
for (const block of content) {
|
|
88
|
+
if (block?.type === 'tool_result') finished.add(block.tool_use_id);
|
|
89
|
+
}
|
|
90
|
+
const uses = content.filter((b) => b?.type === 'tool_use');
|
|
91
|
+
if (entry.message.role === 'assistant' && uses.length) {
|
|
92
|
+
const use = uses[uses.length - 1];
|
|
93
|
+
const tool = describeTool(use.name, use.input);
|
|
94
|
+
return `${finished.has(use.id) ? 'just ran' : 'running'} ${tool}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The contract an agent adapter fulfils. The wrapper, queue, log and
|
|
2
|
+
// interceptor are agent-neutral; an adapter only knows how to start its agent
|
|
3
|
+
// and how to plug a hook into it.
|
|
4
|
+
//
|
|
5
|
+
// A hook process connects to $INJECT_DIR/sock and sends
|
|
6
|
+
// {op:'claim', boundary:'before-tool'|'after-tool', agent, event, doing,
|
|
7
|
+
// projectDir, sessionId}
|
|
8
|
+
// then turns the returned messages into the agent's hook output:
|
|
9
|
+
// before-tool + messages -> block the tool call with formatUrgent(messages)
|
|
10
|
+
// after-tool + messages -> add formatNotes(messages) as context
|
|
11
|
+
//
|
|
12
|
+
// To add an agent (e.g. Codex), create src/adapters/<id>/ exporting an
|
|
13
|
+
// Adapter and a hook entry point, and register it in src/adapters/index.js.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} Adapter
|
|
17
|
+
* @property {string} id stable id, recorded in fixes.jsonl
|
|
18
|
+
* @property {string} name human-readable name
|
|
19
|
+
* @property {string} command executable the wrapper starts
|
|
20
|
+
* @property {() => {path:string, changed:boolean}} install register hooks
|
|
21
|
+
* @property {() => {path:string, changed:boolean}} uninstall remove hooks
|
|
22
|
+
* @property {() => boolean} isInstalled
|
|
23
|
+
* @property {() => {ok:boolean, message:string}} [installMod] optional: plain-session /inject command
|
|
24
|
+
* @property {() => {ok:boolean, message:string}} [uninstallMod]
|
|
25
|
+
* @property {() => boolean} [modInstalled]
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export {};
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// inject [agent args...] run the agent with /inject interception
|
|
2
|
+
// inject --install register hooks (one time)
|
|
3
|
+
// inject --uninstall remove hooks
|
|
4
|
+
//
|
|
5
|
+
// Everything other than the two flags above is passed to the agent unchanged,
|
|
6
|
+
// so `inject --resume` behaves like `claude --resume`.
|
|
7
|
+
|
|
8
|
+
import { defaultAdapter } from './adapters/index.js';
|
|
9
|
+
import { run } from './wrapper/run.js';
|
|
10
|
+
|
|
11
|
+
export async function main(argv) {
|
|
12
|
+
const adapter = defaultAdapter;
|
|
13
|
+
|
|
14
|
+
if (argv[0] === '--install' || argv[0] === '--uninstall') {
|
|
15
|
+
try {
|
|
16
|
+
const { path, changed } = argv[0] === '--install' ? adapter.install() : adapter.uninstall();
|
|
17
|
+
const verb = argv[0] === '--install' ? 'installed in' : 'removed from';
|
|
18
|
+
console.log(changed ? `inject: ${adapter.name} hooks ${verb} ${path}` : `inject: nothing to change in ${path}`);
|
|
19
|
+
if (changed) console.log(`inject: previous settings saved to ${path}.inject-backup`);
|
|
20
|
+
if (changed && argv[0] === '--install') {
|
|
21
|
+
console.log(`inject: already-running ${adapter.name} sessions pick this up after a restart.`);
|
|
22
|
+
}
|
|
23
|
+
const mod = argv[0] === '--install' ? adapter.installMod?.() : adapter.uninstallMod?.();
|
|
24
|
+
if (mod?.message) console[mod.ok ? 'log' : 'error'](`inject: ${mod.message}`);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.error(`inject: ${err.message}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!adapter.isInstalled()) {
|
|
33
|
+
process.stderr.write(
|
|
34
|
+
`inject: ${adapter.name} hooks are not installed, so /inject messages won't be delivered.\n` +
|
|
35
|
+
` Run \`inject --install\` once, then start inject again.\n`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await run(adapter, argv);
|
|
40
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Wording the agent sees. Agent-neutral: adapters wrap these strings in
|
|
2
|
+
// whatever output shape their hook system expects.
|
|
3
|
+
|
|
4
|
+
function bullets(msgs) {
|
|
5
|
+
if (msgs.length === 1) return msgs[0].text;
|
|
6
|
+
return msgs.map((m) => `- ${m.text}`).join('\n');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Context for normal notes delivered after a tool call. */
|
|
10
|
+
export function formatNotes(msgs) {
|
|
11
|
+
return [
|
|
12
|
+
`[inject] The user sent ${msgs.length === 1 ? 'a message' : 'messages'} while you were working. You were not interrupted:`,
|
|
13
|
+
'',
|
|
14
|
+
bullets(msgs),
|
|
15
|
+
'',
|
|
16
|
+
'Take this into account from your next step on.',
|
|
17
|
+
].join('\n');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Reason shown to the agent when an urgent message blocks a tool call. */
|
|
21
|
+
export function formatUrgent(msgs) {
|
|
22
|
+
const urgent = msgs.filter((m) => m.urgency === 'urgent');
|
|
23
|
+
const notes = msgs.filter((m) => m.urgency !== 'urgent');
|
|
24
|
+
const lines = [
|
|
25
|
+
'[inject] This tool call was blocked and did not run, because the user sent an urgent message while you were working:',
|
|
26
|
+
'',
|
|
27
|
+
bullets(urgent),
|
|
28
|
+
];
|
|
29
|
+
if (notes.length) lines.push('', 'Also from the user:', '', bullets(notes));
|
|
30
|
+
lines.push('', 'Re-plan with this in mind before you continue. If the blocked call is still right, you may retry it.');
|
|
31
|
+
return lines.join('\n');
|
|
32
|
+
}
|
package/src/core/ipc.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// One-request-per-connection JSON protocol over the session's unix socket.
|
|
2
|
+
//
|
|
3
|
+
// Request: {"op":"claim","boundary":"before-tool"|"after-tool","agent":..,
|
|
4
|
+
// "event":..,"doing":..,"projectDir":..,"sessionId":..}
|
|
5
|
+
// Response: {"messages":[{id,text,urgency,queuedAt}, ...]}
|
|
6
|
+
|
|
7
|
+
import net from 'node:net';
|
|
8
|
+
|
|
9
|
+
const MAX_REQUEST_BYTES = 1 << 20;
|
|
10
|
+
|
|
11
|
+
/** @param {(req:object)=>object} handle */
|
|
12
|
+
export function listen(sockPath, handle) {
|
|
13
|
+
const server = net.createServer((conn) => {
|
|
14
|
+
let buf = '';
|
|
15
|
+
conn.setEncoding('utf8');
|
|
16
|
+
conn.on('data', (chunk) => {
|
|
17
|
+
buf += chunk;
|
|
18
|
+
if (buf.length > MAX_REQUEST_BYTES) return conn.destroy();
|
|
19
|
+
const nl = buf.indexOf('\n');
|
|
20
|
+
if (nl === -1) return;
|
|
21
|
+
let reply;
|
|
22
|
+
try {
|
|
23
|
+
reply = handle(JSON.parse(buf.slice(0, nl)));
|
|
24
|
+
} catch (err) {
|
|
25
|
+
reply = { error: String(err && err.message ? err.message : err) };
|
|
26
|
+
}
|
|
27
|
+
conn.end(JSON.stringify(reply) + '\n');
|
|
28
|
+
});
|
|
29
|
+
conn.on('error', () => {});
|
|
30
|
+
});
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
server.once('error', reject);
|
|
33
|
+
server.listen(sockPath, () => resolve(server));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function request(sockPath, req, timeoutMs = 3000) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const conn = net.createConnection(sockPath);
|
|
40
|
+
let buf = '';
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
conn.destroy();
|
|
43
|
+
reject(new Error('timed out waiting for the inject wrapper'));
|
|
44
|
+
}, timeoutMs);
|
|
45
|
+
conn.setEncoding('utf8');
|
|
46
|
+
conn.on('connect', () => conn.write(JSON.stringify(req) + '\n'));
|
|
47
|
+
conn.on('data', (chunk) => (buf += chunk));
|
|
48
|
+
conn.on('end', () => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
try {
|
|
51
|
+
resolve(JSON.parse(buf));
|
|
52
|
+
} catch (err) {
|
|
53
|
+
reject(err);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
conn.on('error', (err) => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
reject(err);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
package/src/core/log.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Appends one JSON line per injected message to <project>/.steer/fixes.jsonl.
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export function logPath(projectDir) {
|
|
7
|
+
return path.join(projectDir, '.steer', 'fixes.jsonl');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} projectDir
|
|
12
|
+
* @param {{text:string, urgency:string, queuedAt:string}} msg
|
|
13
|
+
* @param {{agent?:string, deliveredAt?:string|null, event?:string|null,
|
|
14
|
+
* doing?:string|null, sessionId?:string|null}} delivery
|
|
15
|
+
*/
|
|
16
|
+
export function appendFix(projectDir, msg, delivery) {
|
|
17
|
+
const record = {
|
|
18
|
+
ts: msg.queuedAt,
|
|
19
|
+
text: msg.text,
|
|
20
|
+
urgency: msg.urgency,
|
|
21
|
+
agent: delivery.agent ?? null,
|
|
22
|
+
delivered_at: delivery.deliveredAt ?? null,
|
|
23
|
+
delivered_via: delivery.event ?? null,
|
|
24
|
+
doing: delivery.doing ?? null,
|
|
25
|
+
session_id: delivery.sessionId ?? null,
|
|
26
|
+
};
|
|
27
|
+
try {
|
|
28
|
+
fs.mkdirSync(path.dirname(logPath(projectDir)), { recursive: true });
|
|
29
|
+
fs.appendFileSync(logPath(projectDir), JSON.stringify(record) + '\n');
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|