roger-roger 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/LICENSE +21 -0
- package/README.md +147 -0
- package/package.json +45 -0
- package/skills/roger-roger/SKILL.md +289 -0
- package/skills/roger-roger/herdr-plugin.toml +38 -0
- package/skills/roger-roger/scripts/agent.mjs +132 -0
- package/skills/roger-roger/scripts/audio.mjs +392 -0
- package/skills/roger-roger/scripts/client.mjs +121 -0
- package/skills/roger-roger/scripts/daemon.mjs +604 -0
- package/skills/roger-roger/scripts/decisions.mjs +158 -0
- package/skills/roger-roger/scripts/handlers.mjs +1151 -0
- package/skills/roger-roger/scripts/herdr.mjs +140 -0
- package/skills/roger-roger/scripts/hooks-codex.mjs +154 -0
- package/skills/roger-roger/scripts/hooks-opencode.mjs +167 -0
- package/skills/roger-roger/scripts/hooks.mjs +420 -0
- package/skills/roger-roger/scripts/inbox.mjs +381 -0
- package/skills/roger-roger/scripts/install.mjs +560 -0
- package/skills/roger-roger/scripts/lib.mjs +1133 -0
- package/skills/roger-roger/scripts/names.mjs +84 -0
- package/skills/roger-roger/scripts/progress.mjs +91 -0
- package/skills/roger-roger/scripts/protocol.mjs +71 -0
- package/skills/roger-roger/scripts/roger-roger.mjs +536 -0
- package/skills/roger-roger/scripts/router.mjs +86 -0
- package/skills/roger-roger/scripts/sessions.mjs +218 -0
- package/skills/roger-roger/scripts/slack.mjs +240 -0
- package/skills/roger-roger/scripts/slackapp.mjs +205 -0
- package/skills/roger-roger/scripts/slackcli.mjs +144 -0
- package/skills/roger-roger/scripts/speaker.mjs +224 -0
- package/skills/roger-roger/scripts/speechkey.mjs +106 -0
- package/skills/roger-roger/scripts/tray.mjs +128 -0
- package/skills/roger-roger/scripts/tts.mjs +275 -0
- package/skills/roger-roger/scripts/tui.mjs +465 -0
- package/skills/roger-roger/slack/manifest.json +34 -0
- package/skills/roger-roger/sounds/alert.wav +0 -0
- package/skills/roger-roger/sounds/bubble.wav +0 -0
- package/skills/roger-roger/sounds/chime.wav +0 -0
- package/skills/roger-roger/sounds/ding.wav +0 -0
- package/skills/roger-roger/sounds/marimba.wav +0 -0
- package/skills/roger-roger/tray/main.mjs +749 -0
- package/skills/roger-roger/tray/panel.html +501 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Herdr (a terminal multiplexer for coding agents) shows every pane in a sidebar as
|
|
2
|
+
// `<directory> <tab label> <agent label>`. Out of the box that reads "code-red 2 claude" — the tab
|
|
3
|
+
// number and the agent's kind — which says nothing about *which* claude, while the user's Slack
|
|
4
|
+
// messages arrive signed "🍯 amber". This closes that gap: when a session runs inside a Herdr pane,
|
|
5
|
+
// its tab is labelled with the session's badge (`🍯 amber`) and its agent with the kind and the work
|
|
6
|
+
// (`claude-build-process-improvements`), so the sidebar and Slack name the same thing.
|
|
7
|
+
//
|
|
8
|
+
// The pane's context comes from the environment Herdr injects into the pane (HERDR_ENV=1 and the
|
|
9
|
+
// ids). The CLI runs there and passes it along with its identity; the daemon, which runs somewhere
|
|
10
|
+
// else entirely, does the renaming with the `herdr` binary against that pane's ids. Renaming is
|
|
11
|
+
// idempotent and repeated only when the nickname or the work changes.
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { badge } from "./names.mjs";
|
|
15
|
+
|
|
16
|
+
/** Herdr's own rule for an agent name. */
|
|
17
|
+
const AGENT_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
18
|
+
const MAX_AGENT_NAME = 32;
|
|
19
|
+
const RUN_TIMEOUT_MS = 10_000;
|
|
20
|
+
|
|
21
|
+
const str = (v) => (typeof v === "string" && v.trim() ? v.trim() : "");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The pane this command runs in, or null outside Herdr. The socket and binary are carried along so
|
|
25
|
+
* the daemon can reach the same Herdr server from wherever it lives.
|
|
26
|
+
*/
|
|
27
|
+
export function herdrContext(env = process.env) {
|
|
28
|
+
if (str(env.HERDR_ENV) !== "1") return null;
|
|
29
|
+
const tab = str(env.HERDR_TAB_ID);
|
|
30
|
+
const pane = str(env.HERDR_PANE_ID);
|
|
31
|
+
if (!tab || !pane) return null;
|
|
32
|
+
return {
|
|
33
|
+
tab,
|
|
34
|
+
pane,
|
|
35
|
+
workspace: str(env.HERDR_WORKSPACE_ID) || null,
|
|
36
|
+
socket: str(env.HERDR_SOCKET_PATH) || null,
|
|
37
|
+
bin: str(env.HERDR_BIN_PATH) || null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What the tab says: the session's badge, `🍯 amber`. */
|
|
42
|
+
export function tabLabel(session) {
|
|
43
|
+
return session?.nickname ? badge(session.nickname) : "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What the agent is called: its kind and its work, in the only shape Herdr accepts —
|
|
48
|
+
* `claude-build-process-improvements`. Without a `--session` name there is nothing to add to what
|
|
49
|
+
* Herdr already shows (the kind), so the agent is left alone.
|
|
50
|
+
*/
|
|
51
|
+
export function agentName(session) {
|
|
52
|
+
const work = slug(session?.name);
|
|
53
|
+
if (!work) return "";
|
|
54
|
+
const kind = slug(session?.agent) || "agent";
|
|
55
|
+
const name = `${kind}-${work}`.slice(0, MAX_AGENT_NAME).replace(/[-_]+$/, "");
|
|
56
|
+
return AGENT_NAME.test(name) ? name : "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function slug(text) {
|
|
60
|
+
return String(text ?? "")
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
63
|
+
.replace(/^[^a-z]+/, "")
|
|
64
|
+
.replace(/-+$/, "");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The labels a session should carry, or null when it isn't in a Herdr pane. */
|
|
68
|
+
export function wantedLabels(session) {
|
|
69
|
+
if (!session?.herdr?.tab || !session?.herdr?.pane) return null;
|
|
70
|
+
return { tab: tabLabel(session), agent: agentName(session) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The `herdr` invocations that put those labels on; nothing for a label that is already there. */
|
|
74
|
+
export function labelCommands(session, current = session?.herdrLabel ?? null) {
|
|
75
|
+
const wanted = wantedLabels(session);
|
|
76
|
+
if (!wanted) return [];
|
|
77
|
+
const commands = [];
|
|
78
|
+
if (wanted.tab && wanted.tab !== current?.tab) commands.push(["tab", "rename", session.herdr.tab, wanted.tab]);
|
|
79
|
+
if (wanted.agent && wanted.agent !== current?.agent) commands.push(["agent", "rename", session.herdr.pane, wanted.agent]);
|
|
80
|
+
return commands;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Run one `herdr` command. Resolves with its stdout, rejects with what it said on stderr. */
|
|
84
|
+
export function runHerdr(args, { bin = null, socket = null, env = process.env, timeoutMs = RUN_TIMEOUT_MS } = {}) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
// Never inherit another pane's context: the ids are explicit, and only the socket matters.
|
|
87
|
+
const clean = Object.fromEntries(Object.entries(env).filter(([k]) => !k.startsWith("HERDR_")));
|
|
88
|
+
if (socket) clean.HERDR_SOCKET_PATH = socket;
|
|
89
|
+
let out = "";
|
|
90
|
+
let err = "";
|
|
91
|
+
const child = spawn(bin || "herdr", args, { env: clean, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
92
|
+
const timer = setTimeout(() => child.kill(), timeoutMs);
|
|
93
|
+
child.stdout.on("data", (d) => (out += d));
|
|
94
|
+
child.stderr.on("data", (d) => (err += d));
|
|
95
|
+
child.on("error", (e) => (clearTimeout(timer), reject(e)));
|
|
96
|
+
child.on("close", (code) => {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
if (code === 0) return resolve(out);
|
|
99
|
+
reject(new Error(errorText(err || out) || `herdr ${args.slice(0, 2).join(" ")} exited with ${code}`));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Herdr's errors are JSON on stderr; the message is the useful part. */
|
|
105
|
+
function errorText(text) {
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(text)?.error?.message ?? text.trim();
|
|
108
|
+
} catch {
|
|
109
|
+
return text.trim();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const inFlight = new Set();
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Put the session's labels on its Herdr pane, if it has one and they are not there yet. Never
|
|
117
|
+
* throws: a pane that can't be labelled is a line in the log, not a failed command. `touch` records
|
|
118
|
+
* what was put on, so it isn't done again; `run` is the herdr invocation (replaced in tests).
|
|
119
|
+
*/
|
|
120
|
+
export async function labelPane(session, { log = () => {}, touch = () => {}, run = runHerdr } = {}) {
|
|
121
|
+
const commands = labelCommands(session);
|
|
122
|
+
if (!commands.length || inFlight.has(session.id)) return { labelled: false };
|
|
123
|
+
inFlight.add(session.id);
|
|
124
|
+
const done = { ...(session.herdrLabel ?? {}) };
|
|
125
|
+
try {
|
|
126
|
+
for (const args of commands) {
|
|
127
|
+
try {
|
|
128
|
+
await run(args, { bin: session.herdr.bin, socket: session.herdr.socket });
|
|
129
|
+
done[args[0]] = args[3];
|
|
130
|
+
} catch (e) {
|
|
131
|
+
log(`herdr: could not label the ${args[0]} of ${session.nickname} (${args[2]}): ${e.message}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const changed = commands.some((args) => done[args[0]] === args[3]);
|
|
135
|
+
if (changed) touch(session.id, { herdrLabel: done });
|
|
136
|
+
return { labelled: changed, label: done };
|
|
137
|
+
} finally {
|
|
138
|
+
inFlight.delete(session.id);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Codex's side of the terminal-question ping (see hooks.mjs for the idea).
|
|
2
|
+
//
|
|
3
|
+
// Codex asks the user with its `request_user_input` tool — in Plan mode, or anywhere with its
|
|
4
|
+
// `default_mode_request_user_input` feature — and its hooks see that tool like any other:
|
|
5
|
+
//
|
|
6
|
+
// PreToolUse (request_user_input) the question is about to show: note it, wait, ping
|
|
7
|
+
// PostToolUse (request_user_input) answered in the terminal: the ping turns into the answer
|
|
8
|
+
// Interrupt the turn was cut short, which is how a dismissed question ends: close what's open
|
|
9
|
+
//
|
|
10
|
+
// Codex can't say whether anyone is typing, so the ping waits `terminalWait` seconds first. There
|
|
11
|
+
// is no ping for leave to run a command here: nothing fires when the user answers an approval, so
|
|
12
|
+
// there'd be no telling a prompt still waiting from one approved a second after it showed.
|
|
13
|
+
//
|
|
14
|
+
// Hooks live in ~/.codex/hooks.json, in the same layout as Claude Code's. Codex runs each command
|
|
15
|
+
// through a shell — PowerShell on Windows, sh elsewhere — and won't run a hook until the user has
|
|
16
|
+
// reviewed it in `/hooks`, so installing ends with asking them to do that once.
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { ourHooks, readSettings, withOurHooks, withoutOurHooks, writeSettings } from "./hooks.mjs";
|
|
22
|
+
|
|
23
|
+
const str = (v) => (typeof v === "string" ? v.trim() : "");
|
|
24
|
+
const QUESTION_TOOL = "request_user_input";
|
|
25
|
+
const NOTE = /^user_note:\s*/;
|
|
26
|
+
|
|
27
|
+
/** Codex's questions in the shape the rest of the code reads, keeping each one's id for the answer. */
|
|
28
|
+
function asQuestions(list) {
|
|
29
|
+
return {
|
|
30
|
+
questions: (Array.isArray(list) ? list : []).map((q) => ({
|
|
31
|
+
id: str(q?.id),
|
|
32
|
+
question: str(q?.question),
|
|
33
|
+
header: str(q?.header),
|
|
34
|
+
options: (Array.isArray(q?.options) ? q.options : []).map((o) => ({ label: str(o?.label), description: str(o?.description) })),
|
|
35
|
+
})),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The answers, keyed by question text. Codex's tool result is a JSON string of
|
|
41
|
+
* `{ answers: { <question id>: { answers: ["label", "user_note: …"] } } }`; a note typed with an
|
|
42
|
+
* answer is kept, in brackets.
|
|
43
|
+
*/
|
|
44
|
+
export function codexAnswers(input, response) {
|
|
45
|
+
let parsed = response;
|
|
46
|
+
if (typeof parsed === "string") {
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(parsed);
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const byId = parsed?.answers && typeof parsed.answers === "object" ? parsed.answers : {};
|
|
54
|
+
const answers = {};
|
|
55
|
+
for (const q of asQuestions(input?.questions).questions) {
|
|
56
|
+
const given = byId[q.id]?.answers;
|
|
57
|
+
if (!Array.isArray(given)) continue;
|
|
58
|
+
const labels = given.map(String).filter((a) => !NOTE.test(a));
|
|
59
|
+
const notes = given.map(String).filter((a) => NOTE.test(a)).map((a) => a.replace(NOTE, "").trim()).filter(Boolean);
|
|
60
|
+
answers[q.question] = [labels.join(", "), ...notes.map((n) => `(${n})`)].filter(Boolean).join(" ");
|
|
61
|
+
}
|
|
62
|
+
return answers;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What Codex sent, as a `permission` command: `{ sessionId, cwd, args, waitFirst }`, or null. */
|
|
66
|
+
export function codexHookRequest(payload) {
|
|
67
|
+
if (!payload || typeof payload !== "object") return null;
|
|
68
|
+
const sessionId = str(payload.session_id);
|
|
69
|
+
if (!sessionId) return null;
|
|
70
|
+
const cwd = str(payload.cwd);
|
|
71
|
+
const base = { sessionId, cwd };
|
|
72
|
+
const event = payload.hook_event_name;
|
|
73
|
+
if (event === "Interrupt") return { ...base, args: { event: "interrupted", cwd } };
|
|
74
|
+
if (str(payload.tool_name) !== QUESTION_TOOL) return null;
|
|
75
|
+
const id = str(payload.tool_use_id);
|
|
76
|
+
if (!id) return null;
|
|
77
|
+
const input = payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {};
|
|
78
|
+
if (event === "PreToolUse") {
|
|
79
|
+
return { ...base, args: { event: "request", id, tool: "AskUserQuestion", input: asQuestions(input.questions), cwd }, waitFirst: true };
|
|
80
|
+
}
|
|
81
|
+
if (event === "PostToolUse") {
|
|
82
|
+
// No answers in it means the question never got one: cancelled, or the tool failed.
|
|
83
|
+
const answers = codexAnswers(input, payload.tool_response);
|
|
84
|
+
const outcome = Object.keys(answers).length ? "answered" : "dismissed";
|
|
85
|
+
return { ...base, args: { event: "answered", id, tool: "AskUserQuestion", outcome, response: { answers }, cwd } };
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------- hooks.json
|
|
91
|
+
|
|
92
|
+
export const codexHome = (env = process.env) => str(env.CODEX_HOME) || path.join(os.homedir(), ".codex");
|
|
93
|
+
export const codexHooksPath = (env = process.env) => path.join(codexHome(env), "hooks.json");
|
|
94
|
+
|
|
95
|
+
const CODEX_EVENTS = { PreToolUse: QUESTION_TOOL, PostToolUse: QUESTION_TOOL, Interrupt: null };
|
|
96
|
+
|
|
97
|
+
/** Quoted for sh: single quotes, with any single quote closed, escaped and reopened. */
|
|
98
|
+
const shQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
99
|
+
/** Quoted for PowerShell: single quotes are literal, and a single quote inside is doubled. */
|
|
100
|
+
const psQuote = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Codex runs a hook's command through a shell, so the script path is quoted — for sh in `command`,
|
|
104
|
+
* and for PowerShell in `commandWindows`, which Codex uses instead on Windows.
|
|
105
|
+
*/
|
|
106
|
+
export function codexHookCommand(script) {
|
|
107
|
+
const file = script.split(path.sep).join("/");
|
|
108
|
+
return {
|
|
109
|
+
type: "command",
|
|
110
|
+
command: `node ${shQuote(file)} hook codex`,
|
|
111
|
+
commandWindows: `node ${psQuote(file)} hook codex`,
|
|
112
|
+
async: true,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const TRUST = "Codex runs a hook only once you've reviewed it: open Codex, run /hooks, and trust the three roger-roger hooks (again after each reinstall).";
|
|
117
|
+
|
|
118
|
+
export function codexStatus({ env = process.env, script } = {}) {
|
|
119
|
+
const file = codexHooksPath(env);
|
|
120
|
+
const found = ourHooks(readSettings(file));
|
|
121
|
+
const events = Object.keys(CODEX_EVENTS);
|
|
122
|
+
const installed = events.every((e) => found[e]);
|
|
123
|
+
const scripts = [...new Set(Object.values(found).map((f) => f.script))];
|
|
124
|
+
return {
|
|
125
|
+
agent: "codex",
|
|
126
|
+
hooks: file,
|
|
127
|
+
installed,
|
|
128
|
+
partial: !installed && events.some((e) => found[e]),
|
|
129
|
+
found,
|
|
130
|
+
...(scripts.length ? { script: scripts[0], scriptExists: scripts.every((s) => s && fs.existsSync(s)) } : {}),
|
|
131
|
+
...(installed ? { trust: TRUST } : {}),
|
|
132
|
+
...(script && scripts.length && scripts.some((s) => path.resolve(s) !== path.resolve(script)) ? { note: "installed from a different copy of the skill; `hooks install codex` points them at this one" } : {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function installCodex({ env = process.env, script }) {
|
|
137
|
+
const file = codexHooksPath(env);
|
|
138
|
+
const before = readSettings(file);
|
|
139
|
+
const after = withOurHooks(before, script, { events: CODEX_EVENTS, handler: codexHookCommand });
|
|
140
|
+
const changed = JSON.stringify(before) !== JSON.stringify(after);
|
|
141
|
+
if (changed) writeSettings(file, after);
|
|
142
|
+
return { ...codexStatus({ env, script }), changed, ...(changed ? { next: TRUST } : {}) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function uninstallCodex({ env = process.env } = {}) {
|
|
146
|
+
const file = codexHooksPath(env);
|
|
147
|
+
const before = readSettings(file);
|
|
148
|
+
const after = withoutOurHooks(before);
|
|
149
|
+
const changed = JSON.stringify(before) !== JSON.stringify(after);
|
|
150
|
+
if (changed) writeSettings(file, after);
|
|
151
|
+
// Nothing of anyone's left: no file, rather than one Codex might not accept.
|
|
152
|
+
if (changed && !Object.keys(after).length) fs.rmSync(file, { force: true });
|
|
153
|
+
return { ...codexStatus({ env }), changed };
|
|
154
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// OpenCode's side of the terminal-question ping (see hooks.mjs for the idea).
|
|
2
|
+
//
|
|
3
|
+
// OpenCode has no hook commands; it has plugins — a JS module in its config directory, loaded into
|
|
4
|
+
// the OpenCode process, handed every event on its bus. Ours is a few lines that forward the events
|
|
5
|
+
// we care about to `roger-roger hook opencode`, exactly as Claude Code's hooks run it, so all the
|
|
6
|
+
// deciding happens here and the plugin never needs updating. It also puts the session id in the
|
|
7
|
+
// shell's environment, so the agent's own roger-roger commands land on the same session as the pings.
|
|
8
|
+
//
|
|
9
|
+
// question.asked the agent asked something with its question tool: note it, wait, ping
|
|
10
|
+
// question.replied answered in the terminal: the ping turns into the answer
|
|
11
|
+
// question.rejected dismissed in the terminal
|
|
12
|
+
// permission.asked leave to act (pings only with `setup --permission-pings on`)
|
|
13
|
+
// permission.replied allowed or denied in the terminal
|
|
14
|
+
//
|
|
15
|
+
// OpenCode can't say whether anyone is typing, so the ping waits `terminalWait` seconds first and
|
|
16
|
+
// goes out only if nothing was answered meanwhile.
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
const str = (v) => (typeof v === "string" ? v.trim() : "");
|
|
23
|
+
const MARKER = "roger-roger: written by `roger-roger hooks install opencode`";
|
|
24
|
+
|
|
25
|
+
/** OpenCode's questions in the shape the rest of the code reads (Claude Code's AskUserQuestion). */
|
|
26
|
+
function asQuestions(list) {
|
|
27
|
+
return {
|
|
28
|
+
questions: (Array.isArray(list) ? list : []).map((q) => ({
|
|
29
|
+
question: str(q?.question),
|
|
30
|
+
header: str(q?.header),
|
|
31
|
+
options: (Array.isArray(q?.options) ? q.options : []).map((o) => ({ label: str(o?.label), description: str(o?.description) })),
|
|
32
|
+
multiSelect: Boolean(q?.multiple),
|
|
33
|
+
})),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A permission as a tool call: OpenCode names permissions after its tools, in lower case. */
|
|
38
|
+
function asToolCall(permission, patterns, metadata) {
|
|
39
|
+
const first = patterns[0] ?? "";
|
|
40
|
+
switch (permission) {
|
|
41
|
+
case "bash":
|
|
42
|
+
return { tool: "Bash", input: { command: str(metadata.command) || patterns.join("\n"), description: str(metadata.description) } };
|
|
43
|
+
case "edit":
|
|
44
|
+
case "write":
|
|
45
|
+
return { tool: permission === "edit" ? "Edit" : "Write", input: { file_path: str(metadata.filepath) || str(metadata.filePath) || first } };
|
|
46
|
+
case "read":
|
|
47
|
+
return { tool: "Read", input: { file_path: str(metadata.filepath) || str(metadata.filePath) || first } };
|
|
48
|
+
case "webfetch":
|
|
49
|
+
return { tool: "WebFetch", input: { url: str(metadata.url) || first } };
|
|
50
|
+
default:
|
|
51
|
+
return { tool: permission || "permission", input: { patterns } };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What the plugin forwarded, as a `permission` command: `{ sessionId, cwd, args, waitFirst }`, or
|
|
57
|
+
* null. `waitFirst` asks the caller to hold on for the user's terminal wait and then ping.
|
|
58
|
+
*/
|
|
59
|
+
export function opencodeHookRequest(payload) {
|
|
60
|
+
if (!payload || typeof payload !== "object") return null;
|
|
61
|
+
const p = payload.properties && typeof payload.properties === "object" ? payload.properties : {};
|
|
62
|
+
const sessionId = str(p.sessionID);
|
|
63
|
+
if (!sessionId) return null;
|
|
64
|
+
const cwd = str(payload.directory);
|
|
65
|
+
const base = { sessionId, cwd };
|
|
66
|
+
switch (payload.type) {
|
|
67
|
+
case "question.asked":
|
|
68
|
+
if (!str(p.id)) return null;
|
|
69
|
+
return { ...base, args: { event: "request", id: str(p.id), tool: "AskUserQuestion", input: asQuestions(p.questions), cwd }, waitFirst: true };
|
|
70
|
+
case "question.replied":
|
|
71
|
+
return { ...base, args: { event: "answered", id: str(p.requestID), tool: "AskUserQuestion", response: { answers: Array.isArray(p.answers) ? p.answers : [] }, cwd } };
|
|
72
|
+
case "question.rejected":
|
|
73
|
+
return { ...base, args: { event: "answered", id: str(p.requestID), tool: "AskUserQuestion", outcome: "dismissed", cwd } };
|
|
74
|
+
case "permission.asked": {
|
|
75
|
+
if (!str(p.id)) return null;
|
|
76
|
+
const patterns = (Array.isArray(p.patterns) ? p.patterns : []).map(String);
|
|
77
|
+
const metadata = p.metadata && typeof p.metadata === "object" ? p.metadata : {};
|
|
78
|
+
const { tool, input } = asToolCall(str(p.permission), patterns, metadata);
|
|
79
|
+
return { ...base, args: { event: "request", id: str(p.id), tool, input, cwd }, waitFirst: true };
|
|
80
|
+
}
|
|
81
|
+
case "permission.replied":
|
|
82
|
+
return { ...base, args: { event: "answered", id: str(p.requestID), outcome: p.reply === "reject" ? "denied" : "allowed", cwd } };
|
|
83
|
+
default:
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------- the plugin file
|
|
89
|
+
|
|
90
|
+
/** OpenCode's global config directory: OPENCODE_CONFIG_DIR, else $XDG_CONFIG_HOME or ~/.config. */
|
|
91
|
+
export function opencodeConfigDir(env = process.env) {
|
|
92
|
+
if (str(env.OPENCODE_CONFIG_DIR)) return str(env.OPENCODE_CONFIG_DIR);
|
|
93
|
+
return path.join(str(env.XDG_CONFIG_HOME) || path.join(os.homedir(), ".config"), "opencode");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const opencodePluginPath = (env = process.env) => path.join(opencodeConfigDir(env), "plugins", "roger-roger.js");
|
|
97
|
+
|
|
98
|
+
/** The plugin, with the script it runs written in. Plain JS, no imports but Node's, no build. */
|
|
99
|
+
export function opencodePlugin(script) {
|
|
100
|
+
return `// ${MARKER}
|
|
101
|
+
// It forwards OpenCode's question and permission events to roger-roger, which pings you when a
|
|
102
|
+
// question has sat unanswered, and updates the ping once you answer. \`roger-roger hooks uninstall
|
|
103
|
+
// opencode\` deletes this file.
|
|
104
|
+
import { spawn } from "node:child_process";
|
|
105
|
+
|
|
106
|
+
const SCRIPT = ${JSON.stringify(script.split(path.sep).join("/"))};
|
|
107
|
+
const EVENTS = new Set(["question.asked", "question.replied", "question.rejected", "permission.asked", "permission.replied"]);
|
|
108
|
+
|
|
109
|
+
function forward(payload) {
|
|
110
|
+
try {
|
|
111
|
+
const child = spawn("node", [SCRIPT, "hook", "opencode"], { stdio: ["pipe", "ignore", "ignore"], windowsHide: true });
|
|
112
|
+
child.on("error", () => {});
|
|
113
|
+
child.stdin.on("error", () => {});
|
|
114
|
+
child.stdin.end(JSON.stringify(payload));
|
|
115
|
+
} catch {
|
|
116
|
+
// A notification must never get in OpenCode's way.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const RogerRoger = async ({ directory }) => ({
|
|
121
|
+
event: async ({ event }) => {
|
|
122
|
+
if (EVENTS.has(event?.type)) forward({ type: event.type, properties: event.properties, directory });
|
|
123
|
+
},
|
|
124
|
+
// So the agent's own roger-roger commands are recognised as this session.
|
|
125
|
+
"shell.env": async (input, output) => {
|
|
126
|
+
if (input?.sessionID && output?.env) output.env.OPENCODE_SESSION_ID = input.sessionID;
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const scriptIn = (source) => /const SCRIPT = ("(?:[^"\\]|\\.)*");/.exec(source)?.[1];
|
|
133
|
+
|
|
134
|
+
export function opencodeStatus({ env = process.env, script } = {}) {
|
|
135
|
+
const file = opencodePluginPath(env);
|
|
136
|
+
const source = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
137
|
+
const ours = source.includes(MARKER);
|
|
138
|
+
const installed = ours ? JSON.parse(scriptIn(source) ?? "null") : null;
|
|
139
|
+
return {
|
|
140
|
+
agent: "opencode",
|
|
141
|
+
plugin: file,
|
|
142
|
+
installed: ours,
|
|
143
|
+
...(source && !ours ? { note: "a file of that name exists and isn't ours; it is left alone" } : {}),
|
|
144
|
+
...(installed ? { script: installed, scriptExists: fs.existsSync(installed) } : {}),
|
|
145
|
+
...(script && installed && path.resolve(installed) !== path.resolve(script) ? { note: "installed from a different copy of the skill; `hooks install opencode` points it at this one" } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function installOpencode({ env = process.env, script }) {
|
|
150
|
+
const file = opencodePluginPath(env);
|
|
151
|
+
const source = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
152
|
+
if (source && !source.includes(MARKER)) throw new Error(`${file} exists and wasn't written by roger-roger; move it, then run this again`);
|
|
153
|
+
const next = opencodePlugin(script);
|
|
154
|
+
const changed = source !== next;
|
|
155
|
+
if (changed) {
|
|
156
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
157
|
+
fs.writeFileSync(file, next, "utf8");
|
|
158
|
+
}
|
|
159
|
+
return { ...opencodeStatus({ env, script }), changed, ...(changed ? { restart: "restart OpenCode to load it" } : {}) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function uninstallOpencode({ env = process.env } = {}) {
|
|
163
|
+
const file = opencodePluginPath(env);
|
|
164
|
+
const ours = fs.existsSync(file) && fs.readFileSync(file, "utf8").includes(MARKER);
|
|
165
|
+
if (ours) fs.rmSync(file);
|
|
166
|
+
return { ...opencodeStatus({ env }), changed: ours };
|
|
167
|
+
}
|