relaybot 1.0.6 → 1.0.8

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/README.md CHANGED
@@ -93,6 +93,28 @@ RelayBot acts as a bridge between Slack and AI coding agents, allowing you to in
93
93
 
94
94
  ---
95
95
 
96
+ ## Slack Commands
97
+
98
+ Send these as a DM to the bot (or in a channel mention) to control the agent session:
99
+
100
+ - `$status` — Show whether the agent is running, plus PID, start time, uptime, cwd, WORKING_DIR, and last exit
101
+ - `$stop` — Stop the agent session (equivalent to Ctrl-C)
102
+ - `$start [--codex] [--noyolo]` — Start the agent with optional flags
103
+ - `$restart [--codex] [--noyolo]` — Restart the agent with optional flags
104
+ - `$dir /path/to/working/dir` — Update the configured working directory (must exist).
105
+
106
+ Examples:
107
+
108
+ ```
109
+ $status
110
+ $stop
111
+ $start --codex
112
+ $restart --noyolo
113
+ $dir /Users/you/work/project
114
+ ```
115
+
116
+ ---
117
+
96
118
  ## Installation
97
119
 
98
120
  ### Option 1: Install via npm (Recommended)
package/guide.md ADDED
@@ -0,0 +1,21 @@
1
+ Send a Slack DM after tasks or answers using `send-slack-message.js`, replying naturally as a bot (avoid meta summaries about what was asked/answered).
2
+ Choose the best default and proceed automatically; do not ask for clarification.
3
+
4
+ ## Output rules
5
+
6
+ - Keep messages concise and actionable
7
+ - Include relevant links (PR URLs, documentation, etc.)
8
+ - For long answers, distill key points in a natural voice (no meta summary framing)
9
+ - Use clean line breaks; do not include literal `\n` sequences
10
+ - Use emojis sparingly when they improve clarity or tone
11
+ - Send the message itself, not a description of the message. Example:
12
+ - Bad: `User asked how I am; I replied I'm good and ready to help.`
13
+ - Good: `Doing well—ready when you are. What can I help with?`
14
+
15
+ ## Quick commands
16
+
17
+ Send a Slack DM:
18
+
19
+ ```bash
20
+ node ./src/send-slack-message.js "Your message here"
21
+ ```
package/main.js CHANGED
@@ -20,6 +20,14 @@ const app = new App({
20
20
  socketMode: true
21
21
  });
22
22
 
23
+ if (config.WORKING_DIR) {
24
+ try {
25
+ process.chdir(config.WORKING_DIR);
26
+ } catch (error) {
27
+ console.error(`Failed to change WORKING_DIR to ${config.WORKING_DIR}:`, error.message);
28
+ }
29
+ }
30
+
23
31
  slackHandlers.registerHandlers(app);
24
32
 
25
33
  (async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relaybot",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "An AI assistant that lives in your Slack DMs — powered by Claude or Codex",
5
5
  "main": "main.js",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -1,9 +1,14 @@
1
1
  const pty = require('@lydell/node-pty');
2
2
 
3
3
  let claudeProcess = null;
4
- const useCodex = process.argv.includes('--codex');
5
- const noYolo = process.argv.includes('--noyolo');
6
- const shell = useCodex ? 'codex' : 'claude';
4
+ let startTime = null;
5
+ let lastExit = null;
6
+ let stdinHandler = null;
7
+ let stdinAttached = false;
8
+ let rawModeBefore = null;
9
+ let useCodex = process.argv.includes('--codex');
10
+ let noYolo = process.argv.includes('--noyolo');
11
+ let shell = useCodex ? 'codex' : 'claude';
7
12
 
8
13
  function sendCommand(text) {
9
14
  if (claudeProcess) {
@@ -21,29 +26,20 @@ function isRunning() {
21
26
  return claudeProcess !== null;
22
27
  }
23
28
 
24
- function start() {
25
- const defaultArgs = useCodex ? ['--yolo'] : ['--dangerously-skip-permissions'];
26
- const spawnArgs = noYolo ? [] : defaultArgs;
27
- claudeProcess = pty.spawn(shell, spawnArgs, {
28
- name: 'xterm-color',
29
- cols: 80,
30
- rows: 30,
31
- cwd: process.cwd(),
32
- env: { ...process.env, TERM: process.env.TERM || 'xterm-256color' }
33
- });
34
-
35
- console.log(`--- Persistent ${useCodex ? 'Codex' : 'Claude'} Session Started ---`);
29
+ function attachStdin() {
30
+ if (stdinAttached) return;
36
31
 
37
- const hadRawMode = Boolean(process.stdin.isTTY && process.stdin.isRaw);
32
+ rawModeBefore = Boolean(process.stdin.isTTY && process.stdin.isRaw);
38
33
  if (process.stdin.isTTY) {
39
34
  process.stdin.setRawMode(true);
40
35
  }
41
36
  process.stdin.resume();
42
- process.stdin.on('data', (data) => {
37
+
38
+ stdinHandler = (data) => {
43
39
  if (data && data.length === 1 && data[0] === 3) {
44
40
  // Ctrl+C: restore terminal and exit.
45
41
  if (process.stdin.isTTY) {
46
- process.stdin.setRawMode(Boolean(hadRawMode));
42
+ process.stdin.setRawMode(Boolean(rawModeBefore));
47
43
  }
48
44
  if (claudeProcess) {
49
45
  claudeProcess.kill('SIGINT');
@@ -53,8 +49,50 @@ function start() {
53
49
  if (claudeProcess) {
54
50
  claudeProcess.write(data);
55
51
  }
52
+ };
53
+
54
+ process.stdin.on('data', stdinHandler);
55
+ stdinAttached = true;
56
+ }
57
+
58
+ function detachStdin() {
59
+ if (!stdinAttached) return;
60
+ process.stdin.off('data', stdinHandler);
61
+ stdinHandler = null;
62
+ stdinAttached = false;
63
+ if (process.stdin.isTTY) {
64
+ process.stdin.setRawMode(Boolean(rawModeBefore));
65
+ }
66
+ }
67
+
68
+ function start(options = {}) {
69
+ if (claudeProcess) {
70
+ return claudeProcess;
71
+ }
72
+
73
+ if (typeof options.useCodex === 'boolean') {
74
+ useCodex = options.useCodex;
75
+ }
76
+ if (typeof options.noYolo === 'boolean') {
77
+ noYolo = options.noYolo;
78
+ }
79
+ shell = useCodex ? 'codex' : 'claude';
80
+
81
+ const defaultArgs = useCodex ? ['--yolo'] : ['--dangerously-skip-permissions'];
82
+ const spawnArgs = noYolo ? [] : defaultArgs;
83
+ claudeProcess = pty.spawn(shell, spawnArgs, {
84
+ name: 'xterm-color',
85
+ cols: 80,
86
+ rows: 30,
87
+ cwd: process.cwd(),
88
+ env: { ...process.env, TERM: process.env.TERM || 'xterm-256color' }
56
89
  });
57
90
 
91
+ console.log(`--- Persistent ${useCodex ? 'Codex' : 'Claude'} Session Started ---`);
92
+ startTime = new Date();
93
+
94
+ attachStdin();
95
+
58
96
  claudeProcess.onData((data) => {
59
97
  const dataStr = data.toString();
60
98
  const byPassPrompts = ['Do you want to proceed?'];
@@ -76,11 +114,59 @@ function start() {
76
114
  process.stdout.write(data);
77
115
  });
78
116
 
117
+ claudeProcess.onExit(({ exitCode, signal }) => {
118
+ lastExit = {
119
+ exitCode,
120
+ signal,
121
+ at: new Date()
122
+ };
123
+ claudeProcess = null;
124
+ startTime = null;
125
+ detachStdin();
126
+ });
127
+
79
128
  return claudeProcess;
80
129
  }
81
130
 
131
+ function stop() {
132
+ if (!claudeProcess) {
133
+ return { stopped: false, reason: 'not_running' };
134
+ }
135
+
136
+ try {
137
+ claudeProcess.write('\x03');
138
+ setTimeout(() => {
139
+ if (claudeProcess) {
140
+ claudeProcess.kill('SIGINT');
141
+ }
142
+ }, 250);
143
+ } catch (error) {
144
+ return { stopped: false, reason: 'error', error };
145
+ }
146
+
147
+ return { stopped: true };
148
+ }
149
+
150
+ function getStatus() {
151
+ const now = Date.now();
152
+ const uptimeSeconds = startTime ? Math.floor((now - startTime.getTime()) / 1000) : null;
153
+ return {
154
+ running: Boolean(claudeProcess),
155
+ shell,
156
+ pid: claudeProcess ? claudeProcess.pid : null,
157
+ startedAt: startTime ? startTime.toISOString() : null,
158
+ uptimeSeconds,
159
+ noYolo,
160
+ useCodex,
161
+ cwd: process.cwd(),
162
+ lastExit
163
+ };
164
+ }
165
+
82
166
  module.exports = {
83
167
  sendCommand,
84
168
  isRunning,
85
- start
169
+ start,
170
+ stop,
171
+ getStatus
86
172
  };
@@ -27,29 +27,85 @@ function getLastChannel() {
27
27
  }
28
28
  const channelId = getLastChannel() || config.SLACK_USER_ID;
29
29
 
30
- // Get the message from command line arguments
31
- // Convert literal \n to actual newlines
32
- const message = process.argv[2]?.replace(/\\n/g, '\n');
30
+ function parseArgs() {
31
+ const args = process.argv.slice(2);
32
+ const useStdin = args.includes('--stdin');
33
+ const filtered = args.filter((arg) => arg !== '--stdin');
34
+ const argMessage = filtered.join(' ');
35
+ return { useStdin, argMessage };
36
+ }
33
37
 
34
- if (!message) {
35
- console.error('Please provide a message to send.');
36
- process.exit(1);
38
+ function readStdin() {
39
+ return new Promise((resolve) => {
40
+ if (process.stdin.isTTY) {
41
+ resolve('');
42
+ return;
43
+ }
44
+ let data = '';
45
+ process.stdin.setEncoding('utf-8');
46
+ process.stdin.on('data', (chunk) => {
47
+ data += chunk;
48
+ });
49
+ process.stdin.on('end', () => resolve(data));
50
+ });
51
+ }
52
+
53
+ function chunkMessage(text, maxLen) {
54
+ const normalized = text.replace(/\r\n/g, '\n').trim();
55
+ if (!normalized) return [];
56
+ if (normalized.length <= maxLen) return [normalized];
57
+
58
+ const chunks = [];
59
+ let remaining = normalized;
60
+ while (remaining.length > maxLen) {
61
+ const slice = remaining.slice(0, maxLen);
62
+ let idx = slice.lastIndexOf('\n\n');
63
+ if (idx < maxLen * 0.5) idx = slice.lastIndexOf('\n');
64
+ if (idx < maxLen * 0.5) idx = slice.lastIndexOf(' ');
65
+ if (idx < 1) idx = maxLen;
66
+ const piece = remaining.slice(0, idx).trimEnd();
67
+ if (piece) chunks.push(piece);
68
+ remaining = remaining.slice(idx).trimStart();
69
+ }
70
+ if (remaining) chunks.push(remaining);
71
+ return chunks;
37
72
  }
38
73
 
39
74
  (async () => {
40
- try {
41
- if (!channelId) {
42
- console.error('No recent channel found and SLACK_USER_ID is not set.');
43
- console.error('Send a Slack message to the bot first or set SLACK_USER_ID in the config.');
44
- process.exit(1);
45
- }
46
- const result = await web.chat.postMessage({
47
- channel: channelId,
48
- text: message,
49
- });
50
- console.log(`Successfully sent message to ${channelId}`);
51
- } catch (error) {
52
- console.error(`Error sending message: ${error}`);
53
- process.exit(1);
75
+ try {
76
+ if (!channelId) {
77
+ console.error('No recent channel found and SLACK_USER_ID is not set.');
78
+ console.error('Send a Slack message to the bot first or set SLACK_USER_ID in the config.');
79
+ process.exit(1);
54
80
  }
81
+
82
+ const { useStdin, argMessage } = parseArgs();
83
+ let message = argMessage.replace(/\\n/g, '\n');
84
+ if (useStdin || !message) {
85
+ const stdinMessage = await readStdin();
86
+ if (stdinMessage) {
87
+ message = stdinMessage;
88
+ }
89
+ }
90
+
91
+ if (!message || !message.trim()) {
92
+ console.error('Please provide a message to send.');
93
+ console.error('Usage: node ./src/send-slack-message.js "Your message here"');
94
+ console.error('Or: cat message.txt | node ./src/send-slack-message.js --stdin');
95
+ process.exit(1);
96
+ }
97
+
98
+ const chunks = chunkMessage(message, 39000);
99
+ for (const chunk of chunks) {
100
+ await web.chat.postMessage({
101
+ channel: channelId,
102
+ text: chunk,
103
+ });
104
+ }
105
+ console.log(`Successfully sent ${chunks.length} message(s) to ${channelId}`);
106
+ } catch (error) {
107
+ const apiError = error?.data?.error ? ` (${error.data.error})` : '';
108
+ console.error(`Error sending message: ${error}${apiError}`);
109
+ process.exit(1);
110
+ }
55
111
  })();
@@ -5,7 +5,7 @@ const loadConfig = require('./load-config');
5
5
 
6
6
  const config = loadConfig();
7
7
  const isProduction = __dirname.includes('node_modules');
8
- const skillPath = path.join(__dirname, '..', 'skills', 'relay-bot', 'SKILL.md');
8
+ const skillPath = path.join(__dirname, '..', 'guide.md');
9
9
  const lastMessagePath = path.join(loadConfig.CONFIG_DIR, 'last_message.json');
10
10
 
11
11
  function storeLastMessage(message) {
@@ -23,10 +23,7 @@ function storeLastMessage(message) {
23
23
  }
24
24
 
25
25
  function getPromptSuffix() {
26
- if (isProduction) {
27
- return `\nIMPORTANT: Read and follow the instructions in ${skillPath}`;
28
- }
29
- return '\nIMPORTANT: Use the relay-bot skill.';
26
+ return `\nIMPORTANT: Read and follow the instructions in ${skillPath}`;
30
27
  }
31
28
 
32
29
  function stripMentions(text) {
@@ -34,6 +31,47 @@ function stripMentions(text) {
34
31
  return text.replace(/<@[^>]+>/g, '').replace(/\s+/g, ' ').trim();
35
32
  }
36
33
 
34
+ function updateConfigValue(key, value) {
35
+ fs.mkdirSync(loadConfig.CONFIG_DIR, { recursive: true });
36
+ let lines = [];
37
+ if (fs.existsSync(loadConfig.CONFIG_PATH)) {
38
+ const content = fs.readFileSync(loadConfig.CONFIG_PATH, 'utf-8');
39
+ lines = content.split('\n');
40
+ }
41
+
42
+ let updated = false;
43
+ lines = lines.map((line) => {
44
+ if (line.trim().startsWith('#') || !line.includes('=')) {
45
+ return line;
46
+ }
47
+ const [k] = line.split('=');
48
+ if (k.trim() === key) {
49
+ updated = true;
50
+ return `${key}=${value}`;
51
+ }
52
+ return line;
53
+ });
54
+
55
+ if (!updated) {
56
+ lines.push(`${key}=${value}`);
57
+ }
58
+
59
+ fs.writeFileSync(loadConfig.CONFIG_PATH, lines.join('\n'));
60
+ }
61
+
62
+ function parseCommandOptions(text) {
63
+ const parts = (text || '').trim().split(/\s+/);
64
+ const options = {
65
+ useCodex: parts.includes('--codex'),
66
+ noYolo: parts.includes('--noyolo')
67
+ };
68
+ return options;
69
+ }
70
+
71
+ function delay(ms) {
72
+ return new Promise(resolve => setTimeout(resolve, ms));
73
+ }
74
+
37
75
  function registerHandlers(app) {
38
76
  async function handleMessage({ message, say, text }) {
39
77
  // Only respond to messages from the configured user
@@ -43,8 +81,84 @@ function registerHandlers(app) {
43
81
 
44
82
  storeLastMessage(message);
45
83
 
84
+ const trimmedText = (text || '').trim();
85
+ if (trimmedText === '$stop') {
86
+ const result = agent.stop();
87
+ if (result.stopped) {
88
+ await say('Stopped the agent session (SIGINT).');
89
+ } else if (result.reason === 'not_running') {
90
+ await say('Agent is not running.');
91
+ } else {
92
+ await say('Failed to stop the agent session.');
93
+ }
94
+ return;
95
+ }
96
+
97
+ if (trimmedText.startsWith('$start')) {
98
+ const options = parseCommandOptions(trimmedText);
99
+ if (agent.isRunning()) {
100
+ await say('Agent is already running.');
101
+ } else {
102
+ agent.start(options);
103
+ await say(`Started the agent session${options.useCodex ? ' (Codex)' : ' (Claude)'}${options.noYolo ? ' without auto-approve flags' : ''}.`);
104
+ }
105
+ return;
106
+ }
107
+
108
+ if (trimmedText.startsWith('$restart')) {
109
+ const options = parseCommandOptions(trimmedText);
110
+ if (agent.isRunning()) {
111
+ agent.stop();
112
+ await delay(400);
113
+ }
114
+ agent.start(options);
115
+ await say(`Restarted the agent session${options.useCodex ? ' (Codex)' : ' (Claude)'}${options.noYolo ? ' without auto-approve flags' : ''}.`);
116
+ return;
117
+ }
118
+
119
+ if (trimmedText === '$status') {
120
+ const status = agent.getStatus();
121
+ const lines = [
122
+ `Running: ${status.running}`,
123
+ `Shell: ${status.shell}`,
124
+ `PID: ${status.pid || 'n/a'}`,
125
+ `Started: ${status.startedAt || 'n/a'}`,
126
+ `Uptime (s): ${status.uptimeSeconds || 'n/a'}`,
127
+ `CWD: ${status.cwd}`,
128
+ `Configured WORKING_DIR: ${config.WORKING_DIR || 'n/a'}`,
129
+ `Use Codex: ${status.useCodex}`,
130
+ `No YOLO: ${status.noYolo}`
131
+ ];
132
+ if (status.lastExit) {
133
+ lines.push(`Last exit: code=${status.lastExit.exitCode ?? 'n/a'} signal=${status.lastExit.signal || 'n/a'} at=${status.lastExit.at.toISOString()}`);
134
+ }
135
+ await say(lines.join('\n'));
136
+ return;
137
+ }
138
+
139
+ if (trimmedText.startsWith('$dir ')) {
140
+ const newDir = trimmedText.replace(/^\$dir\s+/, '').trim();
141
+ if (!newDir) {
142
+ await say('Usage: $dir /path/to/working/dir');
143
+ return;
144
+ }
145
+ if (!fs.existsSync(newDir)) {
146
+ await say(`Directory does not exist: ${newDir}`);
147
+ return;
148
+ }
149
+ const stat = fs.statSync(newDir);
150
+ if (!stat.isDirectory()) {
151
+ await say(`Not a directory: ${newDir}`);
152
+ return;
153
+ }
154
+ updateConfigValue('WORKING_DIR', newDir);
155
+ config.WORKING_DIR = newDir;
156
+ await say(`Working directory set to: ${newDir}`);
157
+ return;
158
+ }
159
+
46
160
  if (agent.isRunning()) {
47
- const fullPrompt = text + getPromptSuffix();
161
+ const fullPrompt = trimmedText + getPromptSuffix();
48
162
  agent.sendCommand(fullPrompt);
49
163
  } else {
50
164
  await say('Agent process is not running.');
@@ -1,15 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(*)",
5
- "Read(*)",
6
- "Edit(*)",
7
- "Write(*)"
8
- ],
9
- "deny": [
10
- "Bash(rm *)",
11
- "Bash(rm -rf *)",
12
- "Bash(rmdir *)"
13
- ]
14
- }
15
- }
Binary file
@@ -1,53 +0,0 @@
1
- ---
2
- name: relay-bot
3
- description: "Workspace-aware assistant that maintains project directory context and sends a Slack DM reply after completing a task or answering a question."
4
- ---
5
-
6
- # RelayBot
7
-
8
- ## Overview
9
-
10
- Maintain working-directory context for projects and send a Slack DM after tasks or answers using `send-slack-message.js`.
11
-
12
- ## Working directory
13
-
14
- The working directory where the AI operates is configured in `~/.relaybot/config.conf`:
15
-
16
- ```
17
- WORKING_DIR=/path/to/directory
18
- ```
19
-
20
- ## Workflow
21
-
22
- ### 1) Resolve project context
23
-
24
- - Use the directory configured in `WORKING_DIR` as the working context when set; otherwise use the current process working directory.
25
- - Run all commands and file operations within this directory.
26
- - Avoid asking the user for clarification; pick the best default and proceed automatically.
27
- - If a question is truly required to continue, send it as a Slack DM using the script, then wait for the user's response via the Slack socket before proceeding.
28
-
29
- ### 2) Execute the task in the active project
30
-
31
- - Run commands and edit files in the resolved working directory.
32
- - Keep the project context for follow-up requests unless the user switches again.
33
-
34
- ### 3) Send a Slack DM reply after completion
35
-
36
- Run from this repo root:
37
-
38
- ```bash
39
- node ./src/send-slack-message.js "Your message here"
40
- ```
41
-
42
- ## Output rules
43
-
44
- - Keep messages concise and actionable
45
- - Include relevant links (PR URLs, documentation, etc.)
46
- - For long answers, summarize key points
47
- - Format replies with clean, readable line breaks; do not use literal `\n` sequences
48
- - Use emojis sparingly when they improve clarity or tone
49
-
50
- ## Notes
51
-
52
- - The script posts to the configured DM user in `~/.relaybot/config.conf`
53
- - The Slack bot token must be configured in `~/.relaybot/config.conf`