ework-daemon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/bin/ework-daemon-server.js +2 -0
- package/package.json +65 -0
- package/scripts/ework-daemon.service +22 -0
- package/src/cli.ts +218 -0
- package/src/config.ts +134 -0
- package/src/gitea.ts +137 -0
- package/src/index.ts +48 -0
- package/src/logger.ts +64 -0
- package/src/op.ts +486 -0
- package/src/opencode.ts +1219 -0
- package/src/server.ts +160 -0
- package/src/trackers/gitea-tracker.ts +181 -0
- package/src/trackers/types.ts +155 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dog and contributors
|
|
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,262 @@
|
|
|
1
|
+
# ework-daemon
|
|
2
|
+
|
|
3
|
+
Issue-Driven AI Development Bridge. Connects an issue tracker (the [ework-web](../ework) SQLite shim, or real Gitea) to [OpenCode](https://github.com/sst/opencode) AI agents.
|
|
4
|
+
|
|
5
|
+
> Issue-driven AI development daemon. Listens on `:3101` by default; SQLite at `~/.local/share/ework-daemon/ework-daemon.db`; systemd unit `ework-daemon.service`.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
You file an issue → the daemon spawns an OpenCode subprocess to handle it → the AI autonomously reads, replies, and closes via the `opencode-ework` plugin's tools (`issue`, `comments`, `floor`, `reply`, `search`). New user comments are forwarded to the running session. Stuck sessions (30 min no output) get a nudge.
|
|
10
|
+
|
|
11
|
+
**The daemon only does 4 things:**
|
|
12
|
+
|
|
13
|
+
1. **New issue** → auto-spawn OpenCode.
|
|
14
|
+
2. **`@<bot>` mention** on existing issue → trigger a new or existing session.
|
|
15
|
+
3. **Existing session + new user comment** → forward to OpenCode (wrapped as a system message).
|
|
16
|
+
4. **Stuck detection** → 30 min no stdout → stuck alert.
|
|
17
|
+
|
|
18
|
+
It does **not** post AI replies, auto-commit, or auto-merge. The AI does all issue interaction itself via the plugin.
|
|
19
|
+
|
|
20
|
+
## Architecture
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
ework-web (:3002) ework-daemon (:3101) OpenCode (subprocess)
|
|
24
|
+
│ │ │
|
|
25
|
+
│ ── issue webhook ────────────▸ │ │
|
|
26
|
+
│ │ ── Bun.spawn ──────────────▸ │
|
|
27
|
+
│ │ │
|
|
28
|
+
│ ◂── plugin GET /issues/n ──────┼───────────────────────────────┤
|
|
29
|
+
│ ◂── plugin POST /comments ─────┼───────────────────────────────┤
|
|
30
|
+
│ │ ◂── stdout streaming ─────────┤
|
|
31
|
+
│ ── new-comment webhook ─────▸ │ │
|
|
32
|
+
│ │ ── stdin (wrapped) ────────▸ │
|
|
33
|
+
│ │ ◂── completion signal ───────┤
|
|
34
|
+
│ ◂── plugin POST /close ────────┼───────────────────────────────┤
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The OpenCode subprocess loads the [`opencode-ework`](../opencode-ework) plugin, which speaks Gitea REST. The daemon injects `GITEA_URL`, `GITEA_TOKEN`, `BOT_TOKEN`, `BOT_USERNAME` env vars at spawn, so the plugin picks them up automatically. The plugin is backend-agnostic — same plugin works against real Gitea or any Gitea-compatible shim.
|
|
38
|
+
|
|
39
|
+
## Prerequisites
|
|
40
|
+
|
|
41
|
+
- [Bun](https://bun.sh/) runtime (uses `Bun.serve`, `Bun.spawn`, `bun:sqlite` — **Node.js not supported**)
|
|
42
|
+
- [OpenCode](https://github.com/sst/opencode) binary
|
|
43
|
+
- A running [ework-web](../ework) instance (or real Gitea)
|
|
44
|
+
- A bot user PAT on that instance with comment/write permissions
|
|
45
|
+
|
|
46
|
+
The `tea` CLI is **no longer required** — the OpenCode subprocess uses the `opencode-ework` plugin's `reply` tool (HTTP `POST` with `BOT_TOKEN`), not `tea comment`. The `scripts/setup.ts` wizard still configures tea as a convenience for ad-hoc admin tasks; this will be removed in a future cleanup.
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
### 1. Install Bun
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
curl -fsSL https://bun.sh/install | bash
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 2. Clone + setup
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/ranxianglei/ework-daemon.git
|
|
60
|
+
cd ework-daemon
|
|
61
|
+
bun install
|
|
62
|
+
bun run setup # interactive wizard: checks deps, creates data dir, writes .env
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 3. Configure
|
|
66
|
+
|
|
67
|
+
Edit `.env` (created from `.env.example` by the wizard):
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
$EDITOR .env
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Required values:
|
|
74
|
+
|
|
75
|
+
- `GITEA_URL` — your ework-web (e.g. `http://localhost:3002`) or real Gitea URL
|
|
76
|
+
- `GITEA_TOKEN` — admin-scope read token
|
|
77
|
+
- `BOT_USERNAME` — bot user login (must match the user `BOT_TOKEN` belongs to)
|
|
78
|
+
- `BOT_TOKEN` — bot user PAT (used by the plugin to post replies)
|
|
79
|
+
- `GITEA_WEBHOOK_SECRET` — HMAC secret shared with the issue tracker's webhook config
|
|
80
|
+
- `DAEMON_ENV=production` — must be set to enable production mode (else falls back to test)
|
|
81
|
+
- `OPENCODE_BASE_WORKDIR` — parent directory for opencode work dirs (e.g. `/home/<user>/projects`)
|
|
82
|
+
|
|
83
|
+
### 4. Run
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# Test mode (.env.test)
|
|
87
|
+
bun start
|
|
88
|
+
|
|
89
|
+
# Production (.env)
|
|
90
|
+
bun start:prod
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 5. Configure the webhook
|
|
94
|
+
|
|
95
|
+
On ework-web (or Gitea), go to the repository's **Settings → Webhooks → Add Webhook → Gitea**:
|
|
96
|
+
|
|
97
|
+
| Field | Value |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| Target URL | `http://<daemon-ip>:3101/webhook` |
|
|
100
|
+
| Content type | `application/json` |
|
|
101
|
+
| Secret | (the `GITEA_WEBHOOK_SECRET` from `.env`) |
|
|
102
|
+
| Trigger events | Issues, Issue Comment |
|
|
103
|
+
|
|
104
|
+
## Usage
|
|
105
|
+
|
|
106
|
+
### New issue → auto pickup
|
|
107
|
+
|
|
108
|
+
Create an issue. The daemon replies "🔄 picked up" and spawns an OpenCode session. The AI reads the issue via the plugin's `issue` tool and works autonomously.
|
|
109
|
+
|
|
110
|
+
### Existing issue → trigger via mention
|
|
111
|
+
|
|
112
|
+
Comment with `@<bot>` (where `<bot>` is your configured `BOT_USERNAME`, e.g. `ework-daemon`):
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
@ework-daemon this issue still needs work
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Ongoing session → add instructions
|
|
119
|
+
|
|
120
|
+
Once a session is running, just comment (no mention needed):
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
Also add a "remember me" checkbox
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The daemon wraps the comment as a system message and pipes it to the running opencode session's stdin. The AI replies via the plugin's `reply` tool.
|
|
127
|
+
|
|
128
|
+
### Close → stop
|
|
129
|
+
|
|
130
|
+
Closing the issue terminates the OpenCode subprocess and cleans up the session.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
### Watch mode
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
bun dev # test mode (.env.test) — auto-restart on file changes
|
|
138
|
+
bun dev:prod # production config (.env)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Type check
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
bun run check # tsc --noEmit
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Compile standalone binary
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
bun run build # → ./ework-daemon
|
|
151
|
+
bun run install-global # build + cp to ~/.local/bin/
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The standalone binary can run without source: `ework-daemon status`, `ework-daemon issues`, etc. (see `src/cli.ts` for the CLI surface).
|
|
155
|
+
|
|
156
|
+
### Smoke tests
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
bun run test:gitea # ework-web / Gitea connectivity check
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
End-to-end tests live at `scripts/test-e2e.ts` — run manually (not yet in `bun test` format).
|
|
163
|
+
|
|
164
|
+
## Production deployment (systemd)
|
|
165
|
+
|
|
166
|
+
A systemd unit template is provided at `scripts/ework-daemon.service`. After running `./scripts/deploy.sh`:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
sudo cp scripts/ework-daemon.service /etc/systemd/system/
|
|
170
|
+
sudo systemctl daemon-reload
|
|
171
|
+
sudo systemctl enable --now ework-daemon
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Management commands:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
sudo systemctl status ework-daemon
|
|
178
|
+
sudo journalctl -u ework-daemon -f
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Restart safety**: restarting kills all running OpenCode subprocesses. Always check first:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
curl -s http://localhost:3101/api/processes
|
|
185
|
+
# non-empty → wait; empty → safe to restart
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Deploy workflow
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
cd /path/to/ework-daemon
|
|
192
|
+
bun run check && git add -A && git commit -m "..."
|
|
193
|
+
./scripts/deploy.sh
|
|
194
|
+
# (verify no processes running) then:
|
|
195
|
+
sudo systemctl restart ework-daemon
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Environment variables
|
|
199
|
+
|
|
200
|
+
| Variable | Required | Default | Purpose |
|
|
201
|
+
| --- | --- | --- | --- |
|
|
202
|
+
| `DAEMON_ENV` | ✅ | `test` | `production` to use `.env` values |
|
|
203
|
+
| `GITEA_URL` | ✅ | — | ework-web or real Gitea base URL |
|
|
204
|
+
| `GITEA_TOKEN` | ✅ | — | Admin-scope read token |
|
|
205
|
+
| `GITEA_WEBHOOK_SECRET` | ✅ | — | HMAC secret for webhook verification |
|
|
206
|
+
| `BOT_USERNAME` | ✅ | — | Bot user login (anti-recursion check) |
|
|
207
|
+
| `BOT_TOKEN` | ✅ | — | Bot user PAT (passed to opencode subprocess) |
|
|
208
|
+
| `DAEMON_PORT` | — | `3101` | Listen port |
|
|
209
|
+
| `DAEMON_HOST` | — | `0.0.0.0` | Bind host (use `127.0.0.1` for local-only) |
|
|
210
|
+
| `OPENCODE_BINARY` | — | `opencode` | OpenCode binary path |
|
|
211
|
+
| `OPENCODE_BASE_WORKDIR` | ✅ | — | Parent dir for opencode work dirs |
|
|
212
|
+
| `COMPLETION_CHECK_API_KEY` | — | — | Optional: model API key for completion judgment |
|
|
213
|
+
| `COMPLETION_CHECK_BASE_URL` | — | — | Optional: model API base URL |
|
|
214
|
+
| `COMPLETION_CHECK_MODEL` | — | — | Optional: model name |
|
|
215
|
+
| `DAEMON_STUCK_THRESHOLD_MS` | — | `1800000` | Stuck threshold (30 min) |
|
|
216
|
+
| `DAEMON_MAX_STUCK_NUDGES` | — | `1` | Max stuck nudges before giving up |
|
|
217
|
+
|
|
218
|
+
Full list in `.env.example` and `src/config.ts`.
|
|
219
|
+
|
|
220
|
+
## Data storage
|
|
221
|
+
|
|
222
|
+
| Data | Path |
|
|
223
|
+
| --- | --- |
|
|
224
|
+
| Production DB | `~/.local/share/ework-daemon/ework-daemon.db` |
|
|
225
|
+
| Test DB | `test/ework-daemon-test.db` (gitignored) |
|
|
226
|
+
| Large-message archive | `<workdir>/.ework-daemon/comment-NNN.txt` |
|
|
227
|
+
| Code repo work dirs | `$OPENCODE_BASE_WORKDIR/<repo>/` |
|
|
228
|
+
|
|
229
|
+
Honors `XDG_DATA_HOME`.
|
|
230
|
+
|
|
231
|
+
## Anti-recursion
|
|
232
|
+
|
|
233
|
+
- The OpenCode subprocess posts comments via the `opencode-ework` plugin's `reply` tool, using `BOT_TOKEN` (which posts as user `BOT_USERNAME`).
|
|
234
|
+
- The daemon's webhook handler drops any webhook whose `comment.user.login === BOT_USERNAME`. **Critical**: `BOT_USERNAME` must exactly match the user that `BOT_TOKEN` belongs to, or this check fails and the daemon recurses into itself.
|
|
235
|
+
- Same-issue concurrent requests are serialized through an in-memory queue.
|
|
236
|
+
|
|
237
|
+
## Project structure
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
src/
|
|
241
|
+
├── config.ts # Zod env validation (test/production dual mode)
|
|
242
|
+
├── gitea.ts # Gitea-compatible REST client (admin read + bot write)
|
|
243
|
+
├── server.ts # Webhook HTTP service (HMAC-SHA256)
|
|
244
|
+
├── opencode.ts # OpenCode process management (queue, streaming, stuck detection)
|
|
245
|
+
├── op.ts # SQLite persistence (Issue / OpSession / Message)
|
|
246
|
+
├── cli.ts # CLI client
|
|
247
|
+
├── index.ts # Entry
|
|
248
|
+
└── trackers/
|
|
249
|
+
├── types.ts # IssueTracker interface + 3-entity model
|
|
250
|
+
└── gitea-tracker.ts # Gitea implementation (ework-web or real Gitea)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
See [AGENTS.md](./AGENTS.md) for the full development spec — it is the highest-priority doc for contributors.
|
|
254
|
+
|
|
255
|
+
## Related projects
|
|
256
|
+
|
|
257
|
+
- [`ework`](../ework) — Standalone SQLite-backed issue tracker with Gitea-compatible REST shim. The default backend for ework-daemon.
|
|
258
|
+
- [`opencode-ework`](../opencode-ework) — Plugin loaded by the OpenCode subprocess. Provides `issue`, `comments`, `floor`, `reply`, `search` tools.
|
|
259
|
+
|
|
260
|
+
## License
|
|
261
|
+
|
|
262
|
+
MIT — see [LICENSE](./LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ework-daemon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Issue-driven AI development daemon. Spawns opencode subprocesses to resolve Gitea issues.",
|
|
5
|
+
"module": "src/index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "dog and contributors",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/ranxianglei/ework-daemon.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/ranxianglei/ework-daemon",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/ranxianglei/ework-daemon/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"issue-driven",
|
|
19
|
+
"ai-agent",
|
|
20
|
+
"opencode",
|
|
21
|
+
"gitea",
|
|
22
|
+
"automation",
|
|
23
|
+
"daemon",
|
|
24
|
+
"bridge"
|
|
25
|
+
],
|
|
26
|
+
"bin": {
|
|
27
|
+
"ework-daemon": "./src/cli.ts",
|
|
28
|
+
"ework-daemon-server": "./bin/ework-daemon-server.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"src",
|
|
32
|
+
"bin",
|
|
33
|
+
"scripts/ework-daemon.service",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"bun": ">=1.1.0"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"setup": "bun run scripts/setup.ts",
|
|
45
|
+
"start": "bun --env-file=.env.test run src/index.ts",
|
|
46
|
+
"start:prod": "bun --env-file=.env run src/index.ts",
|
|
47
|
+
"dev": "bun --env-file=.env.test --watch src/index.ts",
|
|
48
|
+
"dev:prod": "bun --env-file=.env --watch src/index.ts",
|
|
49
|
+
"check": "tsc --noEmit",
|
|
50
|
+
"typecheck": "tsc --noEmit",
|
|
51
|
+
"test": "bun test",
|
|
52
|
+
"test:gitea": "bun run scripts/test-gitea.ts",
|
|
53
|
+
"build": "bun build src/cli.ts --compile --outfile ework-daemon",
|
|
54
|
+
"install-global": "bun run build && cp ework-daemon ~/.local/bin/"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/bun": "latest"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"typescript": "^5"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"zod": "^4.4.3"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=ework-daemon — issue-driven AI dev daemon
|
|
3
|
+
After=network-online.target
|
|
4
|
+
Wants=network-online.target
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
Type=simple
|
|
8
|
+
User=ework
|
|
9
|
+
Group=ework
|
|
10
|
+
WorkingDirectory=%h/.local/share/ework-daemon
|
|
11
|
+
ExecStart=bun run src/index.ts
|
|
12
|
+
Restart=on-failure
|
|
13
|
+
RestartSec=5
|
|
14
|
+
KillMode=process
|
|
15
|
+
EnvironmentFile=%h/.local/share/ework-daemon/.env
|
|
16
|
+
Environment="PATH=/usr/local/bin:/usr/bin:/bin:%h/.local/bin:%h/.bun/bin"
|
|
17
|
+
StandardOutput=journal
|
|
18
|
+
StandardError=journal
|
|
19
|
+
SyslogIdentifier=ework-daemon
|
|
20
|
+
|
|
21
|
+
[Install]
|
|
22
|
+
WantedBy=multi-user.target
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
const BASE = process.env.EDAEMON_URL || "http://localhost:3101";
|
|
4
|
+
|
|
5
|
+
async function api(path: string, init?: RequestInit) {
|
|
6
|
+
const res = await fetch(`${BASE}${path}`, init);
|
|
7
|
+
if (!res.ok) {
|
|
8
|
+
const body = await res.text();
|
|
9
|
+
console.error(`Error ${res.status}: ${body}`);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
return res.json();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fmtTime(ts: number | null) {
|
|
16
|
+
if (!ts) return "-";
|
|
17
|
+
const d = Date.now() - ts;
|
|
18
|
+
if (d < 60_000) return `${Math.floor(d / 1000)}s ago`;
|
|
19
|
+
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
|
|
20
|
+
return `${Math.floor(d / 3_600_000)}h ago`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fmtState(s: string) {
|
|
24
|
+
const colors: Record<string, string> = {
|
|
25
|
+
active: "\x1b[32m",
|
|
26
|
+
idle: "\x1b[36m",
|
|
27
|
+
running: "\x1b[33m",
|
|
28
|
+
closed: "\x1b[90m",
|
|
29
|
+
created: "\x1b[34m",
|
|
30
|
+
};
|
|
31
|
+
return `${colors[s] || ""}${s}\x1b[0m`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Commands ───
|
|
35
|
+
|
|
36
|
+
async function cmdStatus() {
|
|
37
|
+
const data = (await api("/api/status")) as Record<string, unknown>;
|
|
38
|
+
console.log("Daemon: " + JSON.stringify(data.daemon));
|
|
39
|
+
console.log("Running: " + data.running);
|
|
40
|
+
console.log("Pending: " + data.pending);
|
|
41
|
+
console.log("Processes: " + data.processes);
|
|
42
|
+
console.log("Observed: " + data.observedIssues);
|
|
43
|
+
console.log("Issues: " + data.issues);
|
|
44
|
+
console.log("Sessions: " + data.sessions);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function cmdIssues() {
|
|
48
|
+
const issues = (await api("/api/issues")) as Record<string, unknown>[];
|
|
49
|
+
if (issues.length === 0) {
|
|
50
|
+
console.log("No issues.");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (const issue of issues) {
|
|
54
|
+
const scopeKey = issue.trackerScopeKey as string;
|
|
55
|
+
const issueId = issue.trackerIssueId as string;
|
|
56
|
+
const state = fmtState(issue.state as string);
|
|
57
|
+
console.log(` ${state} ${issue.trackerType}:${scopeKey}#${issueId} "${issue.title}" id=${issue.id}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function cmdSessions() {
|
|
62
|
+
const sessions = (await api("/api/sessions")) as Record<string, unknown>[];
|
|
63
|
+
if (sessions.length === 0) {
|
|
64
|
+
console.log("No sessions.");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
for (const s of sessions) {
|
|
68
|
+
const state = fmtState(s.state as string);
|
|
69
|
+
const pid = s.opencodePid ? `pid=${s.opencodePid}` : "";
|
|
70
|
+
const sess = s.opencodeSessionId ? `session=${(s.opencodeSessionId as string).slice(0, 8)}` : "";
|
|
71
|
+
const extras = [pid, sess].filter(Boolean).join(", ");
|
|
72
|
+
console.log(` ${state} @${s.name} ${extras} id=${s.id}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function cmdIssue(id: string) {
|
|
77
|
+
const data = (await api(`/api/issues/${id}`)) as Record<string, unknown>;
|
|
78
|
+
console.log("ID: " + data.id);
|
|
79
|
+
console.log("Tracker: " + data.trackerType);
|
|
80
|
+
console.log("Scope: " + data.trackerScopeKey);
|
|
81
|
+
console.log("Issue ID: " + data.trackerIssueId);
|
|
82
|
+
console.log("State: " + fmtState(data.state as string));
|
|
83
|
+
console.log("Title: " + data.title);
|
|
84
|
+
console.log("Created: " + data.createdAt);
|
|
85
|
+
const sessions = data.sessions as Record<string, unknown>[] | undefined;
|
|
86
|
+
if (sessions && sessions.length > 0) {
|
|
87
|
+
console.log("Sessions:");
|
|
88
|
+
for (const s of sessions) {
|
|
89
|
+
console.log(` @${s.name} ${fmtState(s.state as string)} id=${s.id}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function cmdSession(id: string) {
|
|
95
|
+
const data = await api(`/api/sessions/${id}`) as Record<string, unknown>;
|
|
96
|
+
console.log("ID: " + data.id);
|
|
97
|
+
console.log("Name: " + data.name);
|
|
98
|
+
console.log("State: " + fmtState(data.state as string));
|
|
99
|
+
console.log("Issue ID: " + data.issueId);
|
|
100
|
+
console.log("Workdir: " + (data.workdir || "-"));
|
|
101
|
+
console.log("PID: " + (data.opencodePid || "-"));
|
|
102
|
+
console.log("Session: " + (data.opencodeSessionId || "-"));
|
|
103
|
+
console.log("Created: " + data.createdAt);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function cmdSessionMessages(id: string) {
|
|
107
|
+
const messages = (await api(`/api/sessions/${id}/messages`)) as Record<string, unknown>[];
|
|
108
|
+
if (messages.length === 0) {
|
|
109
|
+
console.log("No messages.");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
for (const m of messages) {
|
|
113
|
+
const status = fmtState(m.status as string);
|
|
114
|
+
const preview = (m.content as string).slice(0, 60).replace(/\n/g, " ");
|
|
115
|
+
console.log(` ${status} ${preview}... id=${(m.id as string).slice(0, 8)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function cmdQueue() {
|
|
120
|
+
const q = (await api("/api/queue")) as Record<string, number>;
|
|
121
|
+
const entries = Object.entries(q);
|
|
122
|
+
if (entries.length === 0) {
|
|
123
|
+
console.log("Queue empty.");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
for (const [key, depth] of entries) {
|
|
127
|
+
console.log(` ${key}: ${depth}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function cmdProcesses() {
|
|
132
|
+
const procs = (await api("/api/processes")) as Record<string, unknown>[];
|
|
133
|
+
if (procs.length === 0) {
|
|
134
|
+
console.log("No running processes.");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
for (const p of procs) {
|
|
138
|
+
console.log(
|
|
139
|
+
` ▶ ${p.key} pid=${p.pid} last=${fmtTime(p.lastOutputAt as number | null)}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function cmdRetry(msgId: string) {
|
|
145
|
+
const res = (await api(`/api/messages/${msgId}/retry`, { method: "PATCH" })) as Record<string, unknown>;
|
|
146
|
+
console.log(`Retried: ${res.ok} id=${msgId}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function cmdStop(key: string) {
|
|
150
|
+
const res = (await api(`/api/processes/${encodeURIComponent(key)}`, { method: "DELETE" })) as Record<string, unknown>;
|
|
151
|
+
console.log(`Stopped: ${res.stopped}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Main ───
|
|
155
|
+
|
|
156
|
+
function usage() {
|
|
157
|
+
console.log(`Usage: ework-daemon <command> [args...]
|
|
158
|
+
|
|
159
|
+
Commands:
|
|
160
|
+
status Daemon overview
|
|
161
|
+
issues List all issues
|
|
162
|
+
issue <id> Show issue detail + sessions
|
|
163
|
+
sessions List all sessions
|
|
164
|
+
session <id> Show session detail
|
|
165
|
+
messages <sessionId> Show messages for a session
|
|
166
|
+
queue Show pending queues
|
|
167
|
+
processes Show running processes
|
|
168
|
+
retry <msgId> Retry a failed message
|
|
169
|
+
stop <key> Kill process by runtime key
|
|
170
|
+
|
|
171
|
+
Environment:
|
|
172
|
+
EDAEMON_URL Daemon URL (default: http://localhost:3101)
|
|
173
|
+
`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const args = process.argv.slice(2);
|
|
177
|
+
const cmd = args[0];
|
|
178
|
+
|
|
179
|
+
if (!cmd) {
|
|
180
|
+
usage();
|
|
181
|
+
process.exit(0);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const commands: Record<string, () => Promise<void>> = {
|
|
185
|
+
status: cmdStatus,
|
|
186
|
+
issues: cmdIssues,
|
|
187
|
+
sessions: cmdSessions,
|
|
188
|
+
queue: cmdQueue,
|
|
189
|
+
processes: cmdProcesses,
|
|
190
|
+
issue: async () => {
|
|
191
|
+
if (!args[1]) { console.error("Usage: ework-daemon issue <id>"); process.exit(1); }
|
|
192
|
+
await cmdIssue(args[1]);
|
|
193
|
+
},
|
|
194
|
+
session: async () => {
|
|
195
|
+
if (!args[1]) { console.error("Usage: ework-daemon session <id>"); process.exit(1); }
|
|
196
|
+
await cmdSession(args[1]);
|
|
197
|
+
},
|
|
198
|
+
messages: async () => {
|
|
199
|
+
if (!args[1]) { console.error("Usage: ework-daemon messages <sessionId>"); process.exit(1); }
|
|
200
|
+
await cmdSessionMessages(args[1]);
|
|
201
|
+
},
|
|
202
|
+
retry: async () => {
|
|
203
|
+
if (!args[1]) { console.error("Usage: ework-daemon retry <msgId>"); process.exit(1); }
|
|
204
|
+
await cmdRetry(args[1]);
|
|
205
|
+
},
|
|
206
|
+
stop: async () => {
|
|
207
|
+
if (!args[1]) { console.error("Usage: ework-daemon stop <key>"); process.exit(1); }
|
|
208
|
+
await cmdStop(args[1]);
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
if (!commands[cmd]) {
|
|
213
|
+
console.error(`Unknown command: ${cmd}`);
|
|
214
|
+
usage();
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
commands[cmd]!();
|