@micsushi/agent-hotline 0.5.1

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,402 @@
1
+ const fs = require("fs");
2
+ const os = require("os");
3
+ const path = require("path");
4
+
5
+ const SKILL_NAME = "agent-hotline-spoken";
6
+ const MANAGED_BLOCK_START = "<!-- AGENT_HOTLINE_SPOKEN_START -->";
7
+ const MANAGED_BLOCK_END = "<!-- AGENT_HOTLINE_SPOKEN_END -->";
8
+ const VALID_HARNESSES = ["antigravity", "claude-code", "codex", "all"];
9
+ const VALID_SCOPES = ["global", "repo"];
10
+ const NPX_PACKAGE_NAME = "@micsushi/agent-hotline";
11
+ const NPX_HOOK_COMMAND = `npx --yes ${NPX_PACKAGE_NAME} hook`;
12
+
13
+ function packageRoot() {
14
+ return path.resolve(__dirname, "..");
15
+ }
16
+
17
+ function repoRoot() {
18
+ return path.resolve(packageRoot(), "..", "..");
19
+ }
20
+
21
+ function defaultHome() {
22
+ return os.homedir();
23
+ }
24
+
25
+ function defaultHookCommand() {
26
+ return `node "${path.join(packageRoot(), "bin", "agent-hotline.js")}" hook`;
27
+ }
28
+
29
+ function npxHookCommand() {
30
+ return NPX_HOOK_COMMAND;
31
+ }
32
+
33
+ // Embed an arbitrary string into the generated .ps1 as a PowerShell single-quoted
34
+ // literal. Single quotes take their contents verbatim -- no backslash or
35
+ // double-quote processing -- so the only escaping needed is doubling embedded
36
+ // single quotes. This is the ONLY supported way to inline a value into the ps1.
37
+ // Never use JSON.stringify() for this: its \" and \\ escapes are JSON syntax, not
38
+ // PowerShell (which escapes with backticks), so a Windows command like
39
+ // `node "C:\path\x.js" hook` becomes `"node \"C:\\path\\x.js\" hook"`, which
40
+ // PowerShell fails to PARSE -- the script dies before its try/catch and never
41
+ // runs. That class of bug is impossible once everything routes through here.
42
+ function toPowerShellSingleQuoted(value) {
43
+ return `'${String(value).replace(/'/g, "''")}'`;
44
+ }
45
+
46
+ function skillSourcePath() {
47
+ return path.join(packageRoot(), "skills", SKILL_NAME, "SKILL.md");
48
+ }
49
+
50
+ function readFile(filePath) {
51
+ return fs.readFileSync(filePath, "utf8");
52
+ }
53
+
54
+ function readJsonSafe(filePath) {
55
+ try {
56
+ return JSON.parse(readFile(filePath));
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function writeFile(filePath, content) {
63
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
64
+ fs.writeFileSync(filePath, content, "utf8");
65
+ }
66
+
67
+ function copyFile(source, target) {
68
+ fs.mkdirSync(path.dirname(target), { recursive: true });
69
+ fs.copyFileSync(source, target);
70
+ }
71
+
72
+ function normalizeHarness(value) {
73
+ const key = String(value || "").toLowerCase();
74
+ if (key === "claude") return "claude-code";
75
+ if (key === "all") return "all";
76
+ return key;
77
+ }
78
+
79
+ function validateChoice(name, value, validValues) {
80
+ if (!validValues.includes(value)) {
81
+ throw new Error(`Invalid ${name} "${value}". Use one of: ${validValues.join(", ")}.`);
82
+ }
83
+ }
84
+
85
+ function harnessDefinitions(home = defaultHome()) {
86
+ return {
87
+ antigravity: {
88
+ label: "Antigravity",
89
+ scopes: ["global"],
90
+ globalDir: path.join(home, ".gemini", "config"),
91
+ hooksDirName: "hooks",
92
+ hooksConfigName: "hooks.json",
93
+ source: "antigravity",
94
+ schema: "assistant_response",
95
+ skillTargets: ["antigravity"]
96
+ },
97
+ "claude-code": {
98
+ label: "Claude Code",
99
+ scopes: ["global", "repo"],
100
+ globalDir: path.join(home, ".claude"),
101
+ hooksDirName: "hooks",
102
+ hooksConfigName: "settings.json",
103
+ repoConfigName: "settings.local.json",
104
+ source: "claude",
105
+ schema: "assistant_response",
106
+ skillTargets: ["claude-code"]
107
+ },
108
+ codex: {
109
+ label: "Codex",
110
+ scopes: ["global", "repo"],
111
+ globalDir: path.join(home, ".codex"),
112
+ hooksDirName: "hooks",
113
+ hooksConfigName: "hooks.json",
114
+ repoConfigName: "hooks.json",
115
+ source: "codex",
116
+ schema: "response",
117
+ skillTargets: ["codex"]
118
+ }
119
+ };
120
+ }
121
+
122
+ function buildPs1({ source, schema, hookCommand = defaultHookCommand() }) {
123
+ const responseKey = schema === "response" ? "response" : "assistant_response";
124
+ return [
125
+ `$ErrorActionPreference = "Stop"`,
126
+ ``,
127
+ `try {`,
128
+ ` $inputJson = [Console]::In.ReadToEnd()`,
129
+ ` if ([string]::IsNullOrWhiteSpace($inputJson)) { exit 0 }`,
130
+ ``,
131
+ ` $payload = $inputJson | ConvertFrom-Json`,
132
+ ` $assistantText = $payload.last_assistant_message`,
133
+ ` $transcriptPath = $payload.transcript_path`,
134
+ ` $inputMessages = $payload.'input-messages'`,
135
+ ` if ($null -eq $inputMessages) { $inputMessages = $payload.input_messages }`,
136
+ ` if ($null -eq $inputMessages) { $inputMessages = $payload.inputMessages }`,
137
+ // Codex hands us the reply text inline (last_assistant_message); Claude Code's
138
+ // Stop hook only points at a transcript file, so the Node parser reads the
139
+ // last assistant turn from transcript_path. Bail only when we have neither.
140
+ ` if ([string]::IsNullOrWhiteSpace($assistantText) -and [string]::IsNullOrWhiteSpace($transcriptPath)) { exit 0 }`,
141
+ ``,
142
+ ` $normalizedJson = @{`,
143
+ ` source = "${source}"`,
144
+ ` hook_event_name = "Stop"`,
145
+ ` ${responseKey} = @{ text = $assistantText }`,
146
+ ` "input-messages" = $inputMessages`,
147
+ ` input_messages = $payload.input_messages`,
148
+ ` inputMessages = $payload.inputMessages`,
149
+ ` prompt = $payload.prompt`,
150
+ ` user_prompt = $payload.user_prompt`,
151
+ ` userPrompt = $payload.userPrompt`,
152
+ ` user_message = $payload.user_message`,
153
+ ` userMessage = $payload.userMessage`,
154
+ ` last_user_message = $payload.last_user_message`,
155
+ ` lastUserMessage = $payload.lastUserMessage`,
156
+ ` input = $payload.input`,
157
+ ` transcript_path = $transcriptPath`,
158
+ ` session_id = $payload.session_id`,
159
+ ` thread_id = $payload.thread_id`,
160
+ ` thread_name = $payload.thread_name`,
161
+ ` session_name = $payload.session_name`,
162
+ ` cwd = $payload.cwd`,
163
+ ` workspace = $payload.workspace`,
164
+ ` project_dir = $payload.project_dir`,
165
+ ` } | ConvertTo-Json -Depth 8`,
166
+ ``,
167
+ ` $hookCommand = $env:AGENT_HOTLINE_HOOK_CMD`,
168
+ ` if ([string]::IsNullOrWhiteSpace($hookCommand)) {`,
169
+ ` $hookCommand = ${toPowerShellSingleQuoted(hookCommand)}`,
170
+ ` }`,
171
+ ``,
172
+ ` $normalizedJson | cmd.exe /d /s /c $hookCommand`,
173
+ `} catch {`,
174
+ ` exit 0`,
175
+ `}`,
176
+ ``,
177
+ `exit 0`,
178
+ ``
179
+ ].join("\r\n");
180
+ }
181
+
182
+ function powershellHookEntry(ps1Path) {
183
+ return {
184
+ type: "command",
185
+ command: "powershell.exe",
186
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ps1Path],
187
+ timeout: 5
188
+ };
189
+ }
190
+
191
+ function codexHookEntry(ps1Path) {
192
+ return {
193
+ type: "command",
194
+ commandWindows: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${ps1Path}"`,
195
+ timeout: 5,
196
+ statusMessage: "Queueing response for Agent Hotline"
197
+ };
198
+ }
199
+
200
+ function mergeStopHook(existing, newEntry, flavor) {
201
+ const base = existing && typeof existing === "object" ? { ...existing } : {};
202
+ if (!base.hooks || typeof base.hooks !== "object") base.hooks = {};
203
+ if (!Array.isArray(base.hooks.Stop)) base.hooks.Stop = [];
204
+
205
+ const already = base.hooks.Stop.some((group) => {
206
+ const hooks = Array.isArray(group.hooks) ? group.hooks : [];
207
+ return hooks.some((hook) => {
208
+ if (flavor === "codex") return hook.commandWindows === newEntry.commandWindows;
209
+ const target = newEntry.args && newEntry.args[newEntry.args.length - 1];
210
+ return (
211
+ hook.command === "powershell.exe" && Array.isArray(hook.args) && hook.args.includes(target)
212
+ );
213
+ });
214
+ });
215
+
216
+ if (!already) {
217
+ base.hooks.Stop.push({ ...(flavor === "codex" ? {} : { matcher: "" }), hooks: [newEntry] });
218
+ }
219
+ return base;
220
+ }
221
+
222
+ function installOneHook({ harnessKey, scope = "global", home, repo = process.cwd(), hookCommand }) {
223
+ const definitions = harnessDefinitions(home);
224
+ const harness = definitions[harnessKey];
225
+ if (!harness) {
226
+ throw new Error(`Invalid harness "${harnessKey}". Use one of: ${VALID_HARNESSES.join(", ")}.`);
227
+ }
228
+ if (!harness.scopes.includes(scope)) {
229
+ throw new Error(
230
+ `Scope "${scope}" is not valid for ${harness.label}. Valid scopes: ${harness.scopes.join(", ")}.`
231
+ );
232
+ }
233
+
234
+ const isRepo = scope === "repo";
235
+ const configDir = isRepo
236
+ ? path.join(repo, harnessKey === "codex" ? ".codex" : ".claude")
237
+ : harness.globalDir;
238
+ const hooksDir = path.join(configDir, harness.hooksDirName);
239
+ const ps1Path = path.join(hooksDir, "agent-hotline-stop.ps1");
240
+ const configName = isRepo ? harness.repoConfigName : harness.hooksConfigName;
241
+ const configPath = path.join(configDir, configName);
242
+
243
+ writeFile(ps1Path, buildPs1({ source: harness.source, schema: harness.schema, hookCommand }));
244
+
245
+ const entry = harnessKey === "codex" ? codexHookEntry(ps1Path) : powershellHookEntry(ps1Path);
246
+ const existing = readJsonSafe(configPath);
247
+ const merged = mergeStopHook(existing, entry, harnessKey === "codex" ? "codex" : "powershell");
248
+ writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`);
249
+
250
+ return {
251
+ harness: harnessKey,
252
+ label: harness.label,
253
+ scope,
254
+ ps1Path,
255
+ configPath
256
+ };
257
+ }
258
+
259
+ function installHooks(options = {}) {
260
+ const harness = normalizeHarness(options.harness || "all");
261
+ const home = options.home || defaultHome();
262
+ const repo = options.repo || process.cwd();
263
+ const hookCommand = options.hookCommand || defaultHookCommand();
264
+ const scope = options.scope || "global";
265
+
266
+ validateChoice("harness", harness, VALID_HARNESSES);
267
+ validateChoice("scope", scope, VALID_SCOPES);
268
+
269
+ if (harness === "all") {
270
+ const keys =
271
+ scope === "repo" ? ["claude-code", "codex"] : ["antigravity", "claude-code", "codex"];
272
+ return keys.map((key) => installOneHook({ harnessKey: key, scope, home, repo, hookCommand }));
273
+ }
274
+
275
+ return [installOneHook({ harnessKey: harness, scope, home, repo, hookCommand })];
276
+ }
277
+
278
+ function managedInstructionBlock() {
279
+ return [
280
+ MANAGED_BLOCK_START,
281
+ 'Agent Hotline read-aloud: when the user says "hotline on", "read aloud on",',
282
+ '"start read-aloud", or "spoken mode", format every response with these sections:',
283
+ "",
284
+ "Spoken:",
285
+ "A short conversational summary for text-to-speech. Use 2 to 6 short sentences.",
286
+ "Do not include code, file paths, commands, symbols, or markdown.",
287
+ "",
288
+ "==========",
289
+ "",
290
+ "Displayed:",
291
+ "The full normal answer with code, commands, paths, diffs, steps, and detail.",
292
+ "",
293
+ "Keep both labels alone on their own lines. Agent Hotline reads only Spoken.",
294
+ 'Stop this format when the user says "hotline off" or "stop read-aloud".',
295
+ MANAGED_BLOCK_END
296
+ ].join("\n");
297
+ }
298
+
299
+ function upsertManagedBlock(filePath, block = managedInstructionBlock()) {
300
+ const current = fs.existsSync(filePath) ? readFile(filePath) : "";
301
+ const start = current.indexOf(MANAGED_BLOCK_START);
302
+ const end = current.indexOf(MANAGED_BLOCK_END);
303
+
304
+ let next;
305
+ if (start !== -1 && end !== -1 && end > start) {
306
+ next = `${current.slice(0, start)}${block}${current.slice(end + MANAGED_BLOCK_END.length)}`;
307
+ } else {
308
+ const prefix = current.trimEnd();
309
+ next = prefix ? `${prefix}\n\n${block}\n` : `${block}\n`;
310
+ }
311
+
312
+ writeFile(filePath, next);
313
+ return filePath;
314
+ }
315
+
316
+ function installOneSkill({
317
+ target,
318
+ scope = "global",
319
+ home,
320
+ repo = process.cwd(),
321
+ sourcePath = skillSourcePath()
322
+ }) {
323
+ if (target === "antigravity") {
324
+ const targetPath = path.join(home, ".gemini", "config", "skills", SKILL_NAME, "SKILL.md");
325
+ copyFile(sourcePath, targetPath);
326
+ return { target, scope: "global", path: targetPath, mode: "skill" };
327
+ }
328
+
329
+ if (target === "codex") {
330
+ const filePath =
331
+ scope === "repo" ? path.join(repo, "AGENTS.md") : path.join(home, ".codex", "AGENTS.md");
332
+ upsertManagedBlock(filePath);
333
+ return { target, scope, path: filePath, mode: "instructions" };
334
+ }
335
+
336
+ if (target === "claude-code") {
337
+ const filePath =
338
+ scope === "repo" ? path.join(repo, "CLAUDE.md") : path.join(home, ".claude", "CLAUDE.md");
339
+ upsertManagedBlock(filePath);
340
+ return { target, scope, path: filePath, mode: "instructions" };
341
+ }
342
+
343
+ throw new Error(`Invalid skill target "${target}". Use one of: ${VALID_HARNESSES.join(", ")}.`);
344
+ }
345
+
346
+ function installSkills(options = {}) {
347
+ const target = normalizeHarness(options.target || options.harness || "all");
348
+ const home = options.home || defaultHome();
349
+ const repo = options.repo || process.cwd();
350
+ const scope = options.scope || "global";
351
+ const sourcePath = options.sourcePath || skillSourcePath();
352
+
353
+ validateChoice("target", target, VALID_HARNESSES);
354
+ validateChoice("scope", scope, VALID_SCOPES);
355
+
356
+ if (target === "all") {
357
+ const keys =
358
+ scope === "repo" ? ["claude-code", "codex"] : ["antigravity", "claude-code", "codex"];
359
+ return keys.map((key) => installOneSkill({ target: key, scope, home, repo, sourcePath }));
360
+ }
361
+
362
+ return [installOneSkill({ target, scope, home, repo, sourcePath })];
363
+ }
364
+
365
+ function parseArgs(argv) {
366
+ const out = { _: [] };
367
+ for (let i = 0; i < argv.length; i += 1) {
368
+ const arg = argv[i];
369
+ if (!arg.startsWith("--")) {
370
+ out._.push(arg);
371
+ continue;
372
+ }
373
+ const key = arg.slice(2);
374
+ const next = argv[i + 1];
375
+ if (!next || next.startsWith("--")) {
376
+ out[key] = true;
377
+ } else {
378
+ out[key] = next;
379
+ i += 1;
380
+ }
381
+ }
382
+ return out;
383
+ }
384
+
385
+ module.exports = {
386
+ SKILL_NAME,
387
+ buildPs1,
388
+ defaultHookCommand,
389
+ defaultHome,
390
+ harnessDefinitions,
391
+ installHooks,
392
+ installSkills,
393
+ managedInstructionBlock,
394
+ normalizeHarness,
395
+ NPX_PACKAGE_NAME,
396
+ npxHookCommand,
397
+ parseArgs,
398
+ repoRoot,
399
+ skillSourcePath,
400
+ toPowerShellSingleQuoted,
401
+ upsertManagedBlock
402
+ };
@@ -0,0 +1,40 @@
1
+ const path = require("path");
2
+ const { spawn } = require("child_process");
3
+
4
+ const DEFAULT_PORT = "4777";
5
+
6
+ function backendServerPath() {
7
+ return path.join(__dirname, "server.js");
8
+ }
9
+
10
+ function launchBackend(options = {}) {
11
+ const spawnImpl = options.spawn || spawn;
12
+ const execPath = options.execPath || process.execPath;
13
+ const serverPath = options.serverPath || backendServerPath();
14
+ const port = String(options.port || process.env.AGENT_HOTLINE_PORT || DEFAULT_PORT);
15
+ const child = spawnImpl(execPath, [serverPath], {
16
+ cwd: path.dirname(serverPath),
17
+ detached: true,
18
+ env: {
19
+ ...process.env,
20
+ AGENT_HOTLINE_PORT: port
21
+ },
22
+ stdio: "ignore",
23
+ windowsHide: true
24
+ });
25
+
26
+ if (typeof child.unref === "function") {
27
+ child.unref();
28
+ }
29
+
30
+ return {
31
+ pid: child.pid,
32
+ port,
33
+ url: `http://127.0.0.1:${port}`
34
+ };
35
+ }
36
+
37
+ module.exports = {
38
+ backendServerPath,
39
+ launchBackend
40
+ };