omoclaw 1.0.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 +77 -0
- package/dist/config.d.ts +13 -0
- package/dist/dedup.d.ts +6 -0
- package/dist/handlers/agent.d.ts +3 -0
- package/dist/handlers/permission.d.ts +5 -0
- package/dist/handlers/session-status.d.ts +5 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +381 -0
- package/dist/state.d.ts +24 -0
- package/dist/timers.d.ts +13 -0
- package/dist/webhook.d.ts +2 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 onpe5679
|
|
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,77 @@
|
|
|
1
|
+
# omoclaw
|
|
2
|
+
Native event-driven monitoring for OpenCode sessions
|
|
3
|
+
|
|
4
|
+
[](https://www.npmjs.com/package/omoclaw)
|
|
5
|
+
|
|
6
|
+
OpenCode plugin that monitors session state changes and sends webhook notifications. Replaces tmux-based scraping daemons with native event-driven monitoring.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Add the plugin to your `~/.config/opencode/opencode.json` file:
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
{
|
|
14
|
+
"plugin": ["omoclaw@latest"]
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
OpenCode will automatically install the plugin on the next restart.
|
|
19
|
+
|
|
20
|
+
## Configuration
|
|
21
|
+
|
|
22
|
+
Create a configuration file at `~/.config/opencode/opencode-monitor.json`:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"webhook": {
|
|
27
|
+
"url": "http://your-webhook-endpoint/hooks/wake",
|
|
28
|
+
"token": "your-bearer-token"
|
|
29
|
+
},
|
|
30
|
+
"staleTimeoutMs": 900000,
|
|
31
|
+
"permissionReminderMs": 120000,
|
|
32
|
+
"dedupTtlMs": 30000,
|
|
33
|
+
"idleConfirmDelayMs": 1500,
|
|
34
|
+
"debug": false,
|
|
35
|
+
"enabled": true
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Configuration Fields
|
|
40
|
+
|
|
41
|
+
- **webhook.url**: The endpoint where notifications will be sent.
|
|
42
|
+
- **webhook.token**: Bearer token for authentication.
|
|
43
|
+
- **staleTimeoutMs**: Timeout for busy sessions (default: 15 minutes).
|
|
44
|
+
- **permissionReminderMs**: Interval to re-notify when waiting for permissions (default: 2 minutes).
|
|
45
|
+
- **dedupTtlMs**: Time window for deduplicating similar notifications (default: 30 seconds).
|
|
46
|
+
- **idleConfirmDelayMs**: Delay before confirming an idle state.
|
|
47
|
+
- **debug**: Enable file logging to `/tmp/opencode-monitor-debug.log`.
|
|
48
|
+
- **enabled**: Toggle the plugin on or off.
|
|
49
|
+
|
|
50
|
+
## Features
|
|
51
|
+
|
|
52
|
+
- Session state monitoring (idle/busy/error/retry transitions)
|
|
53
|
+
- Permission wait detection with reminders
|
|
54
|
+
- Agent tracking (Prometheus -> Atlas -> Hephaestus chain)
|
|
55
|
+
- Stale session detection (default: 15 minutes)
|
|
56
|
+
- Deduplication to prevent webhook spam
|
|
57
|
+
- Completion notification with full agent chain
|
|
58
|
+
|
|
59
|
+
## Webhook Payload Format
|
|
60
|
+
|
|
61
|
+
The plugin sends a POST request to the configured URL.
|
|
62
|
+
|
|
63
|
+
### Headers
|
|
64
|
+
- **Authorization**: Bearer <token>
|
|
65
|
+
- **Content-Type**: application/json
|
|
66
|
+
|
|
67
|
+
### Body
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"text": "[OpenCode] ...",
|
|
71
|
+
"mode": "now"
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface MonitorConfig {
|
|
2
|
+
webhook: {
|
|
3
|
+
url: string;
|
|
4
|
+
token: string;
|
|
5
|
+
};
|
|
6
|
+
staleTimeoutMs: number;
|
|
7
|
+
permissionReminderMs: number;
|
|
8
|
+
dedupTtlMs: number;
|
|
9
|
+
idleConfirmDelayMs: number;
|
|
10
|
+
debug: boolean;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function loadConfig(): MonitorConfig;
|
package/dist/dedup.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Event } from "@opencode-ai/sdk";
|
|
2
|
+
import type { MonitorConfig } from "../config";
|
|
3
|
+
import type { DedupEngine } from "../dedup";
|
|
4
|
+
import type { TimerManager } from "../timers";
|
|
5
|
+
export declare function handlePermissionEvent(event: Event, _config: MonitorConfig, dedup: DedupEngine, timers: TimerManager, webhookFn: (text: string) => void): void;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Event } from "@opencode-ai/sdk";
|
|
2
|
+
import type { DedupEngine } from "../dedup";
|
|
3
|
+
import type { SessionStateTracker } from "../state";
|
|
4
|
+
import type { TimerManager } from "../timers";
|
|
5
|
+
export declare function handleSessionStatusEvent(event: Event, dedup: DedupEngine, state: SessionStateTracker, timers: TimerManager, webhookFn: (text: string) => void, debugLog?: (msg: string) => void): void;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/index.ts
|
|
3
|
+
import fs2 from "fs";
|
|
4
|
+
|
|
5
|
+
// src/config.ts
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
import os from "os";
|
|
8
|
+
import path from "path";
|
|
9
|
+
var DEFAULT_CONFIG = {
|
|
10
|
+
webhook: {
|
|
11
|
+
url: "",
|
|
12
|
+
token: ""
|
|
13
|
+
},
|
|
14
|
+
staleTimeoutMs: 900000,
|
|
15
|
+
permissionReminderMs: 120000,
|
|
16
|
+
dedupTtlMs: 30000,
|
|
17
|
+
idleConfirmDelayMs: 1500,
|
|
18
|
+
debug: false,
|
|
19
|
+
enabled: true
|
|
20
|
+
};
|
|
21
|
+
function stripJsonComments(input) {
|
|
22
|
+
const noBlock = input.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
23
|
+
return noBlock.replace(/^\s*\/\/.*$/gm, "");
|
|
24
|
+
}
|
|
25
|
+
function asObject(value) {
|
|
26
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function loadConfig() {
|
|
32
|
+
const filePath = path.join(os.homedir(), ".config", "opencode", "opencode-monitor.json");
|
|
33
|
+
try {
|
|
34
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
35
|
+
const parsed = JSON.parse(stripJsonComments(raw));
|
|
36
|
+
const root = asObject(parsed);
|
|
37
|
+
if (!root) {
|
|
38
|
+
return DEFAULT_CONFIG;
|
|
39
|
+
}
|
|
40
|
+
const webhook = asObject(root.webhook);
|
|
41
|
+
return {
|
|
42
|
+
webhook: {
|
|
43
|
+
url: typeof webhook?.url === "string" ? webhook.url : DEFAULT_CONFIG.webhook.url,
|
|
44
|
+
token: typeof webhook?.token === "string" ? webhook.token : DEFAULT_CONFIG.webhook.token
|
|
45
|
+
},
|
|
46
|
+
staleTimeoutMs: typeof root.staleTimeoutMs === "number" ? root.staleTimeoutMs : DEFAULT_CONFIG.staleTimeoutMs,
|
|
47
|
+
permissionReminderMs: typeof root.permissionReminderMs === "number" ? root.permissionReminderMs : DEFAULT_CONFIG.permissionReminderMs,
|
|
48
|
+
dedupTtlMs: typeof root.dedupTtlMs === "number" ? root.dedupTtlMs : DEFAULT_CONFIG.dedupTtlMs,
|
|
49
|
+
idleConfirmDelayMs: typeof root.idleConfirmDelayMs === "number" ? root.idleConfirmDelayMs : DEFAULT_CONFIG.idleConfirmDelayMs,
|
|
50
|
+
debug: typeof root.debug === "boolean" ? root.debug : DEFAULT_CONFIG.debug,
|
|
51
|
+
enabled: typeof root.enabled === "boolean" ? root.enabled : DEFAULT_CONFIG.enabled
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
return DEFAULT_CONFIG;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/dedup.ts
|
|
59
|
+
class DedupEngine {
|
|
60
|
+
ttlMs;
|
|
61
|
+
seen = new Map;
|
|
62
|
+
constructor(ttlMs) {
|
|
63
|
+
this.ttlMs = ttlMs;
|
|
64
|
+
}
|
|
65
|
+
shouldSend(key) {
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const prev = this.seen.get(key);
|
|
68
|
+
if (this.seen.size > 100) {
|
|
69
|
+
for (const [existingKey, timestamp] of this.seen.entries()) {
|
|
70
|
+
if (now - timestamp > this.ttlMs) {
|
|
71
|
+
this.seen.delete(existingKey);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (prev !== undefined && now - prev <= this.ttlMs) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
this.seen.set(key, now);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/handlers/agent.ts
|
|
84
|
+
function handleAgentEvent(event, state) {
|
|
85
|
+
if (event.type === "message.updated") {
|
|
86
|
+
const info = event.properties?.info;
|
|
87
|
+
const agent = info?.agent;
|
|
88
|
+
const sessionID = info?.sessionID;
|
|
89
|
+
if (typeof agent === "string" && typeof sessionID === "string") {
|
|
90
|
+
state.setAgent(sessionID, agent);
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (event.type === "message.part.updated") {
|
|
95
|
+
const { part } = event.properties;
|
|
96
|
+
if (part.type === "agent" && part.name) {
|
|
97
|
+
state.setAgent(part.sessionID, part.name);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/handlers/permission.ts
|
|
103
|
+
function handlePermissionEvent(event, _config, dedup, timers, webhookFn) {
|
|
104
|
+
if (event.type === "permission.updated") {
|
|
105
|
+
const permission = event.properties;
|
|
106
|
+
const dedupKey = `perm:${permission.id}`;
|
|
107
|
+
if (dedup.shouldSend(dedupKey)) {
|
|
108
|
+
webhookFn(`[OpenCode] Permission wait: ${permission.title} (${permission.sessionID})`);
|
|
109
|
+
}
|
|
110
|
+
timers.startPermissionReminder(permission.id, permission.title, permission.sessionID);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (event.type === "permission.replied") {
|
|
114
|
+
timers.clearPermissionReminder(event.properties.permissionID);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/handlers/session-status.ts
|
|
119
|
+
function shortSessionID(sessionID) {
|
|
120
|
+
return sessionID.slice(0, 12);
|
|
121
|
+
}
|
|
122
|
+
function buildAgentChain(state, sessionID) {
|
|
123
|
+
const history = state.getAgentHistory(sessionID);
|
|
124
|
+
if (history.length === 0) {
|
|
125
|
+
return "unknown-agent";
|
|
126
|
+
}
|
|
127
|
+
return history.join(" -> ");
|
|
128
|
+
}
|
|
129
|
+
function toSessionStatus(statusType) {
|
|
130
|
+
if (statusType === "idle" || statusType === "busy" || statusType === "retry" || statusType === "error" || statusType === "unknown") {
|
|
131
|
+
return statusType;
|
|
132
|
+
}
|
|
133
|
+
return "unknown";
|
|
134
|
+
}
|
|
135
|
+
function handleSessionStatusEvent(event, dedup, state, timers, webhookFn, debugLog) {
|
|
136
|
+
const log = debugLog ?? (() => {});
|
|
137
|
+
if (event.type === "session.status") {
|
|
138
|
+
const sessionID = event.properties.sessionID;
|
|
139
|
+
const status = event.properties.status;
|
|
140
|
+
const statusType = toSessionStatus(status.type);
|
|
141
|
+
const transition = state.transition(sessionID, statusType);
|
|
142
|
+
if (transition.changed && transition.current === "busy") {
|
|
143
|
+
state.setBusySince(sessionID, Date.now());
|
|
144
|
+
timers.startStaleTimer(sessionID);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (transition.changed && transition.current === "idle" && transition.prev === "busy") {
|
|
148
|
+
log(`busy\u2192idle transition detected for ${shortSessionID(sessionID)}`);
|
|
149
|
+
const dedupKey = `idle:${sessionID}`;
|
|
150
|
+
if (dedup.shouldSend(dedupKey)) {
|
|
151
|
+
const agentChain = buildAgentChain(state, sessionID);
|
|
152
|
+
log(`Sending webhook: agentChain=${agentChain}`);
|
|
153
|
+
webhookFn(`[OpenCode] Session completed (${shortSessionID(sessionID)}) \u2014 ${agentChain}`);
|
|
154
|
+
}
|
|
155
|
+
timers.clearStaleTimer(sessionID);
|
|
156
|
+
state.clearSession(sessionID);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (transition.current === "retry" && status.type === "retry") {
|
|
160
|
+
const dedupKey = `retry:${sessionID}:${status.attempt}`;
|
|
161
|
+
if (dedup.shouldSend(dedupKey)) {
|
|
162
|
+
webhookFn(`[OpenCode] Retry #${status.attempt}: ${status.message} (${shortSessionID(sessionID)})`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (event.type === "session.error") {
|
|
168
|
+
const sessionID = event.properties.sessionID;
|
|
169
|
+
if (!sessionID) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
state.transition(sessionID, "error");
|
|
173
|
+
timers.clearStaleTimer(sessionID);
|
|
174
|
+
const errorName = event.properties.error?.name ?? "UnknownError";
|
|
175
|
+
const dedupKey = `error:${sessionID}:${errorName}`;
|
|
176
|
+
if (dedup.shouldSend(dedupKey)) {
|
|
177
|
+
webhookFn(`[OpenCode] Error: ${errorName} (${shortSessionID(sessionID)})`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/state.ts
|
|
183
|
+
class SessionStateTracker {
|
|
184
|
+
states = new Map;
|
|
185
|
+
agentHistory = new Map;
|
|
186
|
+
transition(sessionID, newStatus) {
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
const existing = this.states.get(sessionID);
|
|
189
|
+
if (!existing) {
|
|
190
|
+
this.states.set(sessionID, {
|
|
191
|
+
status: newStatus,
|
|
192
|
+
agent: undefined,
|
|
193
|
+
busySince: newStatus === "busy" ? now : undefined,
|
|
194
|
+
lastTransition: now
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
changed: true,
|
|
198
|
+
prev: "unknown",
|
|
199
|
+
current: newStatus
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (existing.status === newStatus) {
|
|
203
|
+
return {
|
|
204
|
+
changed: false,
|
|
205
|
+
prev: existing.status,
|
|
206
|
+
current: existing.status
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const prev = existing.status;
|
|
210
|
+
existing.status = newStatus;
|
|
211
|
+
existing.lastTransition = now;
|
|
212
|
+
if (newStatus === "busy") {
|
|
213
|
+
existing.busySince = now;
|
|
214
|
+
}
|
|
215
|
+
if (newStatus === "idle") {
|
|
216
|
+
existing.busySince = undefined;
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
changed: true,
|
|
220
|
+
prev,
|
|
221
|
+
current: newStatus
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
setAgent(sessionID, agentName) {
|
|
225
|
+
const existing = this.states.get(sessionID);
|
|
226
|
+
if (existing) {
|
|
227
|
+
existing.agent = agentName;
|
|
228
|
+
} else {
|
|
229
|
+
this.states.set(sessionID, {
|
|
230
|
+
status: "unknown",
|
|
231
|
+
agent: agentName,
|
|
232
|
+
busySince: undefined,
|
|
233
|
+
lastTransition: Date.now()
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
const history = this.agentHistory.get(sessionID) ?? [];
|
|
237
|
+
if (history[history.length - 1] !== agentName) {
|
|
238
|
+
history.push(agentName);
|
|
239
|
+
}
|
|
240
|
+
this.agentHistory.set(sessionID, history);
|
|
241
|
+
}
|
|
242
|
+
getAgent(sessionID) {
|
|
243
|
+
return this.states.get(sessionID)?.agent;
|
|
244
|
+
}
|
|
245
|
+
getState(sessionID) {
|
|
246
|
+
return this.states.get(sessionID);
|
|
247
|
+
}
|
|
248
|
+
getAgentHistory(sessionID) {
|
|
249
|
+
return [...this.agentHistory.get(sessionID) ?? []];
|
|
250
|
+
}
|
|
251
|
+
setBusySince(sessionID, time) {
|
|
252
|
+
const existing = this.states.get(sessionID);
|
|
253
|
+
if (existing) {
|
|
254
|
+
existing.busySince = time;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
this.states.set(sessionID, {
|
|
258
|
+
status: "busy",
|
|
259
|
+
agent: undefined,
|
|
260
|
+
busySince: time,
|
|
261
|
+
lastTransition: Date.now()
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
clearSession(sessionID) {
|
|
265
|
+
this.states.delete(sessionID);
|
|
266
|
+
this.agentHistory.delete(sessionID);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// src/timers.ts
|
|
271
|
+
class TimerManager {
|
|
272
|
+
config;
|
|
273
|
+
webhookFn;
|
|
274
|
+
staleTimers = new Map;
|
|
275
|
+
permissionTimers = new Map;
|
|
276
|
+
constructor(config, webhookFn) {
|
|
277
|
+
this.config = config;
|
|
278
|
+
this.webhookFn = webhookFn;
|
|
279
|
+
}
|
|
280
|
+
startStaleTimer(sessionID) {
|
|
281
|
+
this.clearStaleTimer(sessionID);
|
|
282
|
+
const timer = setTimeout(() => {
|
|
283
|
+
this.webhookFn(`[OpenCode] Session stale: ${sessionID.slice(0, 12)} (${sessionID})`);
|
|
284
|
+
this.staleTimers.delete(sessionID);
|
|
285
|
+
}, this.config.staleTimeoutMs);
|
|
286
|
+
this.staleTimers.set(sessionID, timer);
|
|
287
|
+
}
|
|
288
|
+
clearStaleTimer(sessionID) {
|
|
289
|
+
const timer = this.staleTimers.get(sessionID);
|
|
290
|
+
if (!timer) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
clearTimeout(timer);
|
|
294
|
+
this.staleTimers.delete(sessionID);
|
|
295
|
+
}
|
|
296
|
+
startPermissionReminder(permissionID, title, sessionID) {
|
|
297
|
+
this.clearPermissionReminder(permissionID);
|
|
298
|
+
const timer = setTimeout(() => {
|
|
299
|
+
this.webhookFn(`[OpenCode] Permission still waiting: ${title} (${sessionID})`);
|
|
300
|
+
this.permissionTimers.delete(permissionID);
|
|
301
|
+
}, this.config.permissionReminderMs);
|
|
302
|
+
this.permissionTimers.set(permissionID, { sessionID, timer });
|
|
303
|
+
}
|
|
304
|
+
clearPermissionReminder(permissionID) {
|
|
305
|
+
const existing = this.permissionTimers.get(permissionID);
|
|
306
|
+
if (!existing) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
clearTimeout(existing.timer);
|
|
310
|
+
this.permissionTimers.delete(permissionID);
|
|
311
|
+
}
|
|
312
|
+
clearSession(sessionID) {
|
|
313
|
+
this.clearStaleTimer(sessionID);
|
|
314
|
+
for (const [permissionID, entry] of this.permissionTimers.entries()) {
|
|
315
|
+
if (entry.sessionID === sessionID) {
|
|
316
|
+
clearTimeout(entry.timer);
|
|
317
|
+
this.permissionTimers.delete(permissionID);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/webhook.ts
|
|
324
|
+
function sendWebhook(config, text) {
|
|
325
|
+
fetch(config.webhook.url, {
|
|
326
|
+
method: "POST",
|
|
327
|
+
headers: {
|
|
328
|
+
Authorization: `Bearer ${config.webhook.token}`,
|
|
329
|
+
"Content-Type": "application/json"
|
|
330
|
+
},
|
|
331
|
+
body: JSON.stringify({ text, mode: "now" })
|
|
332
|
+
}).then(() => {
|
|
333
|
+
console.log("[monitor] webhook sent:", text);
|
|
334
|
+
}).catch((error) => {
|
|
335
|
+
console.error("[monitor] webhook error:", error);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/index.ts
|
|
340
|
+
var DEBUG_LOG_PATH = "/tmp/opencode-monitor-debug.log";
|
|
341
|
+
var SessionMonitorPlugin = async (_ctx) => {
|
|
342
|
+
const config = loadConfig();
|
|
343
|
+
const debugLog = config.debug ? (msg) => {
|
|
344
|
+
const line = `[${new Date().toISOString()}] ${msg}
|
|
345
|
+
`;
|
|
346
|
+
try {
|
|
347
|
+
fs2.appendFileSync(DEBUG_LOG_PATH, line);
|
|
348
|
+
} catch {}
|
|
349
|
+
} : undefined;
|
|
350
|
+
debugLog?.("Plugin initializing...");
|
|
351
|
+
debugLog?.(`Config loaded: enabled=${config.enabled}, url=${config.webhook.url}`);
|
|
352
|
+
if (!config.enabled) {
|
|
353
|
+
debugLog?.("Plugin disabled, exiting.");
|
|
354
|
+
return {};
|
|
355
|
+
}
|
|
356
|
+
if (!config.webhook.url || !config.webhook.token) {
|
|
357
|
+
console.log("[monitor] Webhook not configured. Set webhook.url and webhook.token in ~/.config/opencode/opencode-monitor.json");
|
|
358
|
+
return {};
|
|
359
|
+
}
|
|
360
|
+
const dedup = new DedupEngine(config.dedupTtlMs);
|
|
361
|
+
const state = new SessionStateTracker;
|
|
362
|
+
const webhookFn = (text) => {
|
|
363
|
+
debugLog?.(`WEBHOOK: ${text}`);
|
|
364
|
+
sendWebhook(config, text);
|
|
365
|
+
};
|
|
366
|
+
const timers = new TimerManager(config, webhookFn);
|
|
367
|
+
debugLog?.("Plugin loaded successfully. Waiting for events...");
|
|
368
|
+
console.log("[monitor] Session monitor plugin loaded");
|
|
369
|
+
return {
|
|
370
|
+
event: async ({ event }) => {
|
|
371
|
+
debugLog?.(`EVENT: ${event.type} props=${JSON.stringify(event.properties).slice(0, 200)}`);
|
|
372
|
+
handleAgentEvent(event, state);
|
|
373
|
+
handlePermissionEvent(event, config, dedup, timers, webhookFn);
|
|
374
|
+
handleSessionStatusEvent(event, dedup, state, timers, webhookFn, debugLog);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
};
|
|
378
|
+
var src_default = SessionMonitorPlugin;
|
|
379
|
+
export {
|
|
380
|
+
src_default as default
|
|
381
|
+
};
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
type SessionStatus = "idle" | "busy" | "retry" | "error" | "unknown";
|
|
2
|
+
interface SessionState {
|
|
3
|
+
status: SessionStatus;
|
|
4
|
+
agent: string | undefined;
|
|
5
|
+
busySince: number | undefined;
|
|
6
|
+
lastTransition: number;
|
|
7
|
+
}
|
|
8
|
+
interface TransitionResult {
|
|
9
|
+
changed: boolean;
|
|
10
|
+
prev: SessionStatus;
|
|
11
|
+
current: SessionStatus;
|
|
12
|
+
}
|
|
13
|
+
export declare class SessionStateTracker {
|
|
14
|
+
private readonly states;
|
|
15
|
+
private readonly agentHistory;
|
|
16
|
+
transition(sessionID: string, newStatus: SessionStatus): TransitionResult;
|
|
17
|
+
setAgent(sessionID: string, agentName: string): void;
|
|
18
|
+
getAgent(sessionID: string): string | undefined;
|
|
19
|
+
getState(sessionID: string): SessionState | undefined;
|
|
20
|
+
getAgentHistory(sessionID: string): string[];
|
|
21
|
+
setBusySince(sessionID: string, time: number): void;
|
|
22
|
+
clearSession(sessionID: string): void;
|
|
23
|
+
}
|
|
24
|
+
export type { SessionStatus };
|
package/dist/timers.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { MonitorConfig } from "./config";
|
|
2
|
+
export declare class TimerManager {
|
|
3
|
+
private readonly config;
|
|
4
|
+
private readonly webhookFn;
|
|
5
|
+
private readonly staleTimers;
|
|
6
|
+
private readonly permissionTimers;
|
|
7
|
+
constructor(config: MonitorConfig, webhookFn: (text: string) => void);
|
|
8
|
+
startStaleTimer(sessionID: string): void;
|
|
9
|
+
clearStaleTimer(sessionID: string): void;
|
|
10
|
+
startPermissionReminder(permissionID: string, title: string, sessionID: string): void;
|
|
11
|
+
clearPermissionReminder(permissionID: string): void;
|
|
12
|
+
clearSession(sessionID: string): void;
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "omoclaw",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenCode session monitor plugin — native event-driven webhook notifications for session state changes",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "bun build src/index.ts --outdir dist --target bun --format esm && tsc --emitDeclarationOnly",
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"test": "bun test",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"opencode",
|
|
19
|
+
"plugin",
|
|
20
|
+
"monitor",
|
|
21
|
+
"webhook"
|
|
22
|
+
],
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/onpe5679/omoclaw.git"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@opencode-ai/plugin": "^1.1.19",
|
|
30
|
+
"@opencode-ai/sdk": "^1.1.19"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"bun-types": "^1.3.6",
|
|
34
|
+
"typescript": "^5.7.3"
|
|
35
|
+
}
|
|
36
|
+
}
|