tr-ai-intercom-helper 0.0.0-stage → 1.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/CHANGELOG.md +35 -0
- package/LICENSE +24 -0
- package/MIT-RINNE +24 -0
- package/README.md +365 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +55 -0
- package/dist/helper.d.ts +24 -0
- package/dist/helper.js +77 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/listener.d.ts +32 -0
- package/dist/listener.js +165 -0
- package/dist/logger.d.ts +15 -0
- package/dist/logger.js +23 -0
- package/dist/notifier.d.ts +38 -0
- package/dist/notifier.js +148 -0
- package/dist/notify-cli.d.ts +2 -0
- package/dist/notify-cli.js +49 -0
- package/dist/notify-options.d.ts +9 -0
- package/dist/notify-options.js +55 -0
- package/dist/options.d.ts +47 -0
- package/dist/options.js +193 -0
- package/dist/proxy.d.ts +34 -0
- package/dist/proxy.js +110 -0
- package/dist/sinks.d.ts +64 -0
- package/dist/sinks.js +244 -0
- package/dist/urls.d.ts +14 -0
- package/dist/urls.js +35 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +4 -0
- package/dist/wakeup.d.ts +83 -0
- package/dist/wakeup.js +275 -0
- package/package.json +64 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.1.0 (2026-11-15)
|
|
4
|
+
|
|
5
|
+
- **Notification listener.** With `--notification-listener` the helper
|
|
6
|
+
listens on a local socket of its own,
|
|
7
|
+
`/tmp/tr-ai-intercom-helper.<uid>/<port>.sock`, and sends every program
|
|
8
|
+
that connects each notification as a line of JSON.
|
|
9
|
+
`--notification-listener-path` puts the socket elsewhere. While nobody is
|
|
10
|
+
connected the latest notification is kept for the next program that
|
|
11
|
+
connects.
|
|
12
|
+
- **`tr-ai-intercom-notify`**, a second command: connects to that socket and
|
|
13
|
+
prints the notifications to standard output, for an agent that is woken by
|
|
14
|
+
the output of a background command. `--monitor` prints everything,
|
|
15
|
+
`--oneshot` one line; `--reconnect` waits for a helper that is not there;
|
|
16
|
+
`--idle-notification-interval` adds a line of its own when nothing came
|
|
17
|
+
for a while.
|
|
18
|
+
- The helper logs the command line of the client when it starts with the
|
|
19
|
+
listener on.
|
|
20
|
+
- The existing `--notification-local-socket`, with which the helper connects
|
|
21
|
+
out to a socket somebody else listens on, is unchanged.
|
|
22
|
+
|
|
23
|
+
## 1.0.0 (2026-11-15)
|
|
24
|
+
|
|
25
|
+
First version.
|
|
26
|
+
|
|
27
|
+
- A local HTTP proxy to a TR AI Intercom server: the agent connects to the
|
|
28
|
+
helper instead of the intercom, and everything is forwarded unchanged,
|
|
29
|
+
event streams and long-polls included.
|
|
30
|
+
- Opens the intercom's wake-up socket by itself, with the address the
|
|
31
|
+
intercom names in its responses and the token of the agent's own
|
|
32
|
+
requests, and reconnects when it closes.
|
|
33
|
+
- Hands each notification to a file, a directory, a URL, a local socket or a
|
|
34
|
+
command, or to several of them.
|
|
35
|
+
- Tells when something was missed while the socket was closed.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright © 2026 Timo J. Rinne <tri@iki.fi>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person
|
|
6
|
+
obtaining a copy of this software and associated documentation
|
|
7
|
+
files (the "Software"), to deal in the Software without
|
|
8
|
+
restriction, including without limitation the rights to use,
|
|
9
|
+
copy, modify, merge, publish, distribute, sublicense, and/or
|
|
10
|
+
sell copies of the Software, and to permit persons to whom
|
|
11
|
+
the Software is furnished to do so, subject to the following
|
|
12
|
+
conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be
|
|
15
|
+
included in all copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
18
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
19
|
+
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
20
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
21
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
22
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
23
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
24
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
package/MIT-RINNE
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright © 2026 Timo J. Rinne <tri@iki.fi>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person
|
|
6
|
+
obtaining a copy of this software and associated documentation
|
|
7
|
+
files (the "Software"), to deal in the Software without
|
|
8
|
+
restriction, including without limitation the rights to use,
|
|
9
|
+
copy, modify, merge, publish, distribute, sublicense, and/or
|
|
10
|
+
sell copies of the Software, and to permit persons to whom
|
|
11
|
+
the Software is furnished to do so, subject to the following
|
|
12
|
+
conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be
|
|
15
|
+
included in all copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
18
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
19
|
+
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
20
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
21
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
22
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
23
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
24
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,366 @@
|
|
|
1
|
-
#
|
|
1
|
+
# tr-ai-intercom-helper
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A small local program for AI agents that use
|
|
4
|
+
[TR AI Intercom](https://github.com/rinne/tr-ai-intercom), an MCP server
|
|
5
|
+
through which independent agents exchange messages.
|
|
6
|
+
|
|
7
|
+
An agent hears of a new intercom message only when it calls a tool. Most
|
|
8
|
+
agent programs do nothing while they are idle, so a message can wait for a
|
|
9
|
+
long time. This helper closes that gap from the outside:
|
|
10
|
+
|
|
11
|
+
- It sits between the agent and the intercom as a **transparent local
|
|
12
|
+
proxy**. The agent talks to the helper; the helper passes everything on.
|
|
13
|
+
- It opens the intercom's **wake-up socket** by itself and is told, in a
|
|
14
|
+
few bytes and without any content, whenever something happened that
|
|
15
|
+
merits a look at the inbox.
|
|
16
|
+
- It hands each such notification to **something local**: a file, a
|
|
17
|
+
directory, a URL, a local socket, a command, or any program that connects
|
|
18
|
+
to the helper's own notification socket. A ready-made one,
|
|
19
|
+
`tr-ai-intercom-notify`, prints them to its standard output.
|
|
20
|
+
|
|
21
|
+
What then makes the agent actually call `check_inbox` depends on the agent
|
|
22
|
+
program and is yours to set up: a watcher on the file, a hook, a wrapper
|
|
23
|
+
that starts a turn. The helper delivers the signal and stops there. It is
|
|
24
|
+
meant to grow new delivery methods as agent programs grow ways to be
|
|
25
|
+
prompted, and to become unnecessary when they act on MCP notifications by
|
|
26
|
+
themselves.
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
It needs Node.js 20 or later.
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npx -y tr-ai-intercom-helper \
|
|
34
|
+
--mcp-server-url https://intercom.example.com/ \
|
|
35
|
+
--notification-file ~/.intercom/notifications
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Then point the agent at the helper instead of at the intercom. Only the URL
|
|
39
|
+
changes; the token stays in the agent's configuration as before:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"mcpServers": {
|
|
44
|
+
"intercom": {
|
|
45
|
+
"type": "http",
|
|
46
|
+
"url": "http://127.0.0.1:32323/mcp",
|
|
47
|
+
"headers": { "Authorization": "Bearer tai_..." }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
From the agent's first request on, a line of JSON is appended to the file
|
|
54
|
+
whenever something arrives for it:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{ "message": "inbox-activity", "seq": 4711, "time": "2026-11-15T12:34:56.789Z", "data": {} }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The helper is not given the token. It takes it from the agent's own
|
|
61
|
+
requests, keeps it in memory only, and uses it for nothing but the wake-up
|
|
62
|
+
socket of the same server.
|
|
63
|
+
|
|
64
|
+
## How it works
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
agent --HTTP--> helper (127.0.0.1:32323) --HTTP(S)--> intercom /mcp
|
|
68
|
+
| <--websocket-- intercom /mcp/ws
|
|
69
|
+
+--> file, directory, URL, local socket, command
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
1. The agent sends its MCP requests to the helper. `--mcp-server-url` is
|
|
73
|
+
the root of the intercom, not the `/mcp` path: a request for `/mcp` goes
|
|
74
|
+
to that URL with `/mcp` appended. The URL may end in a slash or not, and
|
|
75
|
+
may carry a path prefix.
|
|
76
|
+
2. Requests and responses pass through unchanged: method, path, headers,
|
|
77
|
+
body and status. Only `Host` is rewritten and the hop-by-hop headers are
|
|
78
|
+
dropped. Event streams are passed on as they come and long-polls are not
|
|
79
|
+
cut off. The helper never looks into what it forwards.
|
|
80
|
+
3. Every successful response of the intercom names its wake-up socket in
|
|
81
|
+
the header `X-TR-AI-Intercom-Status-WS`. When the helper sees it, it
|
|
82
|
+
opens that socket with the `Authorization` header of the request. A
|
|
83
|
+
server that does not send the header gets a plain proxy and nothing
|
|
84
|
+
else.
|
|
85
|
+
4. Each notification from the socket goes to every delivery method you
|
|
86
|
+
configured.
|
|
87
|
+
|
|
88
|
+
The protocol of the socket is described in the intercom's
|
|
89
|
+
[WAKE-UP-SOCKET.md](https://github.com/rinne/tr-ai-intercom/blob/main/docs/WAKE-UP-SOCKET.md).
|
|
90
|
+
The intercom needs version 1.4.0 or later.
|
|
91
|
+
|
|
92
|
+
## Options
|
|
93
|
+
|
|
94
|
+
Every option can also be given in the environment, as `INTERCOM_HELPER_OPT_`
|
|
95
|
+
followed by the option name in upper case with dashes as underscores:
|
|
96
|
+
`--listen-port` is `INTERCOM_HELPER_OPT_LISTEN_PORT`. The command line wins
|
|
97
|
+
when both are given. `--help` prints the list.
|
|
98
|
+
|
|
99
|
+
| Option | Default | Meaning |
|
|
100
|
+
| ---------------------------------- | ------------ | --------------------------------------------------------------------------------------------------- |
|
|
101
|
+
| `--mcp-server-url` | (required) | Root URL of the intercom, for example `https://intercom.example.com/` |
|
|
102
|
+
| `--listen-address` | `127.0.0.1` | Local address the agent connects to |
|
|
103
|
+
| `--listen-port` | `32323` | Local port the agent connects to. `0` picks a free one. |
|
|
104
|
+
| `--notification-file` | | Append each notification to this file |
|
|
105
|
+
| `--notification-dir` | | Write each notification into this directory as a file of its own |
|
|
106
|
+
| `--notification-tmp-dir` | `<dir>/.tmp` | Where those files are prepared before they are moved into place |
|
|
107
|
+
| `--notification-url` | | POST each notification to this URL |
|
|
108
|
+
| `--notification-url-header` | | One header line for that POST, `Name: value` |
|
|
109
|
+
| `--notification-local-socket` | | Write each notification to this local (Unix domain) socket |
|
|
110
|
+
| `--notification-command` | | Run this command for each notification |
|
|
111
|
+
| `--notification-listener` | off | Listen on a local socket of the helper's own and send every program that connects each notification |
|
|
112
|
+
| `--notification-listener-path` | see below | Listen on this socket path instead of the default one. Turns the listener on. |
|
|
113
|
+
| `--notification-websocket-timeout` | none | Close the wake-up socket after this long without requests from the agent |
|
|
114
|
+
| `--log-level` | `info` | `error`, `warn`, `info`, `debug` or `silent`. The log is JSON lines on stderr. |
|
|
115
|
+
|
|
116
|
+
Time values are seconds, or carry a unit: `90`, `15m`, `2h`, `1d`.
|
|
117
|
+
|
|
118
|
+
Several delivery methods can be used at once, one of each kind. Without any,
|
|
119
|
+
the helper is a plain proxy and says so when it starts.
|
|
120
|
+
|
|
121
|
+
## Notifications
|
|
122
|
+
|
|
123
|
+
Each notification is one JSON object:
|
|
124
|
+
|
|
125
|
+
| Field | Meaning |
|
|
126
|
+
| --------- | ---------------------------------------------------------- |
|
|
127
|
+
| `message` | The kind, see below |
|
|
128
|
+
| `seq` | The agent's sequence number on the intercom; it only grows |
|
|
129
|
+
| `time` | The intercom's clock when it happened, ISO 8601 in UTC |
|
|
130
|
+
| `data` | An object. Empty except for `burst`. |
|
|
131
|
+
|
|
132
|
+
| `message` | Meaning |
|
|
133
|
+
| ---------------------- | -------------------------------------------------------------------------- |
|
|
134
|
+
| `inbox-activity` | A new message, reply request or notice waits for the agent |
|
|
135
|
+
| `shared-memory-update` | Another member changed the shared memory of one of the agent's channels |
|
|
136
|
+
| `inbox-reminder` | Something still waits unfetched; repeated by the server after a long while |
|
|
137
|
+
| `burst` | Several of the above that the server sent together, in `data.burst` |
|
|
138
|
+
| `missed` | Made by the helper: something happened while the socket was not open |
|
|
139
|
+
|
|
140
|
+
`tr-ai-intercom-notify` can add one of its own, `local-idle-notification`,
|
|
141
|
+
described with that command below.
|
|
142
|
+
|
|
143
|
+
A notification never says what happened, who wrote or what about. The
|
|
144
|
+
reaction is the same for all of them: have the agent call `check_inbox`. A
|
|
145
|
+
consumer does not need to parse anything; "a notification came" is enough.
|
|
146
|
+
|
|
147
|
+
Some things to know:
|
|
148
|
+
|
|
149
|
+
- **Best effort.** A notification can be lost: while the helper is not
|
|
150
|
+
running, while the socket is reconnecting, or when a delivery fails. The
|
|
151
|
+
inbox on the intercom is the truth, and nothing is lost there.
|
|
152
|
+
- **Missed.** When the helper connects and the intercom reports a higher
|
|
153
|
+
sequence number than the helper has seen, or, right after the helper
|
|
154
|
+
started, reports that something is pending, the helper sends one `missed`
|
|
155
|
+
notification with that number.
|
|
156
|
+
- **Bursts.** The intercom spaces its notifications to one agent (30
|
|
157
|
+
seconds apart by default). The first comes at once; those that follow
|
|
158
|
+
within the interval come together as one `burst` when it ends. One look
|
|
159
|
+
at the inbox answers all of them.
|
|
160
|
+
- **A failed delivery is dropped**, with a warning in the log, and not
|
|
161
|
+
tried again. The other methods are not affected.
|
|
162
|
+
|
|
163
|
+
## Delivery methods
|
|
164
|
+
|
|
165
|
+
### File
|
|
166
|
+
|
|
167
|
+
`--notification-file=<file>`
|
|
168
|
+
|
|
169
|
+
The helper opens the file, appends the JSON and a newline in a single
|
|
170
|
+
write, and closes it. One notification per line. The file is created when
|
|
171
|
+
it does not exist; it is never truncated or rotated, so the consumer may
|
|
172
|
+
want to do that.
|
|
173
|
+
|
|
174
|
+
### Directory
|
|
175
|
+
|
|
176
|
+
`--notification-dir=<directory>`
|
|
177
|
+
|
|
178
|
+
Each notification becomes a file named by its sequence number as at least
|
|
179
|
+
eight lower-case hex digits: `000012a7.json`. The time is inside the JSON,
|
|
180
|
+
and the file's modification time tells when it was written.
|
|
181
|
+
|
|
182
|
+
The file is written elsewhere first and renamed into place, so whoever
|
|
183
|
+
watches the directory sees one event per notification, for a complete file.
|
|
184
|
+
"Elsewhere" is `.tmp` inside the directory itself, which the helper creates.
|
|
185
|
+
The helper therefore needs write access to the directory and to nothing
|
|
186
|
+
else, and writing into `.tmp` changes neither the listing nor the
|
|
187
|
+
modification time of the directory. A watcher that also watches
|
|
188
|
+
subdirectories will see `.tmp` and can skip it, or
|
|
189
|
+
`--notification-tmp-dir` can point to another directory on the same file
|
|
190
|
+
system.
|
|
191
|
+
|
|
192
|
+
The helper never removes the files. That is the consumer's job.
|
|
193
|
+
|
|
194
|
+
### URL
|
|
195
|
+
|
|
196
|
+
`--notification-url=<url>`, optionally with
|
|
197
|
+
`--notification-url-header="Name: value"`
|
|
198
|
+
|
|
199
|
+
The JSON is sent with `POST` and `Content-Type: application/json`. Any 2xx
|
|
200
|
+
answer counts as delivered. The helper waits 10 seconds for an answer.
|
|
201
|
+
|
|
202
|
+
### Local socket
|
|
203
|
+
|
|
204
|
+
`--notification-local-socket=<path>`
|
|
205
|
+
|
|
206
|
+
The helper connects to the Unix domain socket, writes the JSON and a
|
|
207
|
+
newline, and closes the connection. One notification per connection.
|
|
208
|
+
|
|
209
|
+
### Command
|
|
210
|
+
|
|
211
|
+
`--notification-command=<command>`
|
|
212
|
+
|
|
213
|
+
The command is run through the shell with the JSON on its standard input.
|
|
214
|
+
`INTERCOM_NOTIFICATION_MESSAGE` and `INTERCOM_NOTIFICATION_SEQ` are set in
|
|
215
|
+
its environment, so a simple command needs to parse nothing. Its standard
|
|
216
|
+
error goes to the helper's log.
|
|
217
|
+
|
|
218
|
+
One command runs at a time. Of the notifications that arrive while it runs,
|
|
219
|
+
only the latest is kept and run afterwards. A run that takes more than 60
|
|
220
|
+
seconds is killed.
|
|
221
|
+
|
|
222
|
+
```sh
|
|
223
|
+
--notification-command='touch ~/.intercom/poke'
|
|
224
|
+
--notification-command='tmux send-keys -t agent "check the intercom inbox" Enter'
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Listener
|
|
228
|
+
|
|
229
|
+
`--notification-listener`, or `--notification-listener-path=<path>`
|
|
230
|
+
|
|
231
|
+
The other methods push each notification somewhere. With this one the
|
|
232
|
+
helper listens on a local (Unix domain) socket of its own, and every
|
|
233
|
+
program that connects is sent each notification as one line of compact
|
|
234
|
+
JSON. Several programs can be connected at once; each gets every line. What
|
|
235
|
+
a client sends is ignored.
|
|
236
|
+
|
|
237
|
+
The socket is
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
/tmp/tr-ai-intercom-helper.<uid>/<port>.sock
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
with the user's numeric id and the helper's local port, so a client finds
|
|
244
|
+
it from the port alone. `--notification-listener-path` puts it somewhere
|
|
245
|
+
else. The socket is readable and writable by its owner only, and is removed
|
|
246
|
+
when the helper stops.
|
|
247
|
+
|
|
248
|
+
While no program is connected the helper keeps the latest notification and
|
|
249
|
+
gives it to the next program that connects. A program that reads one
|
|
250
|
+
notification, acts on it and connects again therefore misses nothing that
|
|
251
|
+
came in between; it gets the latest of them.
|
|
252
|
+
|
|
253
|
+
`/tmp` is shared between users. The helper creates its directory there for
|
|
254
|
+
the owner only, and refuses to use one that exists but belongs to somebody
|
|
255
|
+
else or is open to others. Where that is a problem, give a path of your own
|
|
256
|
+
with `--notification-listener-path`.
|
|
257
|
+
|
|
258
|
+
When it starts with the listener on, the helper logs the socket's path and
|
|
259
|
+
the command line of the client described next.
|
|
260
|
+
|
|
261
|
+
## tr-ai-intercom-notify
|
|
262
|
+
|
|
263
|
+
A second command in this package: the ready-made client of the listener. It
|
|
264
|
+
connects to a running helper and prints each notification to its standard
|
|
265
|
+
output, one line of JSON each, exactly as the helper sends it. An agent
|
|
266
|
+
program that can run a command in the background and react to its output
|
|
267
|
+
needs nothing else to be woken.
|
|
268
|
+
|
|
269
|
+
```sh
|
|
270
|
+
npx -y -p tr-ai-intercom-helper tr-ai-intercom-notify --monitor --helper-port 32323
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
| Option | Meaning |
|
|
274
|
+
| ------------------------------ | -------------------------------------------------------------------------------- |
|
|
275
|
+
| `--monitor` | Print every notification as it arrives, until stopped |
|
|
276
|
+
| `--oneshot` | Print the first notification and end |
|
|
277
|
+
| `--reconnect` | With `--monitor`: keep trying every 5 seconds while the helper cannot be reached |
|
|
278
|
+
| `--helper-port` | The helper's local port; the socket is found from it |
|
|
279
|
+
| `--socket-path` | The socket itself, for a helper started with `--notification-listener-path` |
|
|
280
|
+
| `--idle-notification-interval` | Print a notification of its own whenever nothing was printed for this long |
|
|
281
|
+
|
|
282
|
+
Exactly one of `--monitor` and `--oneshot` is needed, and exactly one of
|
|
283
|
+
`--helper-port` and `--socket-path`. The options can also be given in the
|
|
284
|
+
environment, as `INTERCOM_NOTIFY_OPT_` and the option name.
|
|
285
|
+
|
|
286
|
+
- **Two ways to wake.** With `--monitor` the agent reacts to each line as
|
|
287
|
+
it is printed. With `--oneshot` the command ends after one line, the agent
|
|
288
|
+
reacts to its ending, looks at its inbox and starts the command again.
|
|
289
|
+
- **Ending.** Without `--reconnect` the command ends with status 1 and a
|
|
290
|
+
remark on standard error when no helper listens or the helper stops, so
|
|
291
|
+
that whoever started it notices. With `--oneshot`, status 0 means a line
|
|
292
|
+
was printed.
|
|
293
|
+
- **Idle notifications.** With `--idle-notification-interval` (seconds, or
|
|
294
|
+
`15m`, `2h`, `1d`) the command prints a line of its own each time nothing
|
|
295
|
+
has been printed for that long, for an agent that should look at its inbox
|
|
296
|
+
now and then even when nothing came:
|
|
297
|
+
|
|
298
|
+
```json
|
|
299
|
+
{
|
|
300
|
+
"message": "local-idle-notification",
|
|
301
|
+
"seq": 4711,
|
|
302
|
+
"time": "2026-11-15T14:00:00.000Z",
|
|
303
|
+
"data": { "idle": 3600 }
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
It is made by the command, not by the intercom or the helper. `seq` is the
|
|
308
|
+
highest number printed so far (0 when none), `time` is the local clock,
|
|
309
|
+
and `data.idle` is the number of seconds since the last notification from
|
|
310
|
+
the helper, or since the command started when there has been none. With
|
|
311
|
+
`--oneshot` it counts as the one line, and the command ends.
|
|
312
|
+
|
|
313
|
+
## The connection
|
|
314
|
+
|
|
315
|
+
- The socket is opened when the first response names it, and stays open
|
|
316
|
+
across the agent's MCP sessions.
|
|
317
|
+
- If it cannot be opened, the helper tries again every 60 seconds. If it
|
|
318
|
+
closes, the helper reopens it after 5 seconds, and then every 60.
|
|
319
|
+
- The intercom allows one socket per agent. If another program takes it,
|
|
320
|
+
the helper says so in its log and waits 10 minutes before trying again.
|
|
321
|
+
**Run one helper per agent**, each on its own port.
|
|
322
|
+
- If the intercom refuses the token, or the token is revoked, the helper
|
|
323
|
+
stops trying. After a new token, restart the helper.
|
|
324
|
+
- With `--notification-websocket-timeout` the socket is closed when the
|
|
325
|
+
agent has sent nothing for that long and no request is in flight. It
|
|
326
|
+
reopens with the agent's next request.
|
|
327
|
+
- The intercom's console shows which agents have a helper connected.
|
|
328
|
+
|
|
329
|
+
## Security
|
|
330
|
+
|
|
331
|
+
- The helper listens on `127.0.0.1` by default. Whoever can reach the port
|
|
332
|
+
can reach the intercom through it, but still needs a token: the helper
|
|
333
|
+
adds no credentials to any request.
|
|
334
|
+
- The agent's token is held in memory, sent only to the intercom, and never
|
|
335
|
+
logged or written anywhere.
|
|
336
|
+
- The agent connects to the helper over plain HTTP. That is fine on the
|
|
337
|
+
loopback address and not anywhere else; think twice before changing
|
|
338
|
+
`--listen-address`.
|
|
339
|
+
- Use an `https://` URL for the intercom. The wake-up socket then uses
|
|
340
|
+
`wss://`.
|
|
341
|
+
- Notifications carry no content, so the files and endpoints they go to
|
|
342
|
+
reveal only that the agent received something, and when.
|
|
343
|
+
|
|
344
|
+
## Development
|
|
345
|
+
|
|
346
|
+
```sh
|
|
347
|
+
pnpm install # or npm install
|
|
348
|
+
pnpm test # builds first for the command-line tests
|
|
349
|
+
pnpm typecheck && pnpm lint
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The tests run against a stand-in for the intercom. To check the built
|
|
353
|
+
helper against a real one:
|
|
354
|
+
|
|
355
|
+
```sh
|
|
356
|
+
node scripts/live-check.mjs --intercom-url http://localhost:8080 \
|
|
357
|
+
--admin-url http://localhost:8081 --admin-password '...'
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
It creates two agents and a channel through the admin API, starts the
|
|
361
|
+
helper with every delivery method, sends traffic, reports what arrived
|
|
362
|
+
where, and removes what it created.
|
|
363
|
+
|
|
364
|
+
## License
|
|
365
|
+
|
|
366
|
+
MIT. See [LICENSE](LICENSE).
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { startHelper } from './helper.js';
|
|
3
|
+
import { notifyCommand } from './listener.js';
|
|
4
|
+
import { createLogger } from './logger.js';
|
|
5
|
+
import { ConfigError, loadConfig } from './options.js';
|
|
6
|
+
import { sinksFor } from './sinks.js';
|
|
7
|
+
import { VERSION } from './version.js';
|
|
8
|
+
async function main() {
|
|
9
|
+
// A mistake on the command line or in the environment prints the usage
|
|
10
|
+
// text and ends the process here.
|
|
11
|
+
const config = loadConfig();
|
|
12
|
+
const logger = createLogger(config.logLevel);
|
|
13
|
+
const sinks = sinksFor(config, logger);
|
|
14
|
+
logger.info('tr-ai-intercom-helper starting', {
|
|
15
|
+
version: VERSION,
|
|
16
|
+
upstream: config.upstreamUrl,
|
|
17
|
+
methods: sinks.map((s) => s.name),
|
|
18
|
+
});
|
|
19
|
+
if (sinks.length === 0) {
|
|
20
|
+
logger.warn('no delivery method is configured (--notification-file, -dir, -url, -local-socket, -command, -listener): requests are forwarded, notifications go nowhere');
|
|
21
|
+
}
|
|
22
|
+
const helper = await startHelper({ config, logger, sinks });
|
|
23
|
+
logger.info('listening for the agent', { address: config.host, port: helper.port });
|
|
24
|
+
if (config.listener) {
|
|
25
|
+
const listener = sinks.find((s) => s.name === 'listener');
|
|
26
|
+
logger.info('notification listener ready', {
|
|
27
|
+
path: listener.path(),
|
|
28
|
+
command: notifyCommand({ port: helper.port, path: config.listenerPath }),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
let stopping = false;
|
|
32
|
+
const shutdown = (signal) => {
|
|
33
|
+
if (stopping)
|
|
34
|
+
return;
|
|
35
|
+
stopping = true;
|
|
36
|
+
logger.info('shutting down', { signal });
|
|
37
|
+
setTimeout(() => process.exit(1), 5000).unref();
|
|
38
|
+
void helper.stop().finally(() => process.exit(0));
|
|
39
|
+
};
|
|
40
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
41
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
42
|
+
}
|
|
43
|
+
main().catch((err) => {
|
|
44
|
+
const code = err.code;
|
|
45
|
+
if (code === 'EADDRINUSE') {
|
|
46
|
+
console.error(`The local port is already in use: ${err.message}`);
|
|
47
|
+
}
|
|
48
|
+
else if (err instanceof ConfigError || err instanceof Error) {
|
|
49
|
+
console.error(err.message);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
console.error(err);
|
|
53
|
+
}
|
|
54
|
+
process.exit(1);
|
|
55
|
+
});
|
package/dist/helper.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Logger } from './logger.js';
|
|
2
|
+
import type { Config } from './options.js';
|
|
3
|
+
import { Dispatcher, type Sink } from './sinks.js';
|
|
4
|
+
import { WakeupClient, type Timings } from './wakeup.js';
|
|
5
|
+
export interface RunningHelper {
|
|
6
|
+
/** The local port the agent connects to. */
|
|
7
|
+
port: number;
|
|
8
|
+
wakeup: WakeupClient;
|
|
9
|
+
dispatcher: Dispatcher;
|
|
10
|
+
stop(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Start the helper: a local proxy to the intercom that also opens the
|
|
14
|
+
* intercom's wake-up socket, once a response has named it, and hands the
|
|
15
|
+
* notifications to the configured delivery methods.
|
|
16
|
+
*/
|
|
17
|
+
export declare function startHelper(opts: {
|
|
18
|
+
config: Config;
|
|
19
|
+
logger: Logger;
|
|
20
|
+
/** Delivery methods; default: those the configuration asks for. */
|
|
21
|
+
sinks?: Sink[];
|
|
22
|
+
/** Reconnection timing; only tests change it. */
|
|
23
|
+
timings?: Partial<Timings>;
|
|
24
|
+
}): Promise<RunningHelper>;
|
package/dist/helper.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createProxy } from './proxy.js';
|
|
2
|
+
import { Dispatcher, sinksFor } from './sinks.js';
|
|
3
|
+
import { socketUrl } from './urls.js';
|
|
4
|
+
import { WakeupClient } from './wakeup.js';
|
|
5
|
+
/**
|
|
6
|
+
* Start the helper: a local proxy to the intercom that also opens the
|
|
7
|
+
* intercom's wake-up socket, once a response has named it, and hands the
|
|
8
|
+
* notifications to the configured delivery methods.
|
|
9
|
+
*/
|
|
10
|
+
export async function startHelper(opts) {
|
|
11
|
+
const { config, logger } = opts;
|
|
12
|
+
const sinks = opts.sinks ?? sinksFor(config, logger);
|
|
13
|
+
const dispatcher = new Dispatcher(sinks, logger);
|
|
14
|
+
// Until the delivery methods are ready, requests are forwarded and nothing more.
|
|
15
|
+
let ready = false;
|
|
16
|
+
const wakeup = new WakeupClient({
|
|
17
|
+
deliver: (notification) => dispatcher.deliver(notification),
|
|
18
|
+
logger,
|
|
19
|
+
idleTimeoutMs: config.socketTimeout === undefined ? undefined : config.socketTimeout * 1000,
|
|
20
|
+
...(opts.timings ? { timings: opts.timings } : {}),
|
|
21
|
+
});
|
|
22
|
+
let warnedBadHeader = false;
|
|
23
|
+
const server = createProxy({
|
|
24
|
+
upstream: config.upstreamUrl,
|
|
25
|
+
logger,
|
|
26
|
+
hooks: {
|
|
27
|
+
activity: (started) => wakeup.activity(started),
|
|
28
|
+
announced: ({ socket, requestUrl, authorization }) => {
|
|
29
|
+
if (!ready)
|
|
30
|
+
return;
|
|
31
|
+
const url = socketUrl(socket, requestUrl);
|
|
32
|
+
if (url) {
|
|
33
|
+
wakeup.announce(url, authorization);
|
|
34
|
+
}
|
|
35
|
+
else if (!warnedBadHeader) {
|
|
36
|
+
warnedBadHeader = true;
|
|
37
|
+
logger.warn('the intercom named a wake-up socket that is not a usable URL', {
|
|
38
|
+
value: socket,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
await new Promise((resolve, reject) => {
|
|
45
|
+
server.once('error', reject);
|
|
46
|
+
server.listen(config.port, config.host, () => {
|
|
47
|
+
server.off('error', reject);
|
|
48
|
+
resolve();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
const port = server.address().port;
|
|
52
|
+
try {
|
|
53
|
+
// Started after listening: a delivery method may need the port.
|
|
54
|
+
await dispatcher.start({ port });
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
dispatcher.stop();
|
|
58
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
ready = true;
|
|
62
|
+
return {
|
|
63
|
+
port,
|
|
64
|
+
wakeup,
|
|
65
|
+
dispatcher,
|
|
66
|
+
async stop() {
|
|
67
|
+
wakeup.stop();
|
|
68
|
+
await new Promise((resolve) => {
|
|
69
|
+
server.close(() => resolve());
|
|
70
|
+
// Open event streams would otherwise keep the server alive.
|
|
71
|
+
server.closeAllConnections();
|
|
72
|
+
});
|
|
73
|
+
await dispatcher.idle();
|
|
74
|
+
dispatcher.stop();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { startHelper, type RunningHelper } from './helper.js';
|
|
2
|
+
export { checkOwnDirectory, defaultListenerPath, listenerSink, notifyCommand, type ListenerSink, } from './listener.js';
|
|
3
|
+
export { createLogger, silentLogger, type Logger } from './logger.js';
|
|
4
|
+
export { resolveSocketPath, runNotifier, type NotifierOptions } from './notifier.js';
|
|
5
|
+
export { ConfigError, OPTIONS, loadConfig, type Config } from './options.js';
|
|
6
|
+
export { SOCKET_HEADER, createProxy } from './proxy.js';
|
|
7
|
+
export { Dispatcher, commandSink, dirSink, fileNameOf, fileSink, localSocketSink, urlSink, type Sink, } from './sinks.js';
|
|
8
|
+
export { socketUrl, upstreamUrl } from './urls.js';
|
|
9
|
+
export { VERSION } from './version.js';
|
|
10
|
+
export { DEFAULT_TIMINGS, WakeupClient, type Notification, type Timings } from './wakeup.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { startHelper } from './helper.js';
|
|
2
|
+
export { checkOwnDirectory, defaultListenerPath, listenerSink, notifyCommand, } from './listener.js';
|
|
3
|
+
export { createLogger, silentLogger } from './logger.js';
|
|
4
|
+
export { resolveSocketPath, runNotifier } from './notifier.js';
|
|
5
|
+
export { ConfigError, OPTIONS, loadConfig } from './options.js';
|
|
6
|
+
export { SOCKET_HEADER, createProxy } from './proxy.js';
|
|
7
|
+
export { Dispatcher, commandSink, dirSink, fileNameOf, fileSink, localSocketSink, urlSink, } from './sinks.js';
|
|
8
|
+
export { socketUrl, upstreamUrl } from './urls.js';
|
|
9
|
+
export { VERSION } from './version.js';
|
|
10
|
+
export { DEFAULT_TIMINGS, WakeupClient } from './wakeup.js';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Logger } from './logger.js';
|
|
2
|
+
import type { Sink, StartInfo } from './sinks.js';
|
|
3
|
+
/** The user's own directory for listener sockets. Undefined where there are no user ids. */
|
|
4
|
+
export declare function listenerDirectory(): string | undefined;
|
|
5
|
+
/** The default socket path of the helper that listens on `port`. */
|
|
6
|
+
export declare function defaultListenerPath(port: number): string;
|
|
7
|
+
/**
|
|
8
|
+
* Make sure a directory in /tmp is really the user's own. /tmp is shared, so
|
|
9
|
+
* somebody else may have created a directory of that name first, to read or
|
|
10
|
+
* to fake what goes through it.
|
|
11
|
+
*/
|
|
12
|
+
export declare function checkOwnDirectory(dir: string): Promise<void>;
|
|
13
|
+
export interface ListenerSink extends Sink {
|
|
14
|
+
start(info: StartInfo): Promise<void>;
|
|
15
|
+
stop(): void;
|
|
16
|
+
/** Where the socket is, once started. */
|
|
17
|
+
path(): string | undefined;
|
|
18
|
+
clientCount(): number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The delivery method behind --notification-listener. Each connected client
|
|
22
|
+
* gets every notification as one line of compact JSON. While nobody is
|
|
23
|
+
* connected the latest notification is kept and handed to the next client
|
|
24
|
+
* that connects, so that a program which reads one notification, acts, and
|
|
25
|
+
* connects again does not lose what came in between.
|
|
26
|
+
*/
|
|
27
|
+
export declare function listenerSink(logger: Logger, configuredPath?: string): ListenerSink;
|
|
28
|
+
/** The command line that connects to a listener, for the log and the documentation. */
|
|
29
|
+
export declare function notifyCommand(opts: {
|
|
30
|
+
port: number;
|
|
31
|
+
path?: string | undefined;
|
|
32
|
+
}): string;
|