@kolisachint/hoobot 0.0.3
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/.env.example +46 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/package.json +42 -0
- package/src/codex-client.ts +213 -0
- package/src/config.ts +108 -0
- package/src/format.ts +82 -0
- package/src/index.ts +224 -0
- package/src/links.ts +43 -0
- package/src/session.ts +423 -0
package/.env.example
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Copy to .env and fill in. Never commit .env.
|
|
2
|
+
|
|
3
|
+
# Developer portal → your app → Bot → Reset Token
|
|
4
|
+
DISCORD_TOKEN=
|
|
5
|
+
|
|
6
|
+
# Comma-separated Discord user IDs allowed to use the bot.
|
|
7
|
+
# Required: the bot refuses to start without it.
|
|
8
|
+
ALLOWED_USER_IDS=758289752645959720
|
|
9
|
+
|
|
10
|
+
# Only answer in the Hoo server.
|
|
11
|
+
GUILD_ID=1554149385623838922
|
|
12
|
+
|
|
13
|
+
# Optional: only answer in these channel IDs (comma-separated). Empty = all.
|
|
14
|
+
CHANNEL_IDS=
|
|
15
|
+
|
|
16
|
+
# Folder hoocode works in. Default: ./workspace
|
|
17
|
+
HOO_WORKDIR=./workspace
|
|
18
|
+
|
|
19
|
+
# Which Codex app-server to talk to. Empty = start `hoocode app-server`
|
|
20
|
+
# in HOO_WORKDIR over stdio. Or a running server:
|
|
21
|
+
# unix:// (not supported here; give the full path)
|
|
22
|
+
# unix:///Users/you/.cortexcode/app-server-control/app-server-control.sock
|
|
23
|
+
# stdio:codex app-server (the real Codex, for comparison)
|
|
24
|
+
APP_SERVER=
|
|
25
|
+
|
|
26
|
+
# hoocode command, and extra flags for `hoocode app-server`
|
|
27
|
+
# (only used when APP_SERVER is empty).
|
|
28
|
+
HOOCODE_BIN=hoocode
|
|
29
|
+
HOOCODE_ARGS=
|
|
30
|
+
|
|
31
|
+
# Optional model for new threads, e.g. anthropic/claude-sonnet-4-5
|
|
32
|
+
MODEL=
|
|
33
|
+
|
|
34
|
+
# Discord thread -> app-server thread links.
|
|
35
|
+
# Default: ~/.local/share/hoobot/links.json
|
|
36
|
+
LINKS_FILE=
|
|
37
|
+
|
|
38
|
+
# Minutes to wait for an Allow/Deny click before cancelling.
|
|
39
|
+
APPROVAL_TIMEOUT_MINUTES=10
|
|
40
|
+
|
|
41
|
+
# Minutes of silence before the bot lets go of a thread.
|
|
42
|
+
# The conversation is saved by the server and resumes on the next message.
|
|
43
|
+
IDLE_TIMEOUT_MINUTES=30
|
|
44
|
+
|
|
45
|
+
# 1 = print hoocode's stderr to the terminal
|
|
46
|
+
DEBUG=0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sachin Koli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# hoo-discord-bot
|
|
2
|
+
|
|
3
|
+
Use **hoocode** from Discord.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
Discord ⇄ this bot (Bun + discord.js) ⇄ Codex app-server protocol ⇄ hoocode app-server
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Each Discord thread is one app-server thread. The bot speaks only standard
|
|
10
|
+
Codex app-server methods, so the server is swappable: `hoocode app-server`
|
|
11
|
+
(default), or the real `codex app-server`, set with `APP_SERVER` in `.env`.
|
|
12
|
+
By default the bot starts `hoocode app-server` itself, in `HOO_WORKDIR`.
|
|
13
|
+
|
|
14
|
+
To share threads with other clients (e.g. the Codex TUI with
|
|
15
|
+
`codex --remote unix://PATH`), run one server and point everyone at it:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
cd workspace && hoocode app-server --listen unix:// # prints the socket path
|
|
19
|
+
# .env: APP_SERVER=unix:///Users/you/.cortexcode/app-server-control/app-server-control.sock
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Install from npm
|
|
23
|
+
|
|
24
|
+
Needs [Bun](https://bun.sh). The bot reads `.env` from the folder you start it in.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
bun add -g @kolisachint/hoobot
|
|
28
|
+
curl -o .env https://raw.githubusercontent.com/kolisachint/hoobot/main/.env.example # then fill it in
|
|
29
|
+
hoo-discord-bot
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Setup
|
|
33
|
+
|
|
34
|
+
1. Install dependencies:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
bun install
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
2. Create your settings file:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
cp .env.example .env
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
3. Open `.env` and paste the bot token after `DISCORD_TOKEN=`.
|
|
47
|
+
|
|
48
|
+
4. In the developer portal, on the **Bot** page,
|
|
49
|
+
turn on **Message Content Intent**.
|
|
50
|
+
|
|
51
|
+
5. Start the bot:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
bun start
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
You should see `Logged in as hoo#1234`.
|
|
58
|
+
|
|
59
|
+
## Run in the background (macOS)
|
|
60
|
+
|
|
61
|
+
Runs at login and restarts itself if it crashes.
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
scripts/service.sh install # start + enable at login
|
|
65
|
+
scripts/service.sh status # running? pid?
|
|
66
|
+
scripts/service.sh logs # follow the log
|
|
67
|
+
scripts/service.sh restart # after editing .env or code
|
|
68
|
+
scripts/service.sh uninstall # stop + remove
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- Service file: `~/Library/LaunchAgents/com.hoo.discord-bot.plist`
|
|
72
|
+
- Log: `~/.local/state/hoo-discord-bot/bot.log`
|
|
73
|
+
|
|
74
|
+
The Mac must be awake and logged in for the bot to answer.
|
|
75
|
+
|
|
76
|
+
## Using it
|
|
77
|
+
|
|
78
|
+
- **Start:** in any channel, mention the bot with a request,
|
|
79
|
+
e.g. `@hoo list the files here`. It opens a thread.
|
|
80
|
+
- **Continue:** type in that thread. No mention needed.
|
|
81
|
+
- **Steer:** typing while it's busy redirects the current run.
|
|
82
|
+
|
|
83
|
+
### Commands (inside a thread)
|
|
84
|
+
|
|
85
|
+
| Command | What it does |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `!stop` | Stop the current run |
|
|
88
|
+
| `!new` | Forget the conversation |
|
|
89
|
+
| `!status` | Model, busy or not, thread, server |
|
|
90
|
+
| `!model <name>` | Switch model from the next message |
|
|
91
|
+
| `!help` | Show help |
|
|
92
|
+
|
|
93
|
+
## Safety
|
|
94
|
+
|
|
95
|
+
- Only user IDs in `ALLOWED_USER_IDS` can use it.
|
|
96
|
+
The bot won't start if that list is empty.
|
|
97
|
+
- `bash`, `edit` and `write` show **Allow once / Deny** buttons.
|
|
98
|
+
No click within 10 minutes means denied.
|
|
99
|
+
- There is no "Always" button. It would change your
|
|
100
|
+
global `~/.hoocode/hoo-config.json`.
|
|
101
|
+
|
|
102
|
+
How the approvals work: on first start the bot writes
|
|
103
|
+
`workspace/.cortexcode/hoo-config.json`, which puts that folder in a
|
|
104
|
+
custom `discord` mode. In that mode only `read` runs without asking.
|
|
105
|
+
Your normal `build` mode, which skips approvals, is not used here.
|
|
106
|
+
The server sends approvals to every client on the thread; the first
|
|
107
|
+
answer wins and the other clients see it resolved.
|
|
108
|
+
|
|
109
|
+
## Files
|
|
110
|
+
|
|
111
|
+
| Path | Purpose |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `src/index.ts` | Discord side: mentions, threads, commands |
|
|
114
|
+
| `src/session.ts` | One app-server thread per Discord thread; notifications → messages, buttons → approvals |
|
|
115
|
+
| `src/codex-client.ts` | Codex app-server client (`unix://` WebSocket or `stdio:`) |
|
|
116
|
+
| `src/links.ts` | Discord thread → app-server thread links (`LINKS_FILE`) |
|
|
117
|
+
| `src/format.ts` | Splits long replies to fit Discord's 2000-character limit |
|
|
118
|
+
| `workspace/` | hoocode's working folder (git-ignored); sessions are saved by hoocode |
|
|
119
|
+
|
|
120
|
+
## Tests
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
bun run test # message splitting (what CI runs)
|
|
124
|
+
bun test/session.e2e.ts # real app-server, fake Discord (uses API credits)
|
|
125
|
+
APP_SERVER="stdio:codex app-server" bun test/session.e2e.ts # same, real Codex
|
|
126
|
+
bun run typecheck
|
|
127
|
+
bun run check # typecheck + unit tests
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Releasing
|
|
131
|
+
|
|
132
|
+
Releases are driven by PR labels. `package.json`'s `version` is owned by CI;
|
|
133
|
+
don't edit it by hand.
|
|
134
|
+
|
|
135
|
+
| Label | On merge |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `npm:patch` | 0.1.0 → 0.1.1 |
|
|
138
|
+
| `npm:minor` | 0.1.0 → 0.2.0 |
|
|
139
|
+
| `npm:major` | 0.1.0 → 1.0.0 |
|
|
140
|
+
| none | no release |
|
|
141
|
+
|
|
142
|
+
- `.github/workflows/ci.yml`: typecheck, unit tests and `npm pack --dry-run`
|
|
143
|
+
on every PR and on `main`.
|
|
144
|
+
- `.github/workflows/pr-labels.yml`: fails a PR with more than one `npm:*`
|
|
145
|
+
label.
|
|
146
|
+
- `.github/workflows/release.yml`: on merge to `main`, reads the merged PR's
|
|
147
|
+
label, bumps `package.json`, pushes `chore(release): vX.Y.Z` plus the tag
|
|
148
|
+
`vX.Y.Z` to `main`, creates the GitHub release, publishes to npm
|
|
149
|
+
(`NPM_TOKEN` secret) and comments on the PR. It can also be run by hand
|
|
150
|
+
(Actions → Release → Run workflow, pick a bump).
|
|
151
|
+
|
|
152
|
+
Workflow:
|
|
153
|
+
|
|
154
|
+
1. `/pr minor` (or `patch` / `major`; plain `/pr` for no release): branches,
|
|
155
|
+
runs `bun run check`, commits everything, pushes, opens the PR and
|
|
156
|
+
labels it `npm:minor`.
|
|
157
|
+
2. Merge the PR on GitHub.
|
|
158
|
+
3. `/postmerge`: waits for the release workflow, checks the tag, GitHub
|
|
159
|
+
release and npm version, then switches to `main`, pulls and deletes the
|
|
160
|
+
merged branch.
|
|
161
|
+
|
|
162
|
+
The commands live in `.cortexcode/prompts/`. `NPM_TOKEN` must be an npm
|
|
163
|
+
*Automation* (or granular publish) token. The bot pushes the release commit
|
|
164
|
+
straight to `main`, so if you add branch protection, let GitHub Actions
|
|
165
|
+
bypass it. If a release half-fails, re-run it: the release commit records
|
|
166
|
+
`Release-PR: #N`, so a re-run finishes that version instead of bumping again.
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kolisachint/hoobot",
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "Use hoocode from Discord: a Discord bot that talks the Codex app-server protocol",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/kolisachint/hoobot.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": "https://github.com/kolisachint/hoobot/issues",
|
|
12
|
+
"homepage": "https://github.com/kolisachint/hoobot#readme",
|
|
13
|
+
"bin": {
|
|
14
|
+
"hoo-discord-bot": "src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
".env.example"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"bun": ">=1.1"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/bun": "^1.4.2",
|
|
30
|
+
"typescript": "^7.0.2"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"discord.js": "^14.27.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"start": "bun src/index.ts",
|
|
37
|
+
"dev": "bun --watch src/index.ts",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "bun test test/format.test.ts test/codex-client.test.ts",
|
|
40
|
+
"check": "bun run typecheck && bun run test"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for a Codex app-server: `hoocode app-server` or the real
|
|
3
|
+
* `codex app-server`. Uses only standard Codex methods, so either works.
|
|
4
|
+
*
|
|
5
|
+
* Transports:
|
|
6
|
+
* - `unix://PATH`: WebSocket over a Unix socket (one JSON message per frame);
|
|
7
|
+
* - `stdio:CMD ARGS...`: spawn a server and talk LF-delimited JSON on its pipes.
|
|
8
|
+
*/
|
|
9
|
+
import { EventEmitter } from "node:events";
|
|
10
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
11
|
+
import { createInterface } from "node:readline";
|
|
12
|
+
|
|
13
|
+
export type RequestId = number | string;
|
|
14
|
+
|
|
15
|
+
export type Notification = { method: string; params: any };
|
|
16
|
+
export type ServerRequest = { id: RequestId; method: string; params: any };
|
|
17
|
+
|
|
18
|
+
export class RpcError extends Error {
|
|
19
|
+
constructor(
|
|
20
|
+
readonly code: number,
|
|
21
|
+
message: string,
|
|
22
|
+
) {
|
|
23
|
+
super(message);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Transport {
|
|
28
|
+
send(message: unknown): void;
|
|
29
|
+
close(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Events: `notification` (Notification), `request` (ServerRequest),
|
|
34
|
+
* `close` (reason: string).
|
|
35
|
+
*/
|
|
36
|
+
export class CodexClient extends EventEmitter {
|
|
37
|
+
private nextId = 1;
|
|
38
|
+
private pending = new Map<RequestId, { resolve: (v: any) => void; reject: (e: Error) => void }>();
|
|
39
|
+
private transport: Transport | null = null;
|
|
40
|
+
private closed = false;
|
|
41
|
+
|
|
42
|
+
private constructor(readonly endpoint: string) {
|
|
43
|
+
super();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Connect and run the `initialize` handshake. Fails after
|
|
48
|
+
* `initializeTimeoutMs` if the server never answers (e.g. a hoocode
|
|
49
|
+
* build without `app-server`, which just sits on stdin).
|
|
50
|
+
*/
|
|
51
|
+
static async connect(
|
|
52
|
+
endpoint: string,
|
|
53
|
+
clientInfo = { name: "hoobot", version: "0.1.0" },
|
|
54
|
+
options: { cwd?: string; initializeTimeoutMs?: number } = {},
|
|
55
|
+
): Promise<CodexClient> {
|
|
56
|
+
const client = new CodexClient(endpoint);
|
|
57
|
+
await client.open(options.cwd);
|
|
58
|
+
const timeoutMs = options.initializeTimeoutMs ?? 15_000;
|
|
59
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
60
|
+
try {
|
|
61
|
+
await Promise.race([
|
|
62
|
+
client.request("initialize", {
|
|
63
|
+
clientInfo,
|
|
64
|
+
capabilities: { experimentalApi: true },
|
|
65
|
+
}),
|
|
66
|
+
new Promise((_, reject) => {
|
|
67
|
+
timer = setTimeout(
|
|
68
|
+
() =>
|
|
69
|
+
reject(
|
|
70
|
+
new Error(
|
|
71
|
+
`app-server at ${endpoint} did not answer initialize within ${timeoutMs / 1000}s. ` +
|
|
72
|
+
"Does this hoocode build support `app-server`? Set HOOCODE_BIN or APP_SERVER in .env.",
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
timeoutMs,
|
|
76
|
+
);
|
|
77
|
+
}),
|
|
78
|
+
]);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
client.close();
|
|
81
|
+
throw err;
|
|
82
|
+
} finally {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
}
|
|
85
|
+
client.notify("initialized");
|
|
86
|
+
return client;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `cwd`: working folder for a `stdio:` server. */
|
|
90
|
+
private async open(cwd?: string) {
|
|
91
|
+
const onMessage = (text: string) => this.dispatch(text);
|
|
92
|
+
const onClose = (reason: string) => this.shutdown(reason);
|
|
93
|
+
if (this.endpoint.startsWith("unix://")) {
|
|
94
|
+
this.transport = await openUnix(this.endpoint.slice("unix://".length), onMessage, onClose);
|
|
95
|
+
} else if (this.endpoint.startsWith("stdio:")) {
|
|
96
|
+
const [cmd, ...args] = this.endpoint.slice("stdio:".length).trim().split(/\s+/);
|
|
97
|
+
if (!cmd) throw new Error("stdio: endpoint needs a command");
|
|
98
|
+
this.transport = openStdio(cmd, args, cwd, onMessage, onClose);
|
|
99
|
+
} else {
|
|
100
|
+
throw new Error(`unsupported endpoint ${this.endpoint} (use unix://PATH or stdio:CMD)`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
request<T = any>(method: string, params?: unknown): Promise<T> {
|
|
105
|
+
if (this.closed || !this.transport) return Promise.reject(new Error("app-server connection closed"));
|
|
106
|
+
const id = this.nextId++;
|
|
107
|
+
return new Promise<T>((resolve, reject) => {
|
|
108
|
+
this.pending.set(id, { resolve, reject });
|
|
109
|
+
this.transport!.send(params === undefined ? { id, method } : { id, method, params });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
notify(method: string, params?: unknown) {
|
|
114
|
+
this.transport?.send(params === undefined ? { method } : { method, params });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Answer a server request (e.g. an approval). */
|
|
118
|
+
respond(id: RequestId, result: unknown) {
|
|
119
|
+
this.transport?.send({ id, result });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
close() {
|
|
123
|
+
this.transport?.close();
|
|
124
|
+
this.shutdown("closed by client");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
get isClosed() {
|
|
128
|
+
return this.closed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private dispatch(text: string) {
|
|
132
|
+
let msg: any;
|
|
133
|
+
try {
|
|
134
|
+
msg = JSON.parse(text);
|
|
135
|
+
} catch {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (typeof msg?.method === "string") {
|
|
139
|
+
if (msg.id !== undefined && msg.id !== null) {
|
|
140
|
+
this.emit("request", { id: msg.id, method: msg.method, params: msg.params } satisfies ServerRequest);
|
|
141
|
+
} else {
|
|
142
|
+
this.emit("notification", { method: msg.method, params: msg.params } satisfies Notification);
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const waiter = this.pending.get(msg?.id);
|
|
147
|
+
if (!waiter) return;
|
|
148
|
+
this.pending.delete(msg.id);
|
|
149
|
+
if (msg.error) waiter.reject(new RpcError(msg.error.code ?? -1, msg.error.message ?? "error"));
|
|
150
|
+
else waiter.resolve(msg.result);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private shutdown(reason: string) {
|
|
154
|
+
if (this.closed) return;
|
|
155
|
+
this.closed = true;
|
|
156
|
+
for (const w of this.pending.values()) w.reject(new Error(`app-server connection closed: ${reason}`));
|
|
157
|
+
this.pending.clear();
|
|
158
|
+
this.emit("close", reason);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function openUnix(
|
|
163
|
+
path: string,
|
|
164
|
+
onMessage: (text: string) => void,
|
|
165
|
+
onClose: (reason: string) => void,
|
|
166
|
+
): Promise<Transport> {
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
const ws = new WebSocket("ws+unix://" + path);
|
|
169
|
+
let opened = false;
|
|
170
|
+
ws.onopen = () => {
|
|
171
|
+
opened = true;
|
|
172
|
+
resolve({
|
|
173
|
+
send: (m) => ws.send(JSON.stringify(m)),
|
|
174
|
+
close: () => ws.close(),
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
ws.onmessage = (e) => onMessage(typeof e.data === "string" ? e.data : String(e.data));
|
|
178
|
+
ws.onerror = () => {
|
|
179
|
+
if (!opened) reject(new Error(`cannot connect to app-server at ${path}`));
|
|
180
|
+
};
|
|
181
|
+
ws.onclose = () => onClose("socket closed");
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function openStdio(
|
|
186
|
+
cmd: string,
|
|
187
|
+
args: string[],
|
|
188
|
+
cwd: string | undefined,
|
|
189
|
+
onMessage: (text: string) => void,
|
|
190
|
+
onClose: (reason: string) => void,
|
|
191
|
+
): Transport {
|
|
192
|
+
const child: ChildProcessWithoutNullStreams = spawn(cmd, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
193
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
194
|
+
if (line.trim()) onMessage(line);
|
|
195
|
+
});
|
|
196
|
+
child.stderr.on("data", (d) => {
|
|
197
|
+
if (process.env.DEBUG === "1") process.stderr.write(`[app-server] ${d}`);
|
|
198
|
+
});
|
|
199
|
+
child.on("exit", (code) => onClose(`app-server exited (${code})`));
|
|
200
|
+
child.on("error", (err) => onClose(err.message));
|
|
201
|
+
return {
|
|
202
|
+
send: (m) => child.stdin.write(JSON.stringify(m) + "\n"),
|
|
203
|
+
close: () => child.kill(),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Text input for `turn/start` / `turn/steer`, plus images as data URLs. */
|
|
208
|
+
export function userInput(text: string, images: { data: string; mimeType: string }[] = []) {
|
|
209
|
+
const input: any[] = [];
|
|
210
|
+
if (text) input.push({ type: "text", text, text_elements: [] });
|
|
211
|
+
for (const img of images) input.push({ type: "image", url: `data:${img.mimeType};base64,${img.data}` });
|
|
212
|
+
return input;
|
|
213
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { mkdirSync, existsSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
function required(name: string): string {
|
|
6
|
+
const value = process.env[name]?.trim();
|
|
7
|
+
if (!value) {
|
|
8
|
+
console.error(`Missing ${name}. Copy .env.example to .env and fill it in.`);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function list(name: string): string[] {
|
|
15
|
+
return (process.env[name] ?? "")
|
|
16
|
+
.split(",")
|
|
17
|
+
.map((s) => s.trim())
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const config = {
|
|
22
|
+
token: required("DISCORD_TOKEN"),
|
|
23
|
+
/** Only these Discord user IDs can talk to the bot or approve tools. */
|
|
24
|
+
allowedUserIds: new Set(list("ALLOWED_USER_IDS")),
|
|
25
|
+
/** Optional: restrict to one server / some channels. */
|
|
26
|
+
guildId: process.env.GUILD_ID?.trim() || undefined,
|
|
27
|
+
channelIds: new Set(list("CHANNEL_IDS")),
|
|
28
|
+
/** Directory hoocode works in. */
|
|
29
|
+
workdir: resolve(process.env.HOO_WORKDIR?.trim() || "./workspace"),
|
|
30
|
+
hoocodeBin: process.env.HOOCODE_BIN?.trim() || "hoocode",
|
|
31
|
+
hoocodeArgs: (process.env.HOOCODE_ARGS ?? "").split(/\s+/).filter(Boolean),
|
|
32
|
+
/**
|
|
33
|
+
* The Codex app-server to talk to: `unix://PATH` for a running server
|
|
34
|
+
* (`hoocode app-server --listen unix://` or `codex app-server --listen unix://`),
|
|
35
|
+
* or empty to start `hoocode app-server` in the work folder over stdio.
|
|
36
|
+
*/
|
|
37
|
+
appServer: process.env.APP_SERVER?.trim() || "",
|
|
38
|
+
/** Optional model for new threads, e.g. anthropic/claude-sonnet-4-5. */
|
|
39
|
+
model: process.env.MODEL?.trim() || undefined,
|
|
40
|
+
/** Discord thread → app-server thread links. */
|
|
41
|
+
linksFile: resolve(
|
|
42
|
+
process.env.LINKS_FILE?.trim() || join(homedir(), ".local", "share", "hoobot", "links.json"),
|
|
43
|
+
),
|
|
44
|
+
approvalTimeoutMs: Number(process.env.APPROVAL_TIMEOUT_MINUTES ?? 10) * 60_000,
|
|
45
|
+
idleTimeoutMs: Number(process.env.IDLE_TIMEOUT_MINUTES ?? 30) * 60_000,
|
|
46
|
+
debug: process.env.DEBUG === "1",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (config.allowedUserIds.size === 0) {
|
|
50
|
+
console.error(
|
|
51
|
+
"ALLOWED_USER_IDS is empty. Refusing to start: anyone in the server could run shell commands.",
|
|
52
|
+
);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Give the workspace a project-level hoocode config that puts it in a custom
|
|
58
|
+
* "discord" mode. Your global config auto-allows bash/edit/write in build mode,
|
|
59
|
+
* and project configs can only *add* to auto_allow for an existing mode, so a
|
|
60
|
+
* separate mode name is the only way to make hoocode ask first. Those asks
|
|
61
|
+
* become Allow / Deny buttons in Discord.
|
|
62
|
+
*
|
|
63
|
+
* Rust hoocode reads `<workspace>/.cortexcode/`; the old TypeScript build
|
|
64
|
+
* read `.hoocode/`. Without the right one, the workspace silently falls back
|
|
65
|
+
* to the global mode and nothing asks.
|
|
66
|
+
*
|
|
67
|
+
* Only written when missing, so a real project's own config is never clobbered.
|
|
68
|
+
*/
|
|
69
|
+
export function prepareWorkspace() {
|
|
70
|
+
mkdirSync(config.workdir, { recursive: true });
|
|
71
|
+
|
|
72
|
+
const hooDir = join(config.workdir, ".cortexcode");
|
|
73
|
+
const cfgPath = join(hooDir, "hoo-config.json");
|
|
74
|
+
if (!existsSync(cfgPath)) {
|
|
75
|
+
mkdirSync(hooDir, { recursive: true });
|
|
76
|
+
writeFileSync(
|
|
77
|
+
cfgPath,
|
|
78
|
+
JSON.stringify(
|
|
79
|
+
{
|
|
80
|
+
active_mode: "discord",
|
|
81
|
+
modes: { discord: { auto_allow: ["read"] } },
|
|
82
|
+
},
|
|
83
|
+
null,
|
|
84
|
+
2,
|
|
85
|
+
) + "\n",
|
|
86
|
+
);
|
|
87
|
+
console.log(`Wrote ${cfgPath} (bash/edit/write will ask in Discord first)`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const promptPath = join(hooDir, "modes", "discord", "system.md");
|
|
91
|
+
if (!existsSync(promptPath)) {
|
|
92
|
+
mkdirSync(join(hooDir, "modes", "discord"), { recursive: true });
|
|
93
|
+
writeFileSync(
|
|
94
|
+
promptPath,
|
|
95
|
+
[
|
|
96
|
+
"You are being used through a Discord chat.",
|
|
97
|
+
"",
|
|
98
|
+
"- Keep replies short. Discord messages are capped at 2000 characters.",
|
|
99
|
+
"- Use short lines, simple headings and bullet lists; avoid wide tables.",
|
|
100
|
+
"- Put code and command output in fenced code blocks.",
|
|
101
|
+
"- bash, edit and write need the user's approval via a button;",
|
|
102
|
+
" if a call is denied, ask what they want instead of retrying.",
|
|
103
|
+
"- Never commit or push unless asked.",
|
|
104
|
+
"",
|
|
105
|
+
].join("\n"),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Discord hard limit is 2000; leave room for fence repair. */
|
|
2
|
+
const LIMIT = 1900;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Split text into Discord-sized chunks, preferring line breaks and keeping
|
|
6
|
+
* fenced code blocks valid across chunk boundaries.
|
|
7
|
+
*/
|
|
8
|
+
export function splitMessage(text: string, limit = LIMIT): string[] {
|
|
9
|
+
const chunks: string[] = [];
|
|
10
|
+
let current = "";
|
|
11
|
+
let openFence: string | null = null; // e.g. "```ts" while inside a block
|
|
12
|
+
|
|
13
|
+
const flush = () => {
|
|
14
|
+
if (!current.trim()) return;
|
|
15
|
+
chunks.push(openFence ? current + "\n```" : current);
|
|
16
|
+
current = openFence ? openFence + "\n" : "";
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
for (const rawLine of text.split("\n")) {
|
|
20
|
+
// Hard-wrap single lines longer than the limit.
|
|
21
|
+
const pieces: string[] = [];
|
|
22
|
+
for (let i = 0; i < rawLine.length || i === 0; i += limit - 20) {
|
|
23
|
+
pieces.push(rawLine.slice(i, i + limit - 20));
|
|
24
|
+
if (rawLine.length === 0) break;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const line of pieces) {
|
|
28
|
+
const addition = (current ? "\n" : "") + line;
|
|
29
|
+
if (current.length + addition.length > limit) flush();
|
|
30
|
+
current += (current && !current.endsWith("\n") ? "\n" : "") + line;
|
|
31
|
+
|
|
32
|
+
const fence = line.trim().match(/^```(\S*)/);
|
|
33
|
+
if (fence) openFence = openFence ? null : "```" + (fence[1] ?? "");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (current.trim()) chunks.push(current);
|
|
37
|
+
return chunks.length ? chunks : [""];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function truncate(text: string, max: number): string {
|
|
41
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
42
|
+
return oneLine.length > max ? oneLine.slice(0, max - 1) + "…" : oneLine;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Inline code that survives backticks in the content. */
|
|
46
|
+
export function code(text: string): string {
|
|
47
|
+
const clean = text.replace(/`/g, "ˋ");
|
|
48
|
+
return "`" + clean + "`";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Short human description of a tool call, e.g. "bash `ls -la`". */
|
|
52
|
+
export function describeTool(name: string, args: Record<string, any> = {}): string {
|
|
53
|
+
const path = args.path ?? args.file_path;
|
|
54
|
+
switch (name) {
|
|
55
|
+
case "bash":
|
|
56
|
+
return `bash ${code(truncate(String(args.command ?? ""), 120))}`;
|
|
57
|
+
case "read":
|
|
58
|
+
case "edit":
|
|
59
|
+
case "write":
|
|
60
|
+
return `${name} ${code(truncate(String(path ?? "?"), 120))}`;
|
|
61
|
+
case "SearchCodebase":
|
|
62
|
+
return `search ${code(truncate(String(args.query ?? ""), 100))}`;
|
|
63
|
+
case "webfetch":
|
|
64
|
+
return `fetch ${code(truncate(String(args.url ?? ""), 120))}`;
|
|
65
|
+
case "websearch":
|
|
66
|
+
return `web search ${code(truncate(String(args.query ?? ""), 100))}`;
|
|
67
|
+
case "Task":
|
|
68
|
+
return `subagent ${code(truncate(String(args.description ?? args.subagent_type ?? ""), 80))}`;
|
|
69
|
+
default:
|
|
70
|
+
return code(name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Plain text of an assistant message's text blocks. */
|
|
75
|
+
export function assistantText(message: any): string {
|
|
76
|
+
if (!message || !Array.isArray(message.content)) return "";
|
|
77
|
+
return message.content
|
|
78
|
+
.filter((c: any) => c?.type === "text" && typeof c.text === "string")
|
|
79
|
+
.map((c: any) => c.text)
|
|
80
|
+
.join("\n\n")
|
|
81
|
+
.trim();
|
|
82
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* hoo-discord-bot: talk to hoocode from Discord.
|
|
4
|
+
*
|
|
5
|
+
* - Mention the bot in a channel → it opens a thread and answers there.
|
|
6
|
+
* - Every message in that thread goes to the same hoocode session.
|
|
7
|
+
* - Commands inside a thread: !stop !new !status !model <pattern> !help
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
ChannelType,
|
|
11
|
+
Client,
|
|
12
|
+
Events,
|
|
13
|
+
GatewayIntentBits,
|
|
14
|
+
Partials,
|
|
15
|
+
type Message,
|
|
16
|
+
type ThreadChannel,
|
|
17
|
+
} from "discord.js";
|
|
18
|
+
import { config, prepareWorkspace } from "./config.ts";
|
|
19
|
+
import { CodexClient } from "./codex-client.ts";
|
|
20
|
+
import { LinkStore } from "./links.ts";
|
|
21
|
+
import { ThreadSession } from "./session.ts";
|
|
22
|
+
|
|
23
|
+
prepareWorkspace();
|
|
24
|
+
|
|
25
|
+
const links = new LinkStore(config.linksFile);
|
|
26
|
+
const sessions = new Map<string, ThreadSession>();
|
|
27
|
+
|
|
28
|
+
/** `unix://PATH`, or `hoocode app-server` over stdio in the work folder. */
|
|
29
|
+
function endpoint(): string {
|
|
30
|
+
if (config.appServer) return config.appServer;
|
|
31
|
+
return `stdio:${config.hoocodeBin} app-server ${config.hoocodeArgs.join(" ")}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let app: Promise<CodexClient> | null = null;
|
|
35
|
+
|
|
36
|
+
/** The shared app-server connection; reconnects after it drops. */
|
|
37
|
+
function appServer(): Promise<CodexClient> {
|
|
38
|
+
app ??= (async () => {
|
|
39
|
+
// A stdio server runs in the work folder; a socket server has its own.
|
|
40
|
+
const client = await CodexClient.connect(
|
|
41
|
+
endpoint(),
|
|
42
|
+
{ name: "hoobot", version: "0.1.0" },
|
|
43
|
+
{ cwd: config.workdir },
|
|
44
|
+
);
|
|
45
|
+
console.log(`Connected to app-server ${client.endpoint}`);
|
|
46
|
+
client.on("close", (reason: string) => {
|
|
47
|
+
console.error(`app-server connection closed: ${reason}`);
|
|
48
|
+
app = null;
|
|
49
|
+
// Sessions hold the old client; drop them. Threads resume on the next message.
|
|
50
|
+
for (const s of [...sessions.values()]) s.close();
|
|
51
|
+
});
|
|
52
|
+
return client;
|
|
53
|
+
})().catch((err) => {
|
|
54
|
+
console.error(`Can't reach app-server: ${err instanceof Error ? err.message : String(err)}`);
|
|
55
|
+
app = null;
|
|
56
|
+
throw err;
|
|
57
|
+
});
|
|
58
|
+
return app;
|
|
59
|
+
}
|
|
60
|
+
/** Threads this bot created, so it only listens in its own threads. */
|
|
61
|
+
const ownThreads = new Set<string>();
|
|
62
|
+
|
|
63
|
+
const client = new Client({
|
|
64
|
+
intents: [
|
|
65
|
+
GatewayIntentBits.Guilds,
|
|
66
|
+
GatewayIntentBits.GuildMessages,
|
|
67
|
+
GatewayIntentBits.MessageContent,
|
|
68
|
+
],
|
|
69
|
+
partials: [Partials.Channel],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const HELP = [
|
|
73
|
+
"**hoo: hoocode in Discord**",
|
|
74
|
+
"",
|
|
75
|
+
"**Start:** mention me with a request. I'll open a thread.",
|
|
76
|
+
"**Continue:** just type in the thread.",
|
|
77
|
+
"",
|
|
78
|
+
"**Commands (inside a thread)**",
|
|
79
|
+
"- `!stop`: stop the current run",
|
|
80
|
+
"- `!new`: forget this conversation, start fresh",
|
|
81
|
+
"- `!status`: model, busy or not, folder",
|
|
82
|
+
"- `!model <name>`: switch model from the next message, e.g. `!model anthropic/claude-sonnet-4-5`",
|
|
83
|
+
"- `!help`: this message",
|
|
84
|
+
"",
|
|
85
|
+
"Typing while I'm busy steers the current run.",
|
|
86
|
+
"Commands that change files or run shell ask for approval with buttons.",
|
|
87
|
+
].join("\n");
|
|
88
|
+
|
|
89
|
+
client.once(Events.ClientReady, (c) => {
|
|
90
|
+
console.log(`Logged in as ${c.user.tag}`);
|
|
91
|
+
console.log(`Working folder: ${config.workdir}`);
|
|
92
|
+
console.log(`Allowed users: ${[...config.allowedUserIds].join(", ")}`);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
client.on(Events.MessageCreate, async (message) => {
|
|
96
|
+
try {
|
|
97
|
+
await onMessage(message);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.error("message handler failed", err);
|
|
100
|
+
await message.reply("Something went wrong on my side. Check the bot's terminal.").catch(() => {});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
async function onMessage(message: Message) {
|
|
105
|
+
if (message.author.bot || !client.user) return;
|
|
106
|
+
if (config.guildId && message.guildId !== config.guildId) return;
|
|
107
|
+
|
|
108
|
+
const channel = message.channel;
|
|
109
|
+
const inOwnThread = channel.isThread() && isOwnThread(channel);
|
|
110
|
+
// Discord's autocomplete often picks the bot's auto-created *role* (also
|
|
111
|
+
// named after the bot) instead of the bot user, so accept either.
|
|
112
|
+
const botRoleId = message.guild?.members.me?.roles.botRole?.id;
|
|
113
|
+
const mentioned =
|
|
114
|
+
message.mentions.users.has(client.user.id) ||
|
|
115
|
+
(!!botRoleId && message.mentions.roles.has(botRoleId));
|
|
116
|
+
|
|
117
|
+
if (!inOwnThread && !mentioned) return;
|
|
118
|
+
console.log(`[msg] ${message.author.username} (${message.author.id}) in ${channel.id}: ${message.content.slice(0, 80)}`);
|
|
119
|
+
|
|
120
|
+
const parentId = channel.isThread() ? channel.parentId : channel.id;
|
|
121
|
+
if (config.channelIds.size && (!parentId || !config.channelIds.has(parentId))) return;
|
|
122
|
+
|
|
123
|
+
if (!config.allowedUserIds.has(message.author.id)) {
|
|
124
|
+
if (mentioned) await message.reply("Sorry, you're not on this bot's allow list.");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const text = message.content
|
|
129
|
+
.replace(new RegExp(`<@!?${client.user.id}>`, "g"), "")
|
|
130
|
+
.replace(botRoleId ? new RegExp(`<@&${botRoleId}>`, "g") : /$^/, "")
|
|
131
|
+
.trim();
|
|
132
|
+
const images = await imageAttachments(message);
|
|
133
|
+
|
|
134
|
+
if (!text && images.length === 0) {
|
|
135
|
+
await message.reply(HELP);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Resolve (or create) the thread for this conversation.
|
|
140
|
+
let thread: ThreadChannel;
|
|
141
|
+
if (channel.isThread()) {
|
|
142
|
+
thread = channel;
|
|
143
|
+
ownThreads.add(thread.id);
|
|
144
|
+
} else if (channel.type === ChannelType.GuildText) {
|
|
145
|
+
thread = await message.startThread({
|
|
146
|
+
name: (text || "hoocode").replace(/\s+/g, " ").slice(0, 90),
|
|
147
|
+
autoArchiveDuration: 1440,
|
|
148
|
+
});
|
|
149
|
+
ownThreads.add(thread.id);
|
|
150
|
+
} else {
|
|
151
|
+
await message.reply("Mention me in a normal text channel and I'll open a thread.");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Commands.
|
|
156
|
+
const [cmd, ...rest] = text.split(/\s+/);
|
|
157
|
+
const arg = rest.join(" ").trim();
|
|
158
|
+
if (cmd?.toLowerCase() === "!help") {
|
|
159
|
+
await thread.send(HELP);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
let session: ThreadSession;
|
|
163
|
+
try {
|
|
164
|
+
session = await getSession(thread);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
await thread.send(`**Can't reach hoocode:** ${err instanceof Error ? err.message : String(err)}`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
switch (cmd?.toLowerCase()) {
|
|
170
|
+
case "!stop":
|
|
171
|
+
await session.abort();
|
|
172
|
+
return;
|
|
173
|
+
case "!new":
|
|
174
|
+
await session.newSession();
|
|
175
|
+
return;
|
|
176
|
+
case "!status":
|
|
177
|
+
await session.status();
|
|
178
|
+
return;
|
|
179
|
+
case "!model":
|
|
180
|
+
if (!arg) await thread.send("Usage: `!model provider/model-id`");
|
|
181
|
+
else await session.setModel(arg);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await session.prompt(text || "(see attached image)", images);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function getSession(thread: ThreadChannel): Promise<ThreadSession> {
|
|
189
|
+
const client = await appServer();
|
|
190
|
+
let s = sessions.get(thread.id);
|
|
191
|
+
if (!s) {
|
|
192
|
+
s = new ThreadSession(thread, client, links, (id) => sessions.delete(id));
|
|
193
|
+
sessions.set(thread.id, s);
|
|
194
|
+
}
|
|
195
|
+
return s;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A thread counts as ours if we created it or already hold a session for it. */
|
|
199
|
+
function isOwnThread(thread: ThreadChannel): boolean {
|
|
200
|
+
return ownThreads.has(thread.id) || sessions.has(thread.id) || thread.ownerId === client.user?.id;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function imageAttachments(message: Message) {
|
|
204
|
+
const out: { data: string; mimeType: string }[] = [];
|
|
205
|
+
for (const att of message.attachments.values()) {
|
|
206
|
+
const mime = att.contentType?.split(";")[0] ?? "";
|
|
207
|
+
if (!mime.startsWith("image/") || att.size > 5_000_000) continue;
|
|
208
|
+
const res = await fetch(att.url);
|
|
209
|
+
if (!res.ok) continue;
|
|
210
|
+
out.push({ data: Buffer.from(await res.arrayBuffer()).toString("base64"), mimeType: mime });
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function shutdown() {
|
|
216
|
+
console.log("Shutting down…");
|
|
217
|
+
for (const s of [...sessions.values()]) s.close();
|
|
218
|
+
app?.then((c) => c.close()).catch(() => {});
|
|
219
|
+
client.destroy().finally(() => process.exit(0));
|
|
220
|
+
}
|
|
221
|
+
process.on("SIGINT", shutdown);
|
|
222
|
+
process.on("SIGTERM", shutdown);
|
|
223
|
+
|
|
224
|
+
client.login(config.token);
|
package/src/links.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface thread → app-server thread links (design doc 13). hoocode never
|
|
3
|
+
* learns about Discord; this small JSON file is the only place that does.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
|
|
8
|
+
export type Link = { threadId: string };
|
|
9
|
+
|
|
10
|
+
export class LinkStore {
|
|
11
|
+
private links: Record<string, Link> = {};
|
|
12
|
+
|
|
13
|
+
constructor(private readonly path: string) {
|
|
14
|
+
if (existsSync(path)) {
|
|
15
|
+
try {
|
|
16
|
+
this.links = JSON.parse(readFileSync(path, "utf8"));
|
|
17
|
+
} catch (err) {
|
|
18
|
+
console.error(`Could not read ${path}; starting with no links`, err);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get(key: string): Link | undefined {
|
|
24
|
+
return this.links[key];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
set(key: string, link: Link) {
|
|
28
|
+
this.links[key] = link;
|
|
29
|
+
this.save();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
delete(key: string) {
|
|
33
|
+
delete this.links[key];
|
|
34
|
+
this.save();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private save() {
|
|
38
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
39
|
+
const tmp = `${this.path}.tmp`;
|
|
40
|
+
writeFileSync(tmp, JSON.stringify(this.links, null, 2) + "\n");
|
|
41
|
+
renameSync(tmp, this.path);
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One Discord thread ⇄ one app-server thread. Translates Codex app-server
|
|
3
|
+
* notifications into Discord messages and approval requests into buttons.
|
|
4
|
+
* Uses only standard Codex methods, so it works against `hoocode app-server`
|
|
5
|
+
* and the real `codex app-server` alike.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
ActionRowBuilder,
|
|
9
|
+
ButtonBuilder,
|
|
10
|
+
ButtonStyle,
|
|
11
|
+
ComponentType,
|
|
12
|
+
type Message,
|
|
13
|
+
} from "discord.js";
|
|
14
|
+
import { config } from "./config.ts";
|
|
15
|
+
import { code, describeTool, splitMessage, truncate } from "./format.ts";
|
|
16
|
+
import { CodexClient, RpcError, userInput, type Notification, type RequestId, type ServerRequest } from "./codex-client.ts";
|
|
17
|
+
import type { LinkStore } from "./links.ts";
|
|
18
|
+
|
|
19
|
+
/** The bits of a Discord thread this file uses (tests pass a fake). */
|
|
20
|
+
export interface ThreadLike {
|
|
21
|
+
id: string;
|
|
22
|
+
send(content: any): Promise<any>;
|
|
23
|
+
sendTyping(): Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type ToolLine = { id: string; text: string; state: "running" | "ok" | "error" | "declined" };
|
|
27
|
+
|
|
28
|
+
type Approval = { msg: Message | null; title: string; resolved: boolean };
|
|
29
|
+
|
|
30
|
+
export class ThreadSession {
|
|
31
|
+
private threadId: string | null = null;
|
|
32
|
+
private turnId: string | null = null;
|
|
33
|
+
private tools: ToolLine[] = [];
|
|
34
|
+
private progressMsg: Message | null = null;
|
|
35
|
+
private progressDirty = false;
|
|
36
|
+
private progressTimer: ReturnType<typeof setInterval>;
|
|
37
|
+
private typingTimer: ReturnType<typeof setInterval> | null = null;
|
|
38
|
+
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
39
|
+
private approvals = new Map<RequestId, Approval>();
|
|
40
|
+
private model: string | null = null;
|
|
41
|
+
/** Serialises Discord sends so replies stay in order. */
|
|
42
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
43
|
+
private ready: Promise<void> | null = null;
|
|
44
|
+
private closed = false;
|
|
45
|
+
|
|
46
|
+
constructor(
|
|
47
|
+
readonly thread: ThreadLike,
|
|
48
|
+
private readonly client: CodexClient,
|
|
49
|
+
private readonly links: LinkStore,
|
|
50
|
+
private onClose: (threadId: string) => void,
|
|
51
|
+
) {
|
|
52
|
+
client.on("notification", this.onNotification);
|
|
53
|
+
client.on("request", this.onRequest);
|
|
54
|
+
this.progressTimer = setInterval(() => this.flushProgress(), 1500);
|
|
55
|
+
this.touch();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private get linkKey() {
|
|
59
|
+
return `discord:${this.thread.id}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get busy() {
|
|
63
|
+
return this.turnId !== null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Resume the linked thread, or start a new one. Idempotent. */
|
|
67
|
+
private ensureThread(): Promise<void> {
|
|
68
|
+
this.ready ??= (async () => {
|
|
69
|
+
const link = this.links.get(this.linkKey);
|
|
70
|
+
if (link) {
|
|
71
|
+
try {
|
|
72
|
+
const res = await this.client.request("thread/resume", { threadId: link.threadId });
|
|
73
|
+
this.adopt(res);
|
|
74
|
+
return;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error(`[${this.thread.id}] resume ${link.threadId} failed; starting fresh`, err);
|
|
77
|
+
await this.post(`Couldn't reopen the earlier conversation (${code(errorText(err))}). Starting a new one.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
await this.startThread();
|
|
81
|
+
})().catch((err) => {
|
|
82
|
+
this.ready = null;
|
|
83
|
+
throw err;
|
|
84
|
+
});
|
|
85
|
+
return this.ready;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async startThread() {
|
|
89
|
+
const params: Record<string, unknown> = {};
|
|
90
|
+
if (config.model) params.model = config.model;
|
|
91
|
+
const res = await this.client.request("thread/start", params);
|
|
92
|
+
this.adopt(res);
|
|
93
|
+
this.links.set(this.linkKey, { threadId: res.thread.id });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private adopt(res: any) {
|
|
97
|
+
this.threadId = res.thread.id;
|
|
98
|
+
this.model = res.model ?? null;
|
|
99
|
+
// A turn still running on the server (e.g. after a bot restart).
|
|
100
|
+
const running = (res.thread.turns ?? []).findLast?.((t: any) => t.status === "inProgress");
|
|
101
|
+
this.turnId = running?.id ?? null;
|
|
102
|
+
if (this.turnId) this.startTyping();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Input from Discord ─────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
async prompt(text: string, images: { data: string; mimeType: string }[] = []) {
|
|
108
|
+
this.touch();
|
|
109
|
+
await this.ensureThread();
|
|
110
|
+
const input = userInput(text, images);
|
|
111
|
+
// One turn at a time: while one runs, the message steers it.
|
|
112
|
+
if (this.turnId) {
|
|
113
|
+
try {
|
|
114
|
+
await this.client.request("turn/steer", { threadId: this.threadId, input, expectedTurnId: this.turnId });
|
|
115
|
+
await this.post("Queued. It'll be read after the current step.");
|
|
116
|
+
return;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
// The turn ended in the meantime: start a new one below.
|
|
119
|
+
if (!(err instanceof RpcError)) throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const params: Record<string, unknown> = { threadId: this.threadId, input };
|
|
124
|
+
if (this.pendingModel) params.model = this.pendingModel;
|
|
125
|
+
const res = await this.client.request("turn/start", params);
|
|
126
|
+
if (this.pendingModel) {
|
|
127
|
+
this.model = this.pendingModel;
|
|
128
|
+
this.pendingModel = null;
|
|
129
|
+
}
|
|
130
|
+
this.beginTurn(res.turn.id);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
await this.post(`**Not sent:** ${errorText(err)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async abort() {
|
|
137
|
+
if (!this.turnId || !this.threadId) {
|
|
138
|
+
await this.post("Nothing is running.");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
await this.client.request("turn/interrupt", { threadId: this.threadId, turnId: this.turnId });
|
|
143
|
+
await this.post("**Stopped.**");
|
|
144
|
+
} catch (err) {
|
|
145
|
+
await this.post(`**Could not stop:** ${errorText(err)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async newSession() {
|
|
150
|
+
if (this.turnId && this.threadId) {
|
|
151
|
+
await this.client.request("turn/interrupt", { threadId: this.threadId, turnId: this.turnId }).catch(() => {});
|
|
152
|
+
}
|
|
153
|
+
if (this.threadId) await this.client.request("thread/unsubscribe", { threadId: this.threadId }).catch(() => {});
|
|
154
|
+
this.turnId = null;
|
|
155
|
+
this.ready = this.startThread();
|
|
156
|
+
try {
|
|
157
|
+
await this.ready;
|
|
158
|
+
await this.post("**New session.** Earlier messages are forgotten.");
|
|
159
|
+
} catch (err) {
|
|
160
|
+
this.ready = null;
|
|
161
|
+
await this.post(`**Failed:** ${errorText(err)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async status() {
|
|
166
|
+
await this.ensureThread();
|
|
167
|
+
const lines = [
|
|
168
|
+
"**Status**",
|
|
169
|
+
`- Model: ${code(this.pendingModel ?? this.model ?? "default")}`,
|
|
170
|
+
`- Busy: ${this.turnId ? "yes" : "no"}`,
|
|
171
|
+
`- Thread: ${code(this.threadId ?? "none")}`,
|
|
172
|
+
`- Server: ${code(this.client.endpoint)}`,
|
|
173
|
+
];
|
|
174
|
+
await this.post(lines.join("\n"));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private pendingModel: string | null = null;
|
|
178
|
+
|
|
179
|
+
/** Takes effect on the next turn (`turn/start` `model`). */
|
|
180
|
+
async setModel(name: string) {
|
|
181
|
+
this.pendingModel = name;
|
|
182
|
+
await this.post(`**Model:** ${code(name)} from the next message.`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
close() {
|
|
186
|
+
if (this.closed) return;
|
|
187
|
+
this.closed = true;
|
|
188
|
+
if (this.threadId) this.client.request("thread/unsubscribe", { threadId: this.threadId }).catch(() => {});
|
|
189
|
+
this.dispose();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Output to Discord ──────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
private beginTurn(turnId: string) {
|
|
195
|
+
if (this.turnId === turnId) return;
|
|
196
|
+
this.turnId = turnId;
|
|
197
|
+
this.tools = [];
|
|
198
|
+
this.progressMsg = null;
|
|
199
|
+
this.startTyping();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private onNotification = (n: Notification) => {
|
|
203
|
+
const p = n.params ?? {};
|
|
204
|
+
if (p.threadId !== undefined && p.threadId !== this.threadId) return;
|
|
205
|
+
if (n.method === "thread/started" && p.thread?.id !== this.threadId) return;
|
|
206
|
+
this.handleNotification(n).catch((err) => console.error(`[${this.thread.id}] event error`, err));
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
private async handleNotification({ method, params: p }: Notification) {
|
|
210
|
+
switch (method) {
|
|
211
|
+
case "turn/started":
|
|
212
|
+
this.beginTurn(p.turn.id);
|
|
213
|
+
break;
|
|
214
|
+
|
|
215
|
+
case "turn/completed": {
|
|
216
|
+
if (p.turn.id !== this.turnId) break;
|
|
217
|
+
this.turnId = null;
|
|
218
|
+
this.stopTyping();
|
|
219
|
+
await this.flushProgress();
|
|
220
|
+
if (p.turn.status === "failed" && p.turn.error?.message) {
|
|
221
|
+
await this.post(`**Error:**\n${"```"}\n${truncate(p.turn.error.message, 1500)}\n${"```"}`);
|
|
222
|
+
}
|
|
223
|
+
this.touch();
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
case "item/started": {
|
|
228
|
+
const tool = toolLine(p.item);
|
|
229
|
+
if (tool) {
|
|
230
|
+
this.tools.push(tool);
|
|
231
|
+
this.progressDirty = true;
|
|
232
|
+
}
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
case "item/completed": {
|
|
237
|
+
const item = p.item;
|
|
238
|
+
if (item.type === "agentMessage" && item.text?.trim()) {
|
|
239
|
+
// Text closes the current tool list; the next tools start a fresh one.
|
|
240
|
+
await this.flushProgress();
|
|
241
|
+
this.progressMsg = null;
|
|
242
|
+
this.tools = [];
|
|
243
|
+
for (const chunk of splitMessage(item.text)) await this.post(chunk);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
const t = this.tools.find((x) => x.id === item.id);
|
|
247
|
+
if (t) {
|
|
248
|
+
t.state = item.status === "declined" ? "declined" : item.status === "failed" ? "error" : "ok";
|
|
249
|
+
this.progressDirty = true;
|
|
250
|
+
}
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
case "serverRequest/resolved":
|
|
255
|
+
await this.resolveApproval(p.requestId, null);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Tool activity as one message, edited in place (throttled). */
|
|
261
|
+
private async flushProgress() {
|
|
262
|
+
if (!this.progressDirty || this.tools.length === 0) return;
|
|
263
|
+
this.progressDirty = false;
|
|
264
|
+
// Words, not just symbols, so state doesn't rely on colour or icon shape.
|
|
265
|
+
const label = { running: "⏳ running", ok: "✅ done", error: "❌ failed", declined: "🚫 denied" } as const;
|
|
266
|
+
const lines = this.tools.slice(-15).map((t) => `${label[t.state]} · ${t.text}`);
|
|
267
|
+
const hidden = this.tools.length - 15;
|
|
268
|
+
if (hidden > 0) lines.unshift(`*…${hidden} earlier steps*`);
|
|
269
|
+
const body = lines.join("\n").slice(-1900);
|
|
270
|
+
await this.enqueue(async () => {
|
|
271
|
+
if (this.progressMsg) await this.progressMsg.edit(body).catch(() => {});
|
|
272
|
+
else this.progressMsg = await this.thread.send(body);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Approvals ──────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
private onRequest = (r: ServerRequest) => {
|
|
279
|
+
if (r.params?.threadId !== this.threadId) return;
|
|
280
|
+
this.handleRequest(r).catch((err) => console.error(`[${this.thread.id}] request error`, err));
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
private async handleRequest(r: ServerRequest) {
|
|
284
|
+
if (r.method !== "item/commandExecution/requestApproval" && r.method !== "item/fileChange/requestApproval") {
|
|
285
|
+
// Not something Discord can answer; say no rather than leave it hanging.
|
|
286
|
+
this.client.respond(r.id, { decision: "decline" });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (this.approvals.has(r.id)) return;
|
|
290
|
+
const p = r.params;
|
|
291
|
+
const what =
|
|
292
|
+
r.method === "item/commandExecution/requestApproval"
|
|
293
|
+
? p.command
|
|
294
|
+
? `bash ${code(truncate(p.command, 300))}`
|
|
295
|
+
: (p.reason ?? "run a command")
|
|
296
|
+
: (p.reason?.replace(/^Allow:\s*/, "") ?? "change files");
|
|
297
|
+
const title = `**Approval needed**\n${what}`;
|
|
298
|
+
const approval: Approval = { msg: null, title, resolved: false };
|
|
299
|
+
this.approvals.set(r.id, approval);
|
|
300
|
+
|
|
301
|
+
// No "always" button: it would change hoocode's global config.
|
|
302
|
+
const options = [
|
|
303
|
+
{ label: "Allow once", decision: "accept", style: ButtonStyle.Success },
|
|
304
|
+
{ label: "Deny", decision: "decline", style: ButtonStyle.Danger },
|
|
305
|
+
];
|
|
306
|
+
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
307
|
+
options.map((o, i) => new ButtonBuilder().setCustomId(`ui:${i}`).setLabel(o.label).setStyle(o.style)),
|
|
308
|
+
);
|
|
309
|
+
const msg = (await this.enqueue(() => this.thread.send({ content: title.slice(0, 1800), components: [row] }))) as Message;
|
|
310
|
+
approval.msg = msg;
|
|
311
|
+
this.stopTyping();
|
|
312
|
+
if (approval.resolved) {
|
|
313
|
+
// Answered elsewhere while we were sending.
|
|
314
|
+
await this.markResolved(approval, "**Answered elsewhere.**");
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
const click = await msg.awaitMessageComponent({
|
|
319
|
+
componentType: ComponentType.Button,
|
|
320
|
+
time: config.approvalTimeoutMs,
|
|
321
|
+
filter: async (i) => {
|
|
322
|
+
if (config.allowedUserIds.has(i.user.id)) return true;
|
|
323
|
+
await i.reply({ content: "Only allowed users can answer this.", ephemeral: true }).catch(() => {});
|
|
324
|
+
return false;
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
if (approval.resolved) {
|
|
328
|
+
await click.update({ content: `${title.slice(0, 1800)}\n→ **Answered elsewhere.**`, components: [] }).catch(() => {});
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const chosen = options[Number(click.customId.split(":")[1])]!;
|
|
332
|
+
approval.resolved = true;
|
|
333
|
+
this.client.respond(r.id, { decision: chosen.decision });
|
|
334
|
+
await click.update({ content: `${title.slice(0, 1800)}\n→ **${chosen.label}** by ${click.user.username}`, components: [] });
|
|
335
|
+
} catch {
|
|
336
|
+
if (approval.resolved) return;
|
|
337
|
+
approval.resolved = true;
|
|
338
|
+
this.client.respond(r.id, { decision: "decline" });
|
|
339
|
+
await this.markResolved(approval, "**No answer, denied.**");
|
|
340
|
+
}
|
|
341
|
+
if (this.turnId) this.startTyping();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The server says request `id` is settled (answered by any client, or the turn ended). */
|
|
345
|
+
private async resolveApproval(id: RequestId, _: null) {
|
|
346
|
+
const approval = this.approvals.get(id);
|
|
347
|
+
if (!approval) return;
|
|
348
|
+
this.approvals.delete(id);
|
|
349
|
+
if (approval.resolved) return;
|
|
350
|
+
approval.resolved = true;
|
|
351
|
+
if (approval.msg) await this.markResolved(approval, "**Answered elsewhere.**");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private async markResolved(approval: Approval, note: string) {
|
|
355
|
+
await approval.msg?.edit({ content: `${approval.title.slice(0, 1800)}\n→ ${note}`, components: [] }).catch(() => {});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
private post(text: string) {
|
|
361
|
+
if (!text.trim()) return Promise.resolve();
|
|
362
|
+
return this.enqueue(() => this.thread.send(text)).catch((err) => console.error(`[${this.thread.id}] send failed`, err));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
|
366
|
+
const next = this.queue.then(fn, fn);
|
|
367
|
+
this.queue = next.catch(() => {});
|
|
368
|
+
return next;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private startTyping() {
|
|
372
|
+
if (this.typingTimer) return;
|
|
373
|
+
this.thread.sendTyping().catch(() => {});
|
|
374
|
+
this.typingTimer = setInterval(() => this.thread.sendTyping().catch(() => {}), 8000);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private stopTyping() {
|
|
378
|
+
if (this.typingTimer) clearInterval(this.typingTimer);
|
|
379
|
+
this.typingTimer = null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Let go of idle threads; the server keeps them on disk. */
|
|
383
|
+
private touch() {
|
|
384
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
385
|
+
this.idleTimer = setTimeout(() => {
|
|
386
|
+
if (!this.turnId && this.approvals.size === 0) this.close();
|
|
387
|
+
else this.touch();
|
|
388
|
+
}, config.idleTimeoutMs);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private dispose() {
|
|
392
|
+
clearInterval(this.progressTimer);
|
|
393
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
394
|
+
this.stopTyping();
|
|
395
|
+
this.client.off("notification", this.onNotification);
|
|
396
|
+
this.client.off("request", this.onRequest);
|
|
397
|
+
this.onClose(this.thread.id);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** A progress line for a tool-ish item, or null for messages. */
|
|
402
|
+
function toolLine(item: any): ToolLine | null {
|
|
403
|
+
switch (item?.type) {
|
|
404
|
+
case "commandExecution":
|
|
405
|
+
return { id: item.id, text: describeTool("bash", { command: item.command }), state: "running" };
|
|
406
|
+
case "fileChange": {
|
|
407
|
+
const paths = (item.changes ?? []).map((c: any) => c.path).join(", ");
|
|
408
|
+
return { id: item.id, text: `edit ${code(truncate(paths || "?", 120))}`, state: "running" };
|
|
409
|
+
}
|
|
410
|
+
case "dynamicToolCall":
|
|
411
|
+
return { id: item.id, text: describeTool(item.tool, item.arguments ?? {}), state: "running" };
|
|
412
|
+
case "mcpToolCall":
|
|
413
|
+
return { id: item.id, text: code(`${item.server}/${item.tool}`), state: "running" };
|
|
414
|
+
case "webSearch":
|
|
415
|
+
return { id: item.id, text: `web search ${code(truncate(String(item.query ?? ""), 100))}`, state: "running" };
|
|
416
|
+
default:
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function errorText(err: unknown): string {
|
|
422
|
+
return err instanceof Error ? err.message : String(err);
|
|
423
|
+
}
|